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
+54
View File
@@ -0,0 +1,54 @@
import { AppTopbar } from './components/topbar.js';
import { AppSidebar } from './components/sidebar.js';
import { AppCopilot } from './components/copilot.js';
import { LlmProvidersPage } from './components/llm-providers.js';
import { ModelsHubPage } from './components/models-hub.js';
import { ModelsLlmSection } from './components/models-llm.js';
import { ModelsTranscribeSection } from './components/models-transcribe.js';
import { ModelsImageSection } from './components/models-image.js';
import { ModelsTtsSection } from './components/models-tts.js';
import { TasksPage } from './components/tasks/index.js';
import { AgentsPage } from './components/agents.js';
import { ApprovalGroupsPage } from './components/approval-groups.js';
import { ApprovalRulesPage } from './components/approval-rules.js';
import { ConfigPage } from './components/config-page.js';
import { AgentInboxPage } from './components/agent-inbox.js';
import { HomePage } from './components/home-page.js';
import { LlmRequestsPage } from './components/llm-requests.js';
import { LlmRequestDetail } from './components/llm-request-detail.js';
import { SessionDetailPage } from './components/session-detail.js';
import { TicSessionsPage } from './components/tic-sessions.js';
import { ProjectsPage } from './components/projects/index.js';
import { FileViewerPage } from './components/file-viewer-page.js';
// Register the global `openFile(path)` helper (window.openFile → location.hash).
import './lib/open-file.js';
customElements.define('app-topbar', AppTopbar);
customElements.define('app-sidebar', AppSidebar);
customElements.define('app-copilot', AppCopilot);
customElements.define('llm-providers-page', LlmProvidersPage);
customElements.define('models-hub-page', ModelsHubPage);
customElements.define('models-llm-section', ModelsLlmSection);
customElements.define('models-transcribe-section', ModelsTranscribeSection);
customElements.define('models-image-section', ModelsImageSection);
customElements.define('models-tts-section', ModelsTtsSection);
customElements.define('tasks-page', TasksPage);
customElements.define('agents-page', AgentsPage);
customElements.define('approval-groups-page', ApprovalGroupsPage);
customElements.define('approval-rules-page', ApprovalRulesPage);
customElements.define('config-page', ConfigPage);
customElements.define('agent-inbox-page', AgentInboxPage);
customElements.define('home-page', HomePage);
customElements.define('llm-requests-page', LlmRequestsPage);
customElements.define('llm-request-detail', LlmRequestDetail);
customElements.define('session-detail-page', SessionDetailPage);
customElements.define('tic-sessions-page', TicSessionsPage);
customElements.define('projects-page', ProjectsPage);
customElements.define('file-viewer-page', FileViewerPage);
// Toggle the workspace placeholder when an LLM page opens/closes.
const workspace = document.getElementById('app-workspace');
window.addEventListener('llm-page-change', (e) => {
workspace.style.display = e.detail.page ? 'none' : 'flex';
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

+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>
`;
}
}
+150
View File
@@ -0,0 +1,150 @@
/* Agent Inbox page — desktop layout. Card styles live in inbox-cards.css. */
agent-inbox-page {
display: none;
flex: 1;
min-width: 0;
overflow: hidden;
}
/* ── Page panel (shell shared with other full-page views) ── */
.page-panel {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.page-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
min-height: 54px;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
flex-shrink: 0;
}
.page-panel-header h5 {
margin: 0;
font-weight: 700;
font-size: 1.05rem;
display: flex;
align-items: center;
gap: 8px;
}
/* ── Refresh button ── */
.inbox-refresh-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
border: 1px solid var(--bs-border-color);
background: transparent;
color: var(--bs-secondary-color);
font-size: 1rem;
cursor: pointer;
transition: all 0.15s ease;
}
.inbox-refresh-btn:hover {
background: var(--bs-body-bg);
color: var(--bs-body-color);
border-color: var(--bs-secondary-color);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
.inbox-refresh-btn:active { transform: scale(0.94); }
/* ── Grid container ── */
.inbox-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
padding: 20px;
align-content: start;
overflow-y: auto;
flex: 1;
}
/* ── Section headers (span full grid width) ── */
.inbox-section-header {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
margin-top: 8px;
}
.inbox-section-header:first-child { margin-top: 0; }
.inbox-section-header h6 {
margin: 0;
font-weight: 600;
font-size: 0.95rem;
color: var(--bs-body-color);
}
.inbox-section-header .section-line {
flex: 1;
height: 1px;
background: var(--bs-border-color);
}
.inbox-section-header .badge {
font-size: 0.7rem;
padding: 2px 8px;
}
/* ── Bootstrap btn inside card footer ── */
.inbox-card-footer .btn {
flex: 1;
font-weight: 600;
font-size: 0.82rem;
padding: 7px 12px;
border-radius: 8px;
}
/* ── Mobile tweaks ── */
@media (max-width: 600px) {
.page-panel-header {
padding: 12px 14px;
min-height: 50px;
}
.inbox-grid {
grid-template-columns: 1fr;
padding: 12px;
gap: 12px;
}
.inbox-card-body { padding: 12px; }
.inbox-card-footer {
flex-direction: column;
}
.approval-footer {
grid-template-columns: 1fr;
}
.inbox-card-footer .btn {
width: 100%;
}
.inbox-answer-send {
width: 100%;
justify-content: center;
}
}
+298
View File
@@ -0,0 +1,298 @@
/* ── Agents page layout ───────────────────────────────────────────────────── */
agents-page {
display: none;
flex-direction: column;
flex: 1;
overflow-y: auto;
padding: 1.5rem 2rem;
background: var(--bs-body-bg);
}
.agents-page {
display: flex;
flex-direction: column;
width: 100%;
}
.agents-page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
/* ── Info banner ─────────────────────────────────────────────────────────── */
.agent-info-banner {
display: flex;
gap: 0.65rem;
align-items: flex-start;
font-size: 0.82rem;
line-height: 1.5;
padding: 0.8rem 1rem;
margin-bottom: 1.5rem;
border-radius: 8px;
background: rgba(var(--bs-primary-rgb), 0.06);
border: 1px solid rgba(var(--bs-primary-rgb), 0.15);
color: var(--bs-body-color);
}
.agent-info-banner-icon {
font-size: 1.1rem;
color: var(--bs-primary);
line-height: 1.4;
flex-shrink: 0;
}
.agent-info-banner-body p {
font-size: 0.82rem;
}
.agent-info-banner-body code {
font-size: 0.82em;
background: rgba(var(--bs-primary-rgb), 0.08);
padding: 0.08em 0.35em;
border-radius: 3px;
}
/* ── Agent cards (list view) ─────────────────────────────────────────────── */
.agent-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 0.75rem;
}
.agent-card {
border: 1px solid var(--bs-border-color);
border-radius: 8px;
padding: 0.5rem 0.5rem;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
background: var(--bs-body-bg);
}
.agent-card:hover {
border-color: var(--bs-primary);
box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.1);
}
.agent-card-body {
display: flex;
gap: 0.85rem;
align-items: flex-start;
}
.agent-card-icon {
width: 120px;
height: 160px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
background: var(--bs-tertiary-bg);
}
.agent-card-content {
flex: 1;
min-width: 0;
}
.agent-card-header {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.agent-card-name {
font-weight: 600;
font-size: 0.95rem;
}
.agent-card-id {
font-size: 0.72rem;
font-family: var(--bs-font-monospace);
}
.agent-card-desc {
font-size: 0.82rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.agent-card-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.agent-meta-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.78rem;
}
/* ── Shared pills & dots ─────────────────────────────────────────────────── */
.agent-strength-dot {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
}
.agent-scope-pill {
display: inline-block;
font-size: 0.7rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--bs-secondary-bg);
color: var(--bs-secondary-color);
border: 1px solid var(--bs-border-color);
}
/* ── Detail view ─────────────────────────────────────────────────────────── */
.agent-detail {
display: flex;
flex-direction: column;
gap: 0;
}
.agent-detail-header {
margin-bottom: 1.5rem;
}
.agent-detail-title-row {
display: flex;
align-items: flex-start;
gap: 1.25rem;
margin-top: 0.3rem;
}
.agent-detail-icon {
width: 240px;
height: 312px;
border-radius: 10px;
object-fit: cover;
flex-shrink: 0;
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
}
.agent-detail-title {
font-size: 1.35rem;
font-weight: 700;
margin: 0 0 0.25rem;
}
.agent-detail-body {
display: flex;
flex-direction: column;
gap: 2rem;
}
.agent-section-title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--bs-secondary-color);
margin-bottom: 0.75rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--bs-border-color);
}
/* List view — agents grouped by role (Chat / Task Executors). */
.agent-group + .agent-group {
margin-top: 1.5rem;
}
.agent-group-title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--bs-secondary-color);
margin-bottom: 0.6rem;
}
/* ── Meta table ──────────────────────────────────────────────────────────── */
.agent-meta-table {
font-size: 0.85rem;
border-collapse: collapse;
}
.agent-meta-key {
color: var(--bs-secondary-color);
padding-right: 1.25rem;
padding-bottom: 0.35rem;
white-space: nowrap;
vertical-align: top;
}
/* ── Model resolution table ──────────────────────────────────────────────── */
.agent-model-table {
font-size: 0.83rem;
}
.agent-model-rank {
font-size: 0.75rem;
width: 2rem;
}
.agent-model-id {
font-family: var(--bs-font-monospace);
font-size: 0.78rem;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-model-row--first td {
background: rgba(var(--bs-success-rgb), 0.06);
}
/* ── System prompt ───────────────────────────────────────────────────────── */
.agent-prompt-body {
font-size: 0.88rem;
line-height: 1.6;
border: 1px solid var(--bs-border-color);
border-radius: 6px;
padding: 1rem 1.25rem;
background: var(--bs-secondary-bg);
max-height: 520px;
overflow-y: auto;
}
.agent-prompt-body h1,
.agent-prompt-body h2,
.agent-prompt-body h3 {
font-size: 1em;
font-weight: 700;
margin-top: 1em;
}
.agent-prompt-body code {
font-size: 0.82em;
}
.agent-prompt-body pre {
background: var(--bs-tertiary-bg);
border-radius: 4px;
padding: 0.6rem 0.8rem;
overflow-x: auto;
}
.agent-prompt-body ul,
.agent-prompt-body ol {
padding-left: 1.4em;
}
+469
View File
@@ -0,0 +1,469 @@
/* ── Approval Groups page ──────────────────────────────────────────────────── */
approval-groups-page {
display: none; /* toggled by JS */
flex: 1;
min-width: 0;
overflow-y: auto;
border-right: 1px solid var(--toolbar-border, #e5e9f0);
}
/* ── Dark mode overrides for action colors (shared with rules page) ─────────── */
@media (prefers-color-scheme: dark) {
.apr-card {
--apr-action-bg: rgba(234,179,8,0.18);
--apr-action-color: #fbbf24;
}
.apr-card[data-action="allow"] {
--apr-action-bg: rgba(34,197,94,0.18);
--apr-action-color: #4ade80;
}
.apr-card[data-action="deny"] {
--apr-action-bg: rgba(239,68,68,0.18);
--apr-action-color: #f87171;
}
}
/* ── Page shell ── */
.apr-page {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
max-width: 100%;
}
.apr-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
min-height: 54px;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
flex-shrink: 0;
gap: 12px;
}
.apr-title {
margin: 0;
font-weight: 700;
font-size: 1.05rem;
display: flex;
align-items: center;
gap: 8px;
}
.apr-header-right {
display: flex;
align-items: center;
gap: 10px;
}
.apr-header-count {
font-size: 0.76rem;
font-weight: 500;
color: var(--bs-secondary-color);
}
/* ── Card list ── */
.apr-card-list {
padding: 16px 20px;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── Single card ── */
.apr-card {
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 10px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.apr-card:hover {
border-color: color-mix(in srgb, var(--apr-action-color, #a16207) 40%, var(--bs-border-color));
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
/* ── Row 1: action badge + pattern + priority + actions ── */
.apr-card-row1 {
display: flex;
align-items: center;
gap: 10px;
}
.apr-action-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.25em 0.65em;
border-radius: 5px;
flex-shrink: 0;
background: var(--apr-action-bg, rgba(234,179,8,0.12));
color: var(--apr-action-color, #a16207);
}
.apr-action-badge i {
font-size: 0.7rem;
}
.apr-pattern {
font-family: var(--bs-font-monospace, monospace);
font-size: 0.82rem;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: var(--bs-tertiary-bg, rgba(0,0,0,0.02));
padding: 0.1em 0.45em;
border-radius: 4px;
}
.apr-priority-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.72rem;
font-weight: 500;
color: var(--bs-secondary-color, #6b7280);
flex-shrink: 0;
white-space: nowrap;
padding: 0.2em 0.5em;
background: var(--bs-tertiary-bg, #f0f2f5);
border-radius: 5px;
}
.apr-priority-badge i {
font-size: 0.65rem;
}
.apr-card-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
margin-left: auto;
}
.apr-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
background: transparent;
color: var(--bs-secondary-color, #9ca3af);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.1s;
}
.apr-btn-icon:hover {
background: var(--bs-tertiary-bg, #f0f2f5);
color: var(--bs-body-color, #374151);
}
.apr-btn-edit:hover {
color: var(--bs-primary, #0d6efd);
background: var(--bs-primary-bg-subtle, #eef2ff);
}
.apr-btn-delete:hover {
color: var(--bs-danger, #dc3545);
background: var(--bs-danger-bg-subtle, #fef0f0);
}
/* ── Row 2 / Row 3 ── */
.apr-card-row2 {
padding-left: 0;
}
.apr-card-row3 {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
/* ── Tags ── */
.apr-tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.74rem;
font-weight: 500;
padding: 0.2em 0.6em;
border-radius: 5px;
background: var(--bs-tertiary-bg, #f0f2f5);
color: var(--bs-secondary-color, #6b7280);
white-space: nowrap;
}
.apr-tag i {
font-size: 0.7rem;
}
.apr-tag code {
font-size: inherit;
background: transparent;
padding: 0;
color: inherit;
}
.apr-tag-note {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Empty state ── */
.apr-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: var(--bs-secondary-color, #9ca3af);
gap: 8px;
flex: 1;
}
.apr-empty i {
font-size: 2rem;
opacity: 0.4;
}
.apr-empty p {
margin: 0;
font-size: 0.9rem;
}
/* ── Form ── */
.apr-form {
margin: 16px 20px 0;
border: 1px solid var(--bs-border-color);
border-radius: 10px;
overflow: hidden;
background: var(--bs-body-bg);
flex-shrink: 0;
}
.apr-form-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
font-weight: 600;
font-size: 0.9rem;
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
}
.apr-form-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--bs-secondary-color);
border-radius: 6px;
margin-left: auto;
cursor: pointer;
font-size: 1.1rem;
}
.apr-form-close:hover {
background: var(--bs-secondary-bg, rgba(128,128,128,0.1));
color: var(--bs-body-color);
}
.apr-form-body {
padding: 1.25rem;
}
.apr-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 1rem;
margin-top: 0.5rem;
border-top: 1px solid var(--bs-border-color);
}
/* ── Tool picker (used inside Override/LowPrio form) ── */
.apr-tool-picker {
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 6px;
padding: 0.5rem;
background: var(--bs-tertiary-bg, rgba(0,0,0,0.02));
}
.apr-tool-list {
max-height: 240px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1px;
}
.apr-tool-group-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bs-secondary-color, #6c757d);
padding: 0.4rem 0.4rem 0.1rem;
margin-top: 0.25rem;
}
.apr-tool-item {
display: flex;
align-items: baseline;
gap: 0.6rem;
width: 100%;
padding: 0.3rem 0.4rem;
border: none;
border-radius: 4px;
background: transparent;
text-align: left;
cursor: pointer;
color: var(--bs-body-color);
transition: background 0.12s;
}
.apr-tool-item:hover {
background: var(--bs-secondary-bg, rgba(128,128,128,0.1));
}
.apr-tool-item.selected {
background: var(--bs-primary-bg-subtle, #cfe2ff);
color: var(--bs-primary, #0d6efd);
}
.apr-tool-name {
font-size: 0.78rem;
flex-shrink: 0;
}
.apr-tool-desc {
font-size: 0.75rem;
color: var(--bs-secondary-color, #6c757d);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Group card ── */
.apr-group-card {
cursor: pointer;
}
.apr-group-card:hover {
border-color: var(--bs-primary, #0d6efd);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.apr-group-name {
font-weight: 600;
font-size: 0.92rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apr-group-default-badge {
display: inline-flex;
align-items: center;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.2em 0.55em;
border-radius: 4px;
flex-shrink: 0;
background: var(--bs-secondary-bg, rgba(100,116,139,0.15));
color: var(--bs-secondary-color, #64748b);
}
/* ── Mobile ── */
@media (max-width: 600px) {
.apr-header {
padding: 10px 14px;
min-height: 50px;
}
.apr-card-list {
padding: 10px 12px;
gap: 8px;
}
.apr-card {
padding: 12px;
gap: 6px;
}
.apr-card-row1 {
gap: 7px;
}
.apr-action-badge {
font-size: 0.66rem;
padding: 0.2em 0.5em;
}
.apr-card-actions {
gap: 0;
}
.apr-btn-icon {
width: 28px;
height: 28px;
font-size: 0.8rem;
}
.apr-tag {
font-size: 0.7rem;
}
.apr-tag-note {
max-width: 180px;
}
.apr-form {
margin: 10px 12px 0;
}
.apr-form-body {
padding: 1rem;
}
}
+373
View File
@@ -0,0 +1,373 @@
/* ── Approval Rules (per-group view) ───────────────────────────────────────── */
approval-rules-page {
display: none; /* toggled by JS */
flex: 1;
min-width: 0;
overflow-y: auto;
border-right: 1px solid var(--toolbar-border, #e5e9f0);
}
/* ── Rules body ── */
.apr-rules-body {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ── Side panels (Override / Low Priority) ── */
.apr-side-panel {
flex-shrink: 0;
border-bottom: 1px solid var(--bs-border-color);
}
.apr-side-panel-header {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 20px;
cursor: pointer;
user-select: none;
background: var(--bs-tertiary-bg);
transition: background 0.12s;
}
.apr-side-panel-header:hover {
background: color-mix(in srgb, var(--bs-tertiary-bg) 80%, var(--bs-border-color));
}
.apr-panel-icon {
font-size: 0.78rem;
color: var(--bs-secondary-color);
}
.apr-panel-title {
font-weight: 600;
font-size: 0.85rem;
}
.apr-panel-subtitle {
font-size: 0.7rem;
color: var(--bs-secondary-color);
}
.apr-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
font-size: 0.68rem;
font-weight: 700;
padding: 0 5px;
border-radius: 9px;
background: var(--bs-secondary-bg);
color: var(--bs-secondary-color);
}
.apr-panel-add-btn {
font-size: 0.75rem;
padding: 2px 8px;
margin-left: auto;
flex-shrink: 0;
}
.apr-side-panel-body {
padding: 10px 16px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.apr-side-panel-body .apr-form {
margin: 0 0 4px;
}
.apr-panel-empty {
font-size: 0.8rem;
color: var(--bs-secondary-color);
padding: 4px 4px 2px;
}
/* ── Tool matrix ── */
.apr-matrix {
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--bs-border-color);
flex-shrink: 0;
}
.apr-matrix-header {
display: flex;
align-items: baseline;
gap: 8px;
padding: 9px 20px;
background: var(--bs-body-bg);
border-top: 2px solid var(--bs-border-color);
border-bottom: 1px solid var(--bs-border-color);
flex-shrink: 0;
}
.apr-matrix-title {
font-weight: 700;
font-size: 0.85rem;
}
.apr-matrix-subtitle {
font-size: 0.7rem;
color: var(--bs-secondary-color);
}
.apr-matrix-body {
overflow: visible;
}
/* ── Category sections ── */
.apr-cat-section {
border-bottom: 1px solid var(--bs-border-color);
}
.apr-cat-section:last-child {
border-bottom: none;
}
.apr-cat-header {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 20px;
cursor: pointer;
user-select: none;
transition: background 0.1s;
}
.apr-cat-header:hover {
background: var(--bs-tertiary-bg);
}
.apr-cat-chevron {
font-size: 0.65rem;
color: var(--bs-secondary-color);
width: 10px;
flex-shrink: 0;
}
.apr-cat-name {
font-weight: 600;
font-size: 0.8rem;
}
.apr-cat-desc {
font-size: 0.7rem;
color: var(--bs-secondary-color);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apr-cat-count {
font-size: 0.7rem;
font-weight: 500;
color: var(--bs-primary, #0d6efd);
}
.apr-cat-count--muted {
color: var(--bs-secondary-color);
font-weight: 400;
}
.apr-cat-body {
display: none;
padding: 2px 0 4px;
}
.apr-cat-section--open .apr-cat-body {
display: block;
}
/* ── Tool rows ── */
.apr-tool-row {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 20px 4px 36px;
transition: background 0.1s;
min-height: 34px;
}
.apr-tool-row:hover {
background: var(--bs-tertiary-bg);
}
.apr-matrix-tool-name {
font-size: 0.76rem;
font-weight: 500;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: default;
}
/* ── 4-state chip toggle ── */
.apr-chip-group {
display: inline-flex;
border: 1px solid var(--bs-border-color);
border-radius: 5px;
overflow: hidden;
flex-shrink: 0;
}
.apr-chip {
display: inline-flex;
align-items: center;
padding: 3px 9px;
font-size: 0.7rem;
font-weight: 600;
border: none;
border-right: 1px solid var(--bs-border-color);
background: var(--bs-body-bg);
color: var(--bs-secondary-color);
cursor: pointer;
transition: background 0.1s, color 0.1s;
white-space: nowrap;
letter-spacing: 0.01em;
}
.apr-chip:last-child {
border-right: none;
}
.apr-chip:hover {
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
}
.apr-chip[data-action="unset"].active {
background: var(--bs-secondary-bg);
color: var(--bs-body-color);
font-weight: 700;
}
.apr-chip[data-action="allow"].active {
background: rgba(34,197,94,0.15);
color: #16a34a;
}
.apr-chip[data-action="require"].active {
background: rgba(234,179,8,0.15);
color: #a16207;
}
.apr-chip[data-action="deny"].active {
background: rgba(239,68,68,0.15);
color: #dc2626;
}
@media (prefers-color-scheme: dark) {
.apr-chip[data-action="allow"].active { background: rgba(34,197,94,0.22); color: #4ade80; }
.apr-chip[data-action="require"].active { background: rgba(234,179,8,0.22); color: #fbbf24; }
.apr-chip[data-action="deny"].active { background: rgba(239,68,68,0.22); color: #f87171; }
}
/* ── Default action bar ── */
.apr-default-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border-top: 2px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
flex-shrink: 0;
flex-wrap: wrap;
}
.apr-default-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.82rem;
flex-shrink: 0;
}
.apr-default-hint {
font-weight: 400;
color: var(--bs-secondary-color);
margin-left: 2px;
}
.apr-default-unset {
font-size: 0.72rem;
color: var(--bs-secondary-color);
font-style: italic;
}
/* ── File System panel ── */
.apr-fs-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 34px;
}
.apr-fs-row-icon {
font-size: 0.8rem;
color: var(--bs-secondary-color);
flex-shrink: 0;
}
.apr-fs-path {
flex: 1;
min-width: 0;
font-size: 0.78rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apr-fs-select {
width: auto;
flex-shrink: 0;
font-size: 0.76rem;
}
.apr-fs-path-input {
flex: 1;
min-width: 0;
}
.apr-fs-add {
padding-top: 8px;
margin-top: 2px;
border-top: 1px dashed var(--bs-border-color);
}
.apr-fs-add-btn {
flex-shrink: 0;
}
.apr-fs-default {
padding-top: 8px;
margin-top: 2px;
border-top: 1px solid var(--bs-border-color);
}
.apr-fs-default-label {
font-weight: 600;
font-size: 0.82rem;
flex: 1;
}
+107
View File
@@ -0,0 +1,107 @@
config-page {
display: none;
flex-direction: column;
flex: 1;
overflow-y: auto;
padding: 1.5rem 2rem;
background: var(--bs-body-bg);
}
.config-page {
display: flex;
flex-direction: column;
width: 100%;
}
.config-page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.config-sets {
display: flex;
flex-direction: column;
gap: 2rem;
max-width: 680px;
}
.config-set {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.config-set-header {
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--bs-border-color);
}
.config-set-name {
font-size: 1rem;
font-weight: 700;
}
.config-set-desc {
font-size: 0.8rem;
color: var(--bs-secondary-color, #6c757d);
margin-top: 0.1rem;
}
.config-rows {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.config-row {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
background: var(--bs-body-bg);
}
.config-row-name {
font-weight: 600;
font-size: 0.9rem;
}
.config-row-desc {
font-size: 0.8rem;
color: var(--bs-secondary-color, #6c757d);
}
.config-row-control {
display: flex;
gap: 0.5rem;
align-items: center;
}
.config-input {
flex: 1;
max-width: 400px;
}
.config-save-btn {
white-space: nowrap;
min-width: 64px;
}
.config-bool-switch {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
padding: 0;
}
.config-bool-switch .form-check-input {
cursor: pointer;
width: 2.5em;
height: 1.25em;
margin: 0;
}
+219
View File
@@ -0,0 +1,219 @@
/* ── Input area ────────────────────────────────────────────────────────────── */
.copilot-input-area {
padding: 0.6rem 0.75rem 0.75rem;
border-top: 1px solid var(--toolbar-border);
flex-shrink: 0;
}
.copilot-composer {
position: relative;
display: flex;
flex-direction: column;
border: 1px solid var(--toolbar-border);
border-radius: 0.6rem;
background: var(--msg-assistant-bg);
transition: border-color 0.15s;
}
.copilot-composer:focus-within {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
}
.copilot-textarea {
resize: none;
border: none;
outline: none;
background: transparent;
font-size: 0.85rem;
line-height: 1.55;
padding: 0.6rem 0.75rem 0.4rem;
min-height: 2.6rem;
max-height: 14rem;
overflow-y: auto;
color: inherit;
width: 100%;
box-sizing: border-box;
}
/* ── Toolbar ───────────────────────────────────────────────────────────────── */
.copilot-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.3rem 0.4rem;
border-top: 1px solid var(--toolbar-border);
gap: 0.3rem;
}
.copilot-toolbar-left,
.copilot-toolbar-right {
display: flex;
align-items: center;
gap: 0.25rem;
}
.copilot-toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.8rem;
height: 1.8rem;
padding: 0;
border: none;
border-radius: 0.4rem;
background: transparent;
color: var(--placeholder-color);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.copilot-toolbar-btn:hover {
background: var(--sidebar-hover);
color: var(--text-primary, #1e293b);
}
/* ── Model pill + dropdown ─────────────────────────────────────────────────── */
.copilot-model-wrap {
position: relative;
}
.copilot-model-pill {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.5rem;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
font-size: 0.75rem;
color: var(--placeholder-color);
cursor: pointer;
transition: background 0.12s, border-color 0.12s, color 0.12s;
white-space: nowrap;
}
.copilot-model-pill:hover,
.copilot-model-pill.open {
background: rgba(99, 102, 241, 0.08);
border-color: rgba(99, 102, 241, 0.3);
color: #6366f1;
}
.copilot-model-pill i:first-child {
font-size: 0.75rem;
color: #6366f1;
}
.copilot-model-overlay {
position: fixed;
inset: 0;
z-index: 99;
}
.copilot-model-dropdown {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
background: var(--msg-assistant-bg);
border: 1px solid var(--toolbar-border);
border-radius: 0.5rem;
padding: 0.3rem 0;
min-width: 160px;
z-index: 100;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
}
.copilot-model-item {
display: block;
width: 100%;
padding: 0.3rem 0.75rem;
background: transparent;
border: none;
text-align: left;
font-size: 0.8rem;
cursor: pointer;
color: inherit;
transition: background 0.1s;
}
.copilot-model-item:hover { background: rgba(99, 102, 241, 0.07); }
.copilot-model-item.active { color: #6366f1; font-weight: 600; }
/* ── Slash-command autocomplete ─────────────────────────────────────────────── */
.copilot-cmd-menu {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
right: 0;
background: var(--msg-assistant-bg);
border: 1px solid var(--toolbar-border);
border-radius: 0.5rem;
padding: 0.25rem 0;
max-height: 15rem;
overflow-y: auto;
z-index: 100;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
}
.copilot-cmd-item {
display: flex;
align-items: baseline;
gap: 0.5rem;
width: 100%;
padding: 0.3rem 0.75rem;
background: transparent;
border: none;
text-align: left;
font-size: 0.8rem;
cursor: pointer;
color: inherit;
transition: background 0.1s;
}
.copilot-cmd-item:hover,
.copilot-cmd-item.active { background: rgba(99, 102, 241, 0.1); }
.copilot-cmd-name { font-weight: 600; color: #6366f1; white-space: nowrap; }
.copilot-cmd-desc {
color: var(--text-muted, #888);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Send / stop button ────────────────────────────────────────────────────── */
.copilot-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
padding: 0;
border: none;
border-radius: 0.45rem;
background: #6366f1;
color: #fff;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.12s;
flex-shrink: 0;
}
.copilot-send-btn:hover { background: #4f46e5; }
.copilot-send-btn--stop { background: #dc2626; }
.copilot-send-btn--stop:hover { background: #b91c1c; }
.copilot-send-btn--recording { background: #dc2626; animation: copilot-pulse 1s ease-in-out infinite; }
.copilot-send-btn--recording:hover { background: #b91c1c; }
@keyframes copilot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
+585
View File
@@ -0,0 +1,585 @@
/* ── Messages container ────────────────────────────────────────────────────── */
.copilot-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
/* ── Message bubbles ───────────────────────────────────────────────────────── */
.copilot-msg {
padding: 0.6rem 0.9rem;
border-radius: 0.75rem;
font-size: 0.85rem;
line-height: 1.55;
max-width: 88%;
}
.copilot-msg.assistant {
background: var(--msg-assistant-bg);
color: var(--msg-assistant-text);
align-self: flex-start;
border-bottom-left-radius: 0.2rem;
}
.copilot-msg.user {
background: var(--msg-user-bg);
color: var(--msg-user-text);
align-self: flex-end;
border-bottom-right-radius: 0.2rem;
}
.copilot-msg.error {
background: #fef2f2;
color: #991b1b;
align-self: flex-start;
border-bottom-left-radius: 0.2rem;
font-size: 0.82rem;
}
@media (prefers-color-scheme: dark) {
.copilot-msg.error {
background: #2d1515;
color: #fca5a5;
}
}
.copilot-msg.info {
background: #f0f9ff;
color: #0369a1;
align-self: flex-start;
border-bottom-left-radius: 0.2rem;
font-size: 0.80rem;
}
@media (prefers-color-scheme: dark) {
.copilot-msg.info {
background: #0c1e2d;
color: #7dd3fc;
}
}
.copilot-msg--failed {
opacity: 0.6;
}
.copilot-failed-badge {
color: #dc2626;
font-size: 0.75rem;
margin-right: 0.35rem;
flex-shrink: 0;
}
.copilot-thinking {
opacity: 0.65;
}
.copilot-token-count {
margin-top: 0.3rem;
font-size: 0.7rem;
color: var(--placeholder-color);
opacity: 0.6;
font-style: normal;
font-family: var(--font-mono);
letter-spacing: 0.02em;
}
/* ── Tool call blocks ──────────────────────────────────────────────────────── */
.copilot-tool {
font-size: 0.78rem;
}
/* Pending approval needs to stand out */
.copilot-tool--pending {
border: 1px solid var(--toolbar-border);
border-radius: 0.4rem;
border-left: 3px solid #f59e0b;
}
.copilot-tool-header {
display: flex;
align-items: center;
gap: 0.35rem;
width: 100%;
padding: 0.15rem 0.25rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
font-size: 0.78rem;
color: var(--placeholder-color);
transition: color 0.12s;
}
.copilot-tool--pending .copilot-tool-header {
padding: 0.4rem 0.6rem;
}
.copilot-tool-header:hover {
color: inherit;
}
.copilot-tool-status {
display: flex;
align-items: center;
flex-shrink: 0;
font-size: 0.7rem;
}
.copilot-tool-name {
font-weight: 400;
font-family: var(--bs-font-monospace);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.copilot-tool-name code {
font-family: inherit;
font-size: inherit;
background: rgba(0,0,0,0.06);
border-radius: 0.2rem;
padding: 0 0.25em;
}
@media (prefers-color-scheme: dark) {
.copilot-tool-name code {
background: rgba(255,255,255,0.08);
}
}
/* A file path that opens the file viewer. Reads as a code chip but acts as a link. */
.copilot-tool-name .copilot-tool-path,
.copilot-tool-path {
font-family: var(--bs-font-monospace);
font-size: inherit;
color: #6366f1;
cursor: pointer;
border-radius: 0.2rem;
padding: 0 0.25em;
background: rgba(99,102,241,0.10);
text-decoration: none;
}
.copilot-tool-path:hover {
text-decoration: underline;
background: rgba(99,102,241,0.18);
}
.copilot-tool-body {
border-top: 1px solid var(--toolbar-border);
display: flex;
flex-direction: column;
gap: 0;
}
.copilot-tool-section {
display: flex;
flex-direction: column;
padding: 0.4rem 0.6rem;
}
.copilot-tool-section + .copilot-tool-section {
border-top: 1px solid var(--toolbar-border);
}
.copilot-tool-label {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.25rem;
color: var(--placeholder-color);
}
.copilot-tool-label--done { color: #16a34a; }
.copilot-tool-label--error { color: #dc2626; }
.copilot-tool-pre {
font-size: 0.72rem;
font-family: var(--bs-font-monospace);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
padding: 0.35rem 0.5rem;
background: rgba(0, 0, 0, 0.04);
border-radius: 0.25rem;
max-height: 200px;
overflow-y: auto;
}
@media (prefers-color-scheme: dark) {
.copilot-tool-pre {
background: rgba(255, 255, 255, 0.04);
}
}
.copilot-tool-pre--error { color: #dc2626; }
/* Structured (`result_type === 'json'`) results: keep JSON readable (don't break
mid-token) and flag the typed payload with a left accent. */
.copilot-tool-pre--json {
word-break: normal;
overflow-x: auto;
border-left: 2px solid var(--bs-info, #0dcaf0);
}
/* ── Markdown rendering ────────────────────────────────────────────────────── */
.copilot-markdown { max-width: 100%; }
.copilot-markdown > *:first-child { margin-top: 0; }
.copilot-markdown > *:last-child { margin-bottom: 0; }
.copilot-markdown h1,
.copilot-markdown h2,
.copilot-markdown h3,
.copilot-markdown h4 {
font-size: 0.9rem;
font-weight: 700;
margin: 0.75rem 0 0.3rem;
line-height: 1.3;
}
.copilot-markdown p { margin: 0.4rem 0; }
.copilot-markdown ul,
.copilot-markdown ol {
margin: 0.4rem 0;
padding-left: 1.4rem;
}
.copilot-markdown li { margin: 0.15rem 0; }
.copilot-markdown code {
font-family: var(--bs-font-monospace);
font-size: 0.78rem;
background: rgba(0, 0, 0, 0.08);
padding: 0.1em 0.35em;
border-radius: 0.25rem;
}
@media (prefers-color-scheme: dark) {
.copilot-markdown code { background: rgba(255, 255, 255, 0.1); }
}
.copilot-markdown pre {
background: rgba(0, 0, 0, 0.06);
border-radius: 0.4rem;
padding: 0.65rem 0.85rem;
overflow-x: auto;
margin: 0.5rem 0;
font-size: 0.78rem;
}
@media (prefers-color-scheme: dark) {
.copilot-markdown pre { background: rgba(255, 255, 255, 0.06); }
}
.copilot-markdown pre code { background: none; padding: 0; font-size: inherit; }
.copilot-markdown blockquote {
border-left: 3px solid #6366f1;
margin: 0.5rem 0;
padding: 0.3rem 0.75rem;
color: var(--placeholder-color);
font-style: italic;
}
.copilot-markdown hr {
border: none;
border-top: 1px solid var(--toolbar-border);
margin: 0.6rem 0;
}
.copilot-markdown a { color: #6366f1; text-decoration: underline; }
.copilot-markdown strong { font-weight: 700; }
.copilot-markdown em { font-style: italic; }
/* Keep wide content inside the bubble so it never stretches the message list
(which would let the whole page pan sideways). Images shrink to the bubble
width; tables become their own horizontal scroll box. Code blocks (`pre`)
already scroll within themselves (overflow-x: auto above). */
.copilot-markdown img {
max-width: 100%;
height: auto;
border-radius: 0.4rem;
}
.copilot-markdown table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
/* ── Approval panel ────────────────────────────────────────────────────────── */
.copilot-approval {
border: 1px solid var(--toolbar-border);
border-radius: 0.5rem;
font-size: 0.82rem;
}
.copilot-approval--approved { border-color: #16a34a; }
.copilot-approval--rejected { border-color: #dc2626; }
.copilot-approval-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
background: var(--msg-assistant-bg);
font-size: 0.8rem;
flex-wrap: wrap;
}
.copilot-approval-path {
font-family: var(--bs-font-monospace);
font-size: 0.75rem;
color: #6366f1;
}
.copilot-approval-actions {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--toolbar-border);
}
.copilot-approval-btns {
display: flex;
gap: 0.4rem;
}
.copilot-reject-note {
font-size: 0.8rem;
resize: none;
}
.copilot-clarification-title {
font-weight: 600;
font-size: 0.82rem;
color: var(--text-primary, #1e293b);
}
.copilot-clarification-question {
font-size: 0.82rem;
color: var(--text-secondary, #475569);
}
.copilot-clarification-chips {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.copilot-chip {
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
}
.copilot-clarification-input-row {
display: flex;
align-items: flex-start;
gap: 0.4rem;
}
/* ── Diff view ─────────────────────────────────────────────────────────────── */
.copilot-diff {
font-family: var(--bs-font-monospace);
font-size: 0.72rem;
line-height: 1.5;
white-space: pre;
overflow-x: auto;
max-height: 320px;
overflow-y: auto;
margin: 0;
padding: 0.5rem 0;
background: transparent;
border-top: 1px solid var(--toolbar-border);
}
.diff-added {
display: block;
background: rgba(22, 163, 74, 0.12);
color: #15803d;
padding: 0 0.75rem;
}
.diff-removed {
display: block;
background: rgba(220, 38, 38, 0.1);
color: #b91c1c;
padding: 0 0.75rem;
text-decoration: line-through;
opacity: 0.85;
}
.diff-unchanged {
display: block;
padding: 0 0.75rem;
color: var(--placeholder-color);
}
.diff-ellipsis {
display: block;
padding: 0.1rem 0.75rem;
color: var(--placeholder-color);
font-style: italic;
background: rgba(0, 0, 0, 0.03);
text-align: center;
}
@media (prefers-color-scheme: dark) {
.diff-added { color: #4ade80; }
.diff-removed { color: #f87171; }
.diff-ellipsis { background: rgba(255, 255, 255, 0.03); }
}
/* ── Agent trace ───────────────────────────────────────────────────────────── */
.copilot-agent,
.copilot-agent-end {
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 0.5rem;
font-size: 0.78rem;
overflow: clip;
margin-left: calc(var(--agent-depth, 0) * 1rem);
}
.copilot-agent-header {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.65rem;
background: rgba(99, 102, 241, 0.12);
color: var(--msg-assistant-text);
}
.copilot-agent-header i {
font-size: 0.85rem;
flex-shrink: 0;
color: #6366f1;
}
.copilot-agent-header strong {
color: #6366f1;
font-weight: 600;
}
.copilot-agent-badge {
margin-left: auto;
font-size: 0.67rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
}
.copilot-agent-badge.running {
background: rgba(234, 179, 8, 0.15);
color: #b45309;
}
.copilot-agent-badge.done {
background: rgba(22, 163, 74, 0.12);
color: #15803d;
}
.copilot-agent-preview {
font-size: 0.75rem;
font-family: var(--bs-font-monospace);
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 0.45rem 0.65rem;
color: var(--placeholder-color);
border-top: 1px solid rgba(99, 102, 241, 0.15);
max-height: 120px;
overflow-y: auto;
background: rgba(99, 102, 241, 0.05);
}
.copilot-agent-preview--result {
color: var(--msg-assistant-text);
opacity: 0.8;
}
@media (prefers-color-scheme: dark) {
.copilot-agent-badge.running { background: rgba(234, 179, 8, 0.2); color: #fbbf24; }
.copilot-agent-badge.done { background: rgba(22, 163, 74, 0.18); color: #4ade80; }
.copilot-agent-header { background: rgba(99, 102, 241, 0.1); }
}
/* ── Attachment chips (composer pending + sent user bubble) ──────────────────── */
.attach-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.4rem 0.5rem;
}
/* Inside a user bubble the chips sit below the text with a little top spacing. */
.copilot-msg.user .attach-chips {
padding: 0.4rem 0 0;
}
.attach-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
max-width: 100%;
padding: 0.2rem 0.45rem;
border: 1px solid var(--toolbar-border);
border-radius: 0.5rem;
background: var(--msg-assistant-bg);
font-size: 0.75rem;
line-height: 1.2;
}
.attach-chip--clickable { cursor: pointer; }
.attach-chip--clickable:hover { border-color: #6366f1; }
.attach-chip--uploading { opacity: 0.7; }
.attach-chip .bi { font-size: 0.85rem; flex-shrink: 0; }
.attach-chip-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 12rem;
}
.attach-chip-size {
color: var(--placeholder-color);
flex-shrink: 0;
}
.attach-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
padding: 0;
border: none;
border-radius: 0.3rem;
background: transparent;
color: var(--placeholder-color);
cursor: pointer;
flex-shrink: 0;
}
.attach-chip-remove:hover { background: var(--sidebar-hover); color: #dc2626; }
+130
View File
@@ -0,0 +1,130 @@
/* ── Copilot panel ─────────────────────────────────────────────────────────── */
app-copilot {
display: flex;
flex-direction: column;
position: relative;
width: var(--copilot-width);
height: 100%;
border-left: 1px solid var(--toolbar-border);
background: var(--copilot-bg);
flex-shrink: 0;
}
app-copilot.collapsed {
width: 0;
min-width: 0;
border: none;
overflow: hidden;
}
.copilot-resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 5px;
cursor: col-resize;
z-index: 20;
transition: background 0.15s;
}
.copilot-resize-handle:hover,
.copilot-resize-handle:active {
background: rgba(99, 102, 241, 0.35);
}
.copilot-expand-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
flex: 1;
background: transparent;
border: none;
cursor: pointer;
color: #6366f1;
font-size: 1.1rem;
writing-mode: vertical-rl;
padding: 1rem 0;
}
.copilot-expand-btn:hover {
background: var(--sidebar-hover);
}
.copilot-collapse-btn {
flex-shrink: 0;
padding: 0.2rem 0.4rem;
line-height: 1;
}
.copilot-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--toolbar-border);
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
}
.copilot-header i {
font-size: 1rem;
color: #6366f1;
}
/* ── Tabs (General + project chats) ─────────────────────────────────────────── */
.copilot-tabs {
display: flex;
align-items: stretch;
gap: 2px;
padding: 0 0.5rem;
border-bottom: 1px solid var(--toolbar-border);
overflow-x: auto;
flex-shrink: 0;
}
.copilot-tab {
display: flex;
align-items: center;
gap: 0.35rem;
max-width: 160px;
padding: 0.4rem 0.6rem;
font-size: 0.78rem;
cursor: pointer;
border-bottom: 2px solid transparent;
color: var(--bs-secondary-color, #6c757d);
white-space: nowrap;
}
.copilot-tab:hover {
background: var(--toolbar-border);
}
.copilot-tab--active {
color: var(--bs-body-color, inherit);
border-bottom-color: #6366f1;
font-weight: 600;
}
.copilot-tab-label {
overflow: hidden;
text-overflow: ellipsis;
}
.copilot-tab-close {
border: none;
background: none;
padding: 0;
line-height: 1;
cursor: pointer;
color: inherit;
opacity: 0.6;
flex-shrink: 0;
}
.copilot-tab-close:hover {
opacity: 1;
}
+83
View File
@@ -0,0 +1,83 @@
/* ── Agent selection dialog ─────────────────────────────────────────────────── */
.agent-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
}
.agent-dialog {
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 3px;
padding: 1.25rem;
min-width: 320px;
max-width: 420px;
width: 100%;
box-shadow: 0 8px 32px rgba(0,0,0,0.25);
}
.agent-dialog--ticket {
min-width: 480px;
max-width: 620px;
}
.agent-dialog-title {
font-weight: 600;
font-size: 1rem;
margin-bottom: 1rem;
}
.agent-dialog-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.agent-dialog-option {
display: flex;
align-items: flex-start;
gap: 0.6rem;
padding: 0.6rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--bs-border-color);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.agent-dialog-option input[type="radio"] {
margin-top: 0.2rem;
flex-shrink: 0;
}
.agent-dialog-option.selected {
border-color: var(--bs-primary);
background: var(--bs-primary-bg-subtle, rgba(13,110,253,0.08));
}
.agent-dialog-info {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.agent-dialog-name {
font-weight: 600;
font-size: 0.9rem;
}
.agent-dialog-desc {
font-size: 0.78rem;
opacity: 0.7;
}
.agent-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
+221
View File
@@ -0,0 +1,221 @@
/* ── File viewer page ───────────────────────────────────────────────────────── */
.fv-page {
flex-direction: column;
min-height: 0;
}
.fv-title {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.95rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.9;
margin: 0;
/* RTL flips the ellipsis to the *start*, so a long path keeps its tail (the
filename) visible — `<bdi>` around the path preserves left-to-right order. */
direction: rtl;
text-align: left;
flex: 1 1 auto;
min-width: 0;
}
.fv-download-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0 !important;
border-radius: 6px !important;
flex-shrink: 0;
}
.fv-body {
flex: 1 1 auto;
overflow: auto;
position: relative;
min-height: 0;
}
.fv-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
font-size: 0.9rem;
}
/* ── Markdown preview ────────────────────────────────────────────────────────── */
.fv-md {
max-width: 100%;
padding: 1.5rem 2rem;
line-height: 1.6;
font-size: 0.8rem;
}
.fv-md > *:first-child { margin-top: 0; }
.fv-md > *:last-child { margin-bottom: 0; }
.fv-md h1,
.fv-md h2,
.fv-md h3,
.fv-md h4 { margin: 1.2rem 0 0.5rem; line-height: 1.3; }
.fv-md h1 { font-size: 1.5em; }
.fv-md h2 { font-size: 1.3em; }
.fv-md h3 { font-size: 1.15em; }
.fv-md h4 { font-size: 1em; }
.fv-md p { margin: 0.5rem 0; }
.fv-md ul,
.fv-md ol { margin: 0.5rem 0; padding-left: 1.5rem; }
.fv-md li { margin: 0.2rem 0; }
.fv-md code {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.85em;
background: var(--bs-secondary-bg);
padding: 0.1rem 0.35rem;
border-radius: 3px;
}
.fv-md pre {
background: var(--bs-secondary-bg);
padding: 0.85rem 1rem;
border-radius: 6px;
overflow: auto;
margin: 0.75rem 0;
}
.fv-md pre code { background: none; padding: 0; font-size: inherit; }
.fv-md blockquote {
border-left: 3px solid var(--bs-border-color);
margin: 0.75rem 0;
padding: 0.25rem 0 0.25rem 1rem;
color: var(--bs-secondary-color);
}
.fv-md hr { border: none; border-top: 1px solid var(--bs-border-color); margin: 1.25rem 0; }
.fv-md a { color: #6366f1; text-decoration: underline; }
.fv-md strong { font-weight: 700; }
.fv-md em { font-style: italic; }
.fv-md img { max-width: 100%; border-radius: 4px; }
.fv-md table {
border-collapse: collapse;
margin: 0.75rem 0;
}
.fv-md th,
.fv-md td {
border: 1px solid var(--bs-border-color);
padding: 0.4rem 0.6rem;
}
/* ── Code/text preview ───────────────────────────────────────────────────────── */
.fv-code {
margin: 0;
padding: 1.25rem 1.5rem;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.85rem;
line-height: 1.55;
white-space: pre;
overflow: auto;
background: var(--bs-secondary-bg);
color: var(--bs-body-color);
min-height: 100%;
tab-size: 4;
}
/* Header action buttons (mode toggle + download) sit in one row. */
.fv-header-actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
/* ── PDF preview ─────────────────────────────────────────────────────────────── */
.fv-pdf {
width: 100%;
height: 100%;
border: none;
display: block;
}
/* ── HTML live preview ───────────────────────────────────────────────────────── */
/* Rendered in an origin-isolated iframe (srcdoc + sandbox="allow-scripts").
White canvas so pages that don't set their own background stay legible. */
.fv-html {
width: 100%;
height: 100%;
border: none;
display: block;
background: #fff;
}
/* ── LaTeX compile-error banner (shown above the source fallback) ─────────────── */
.fv-compile-error {
margin: 0;
padding: 0.5rem 1.5rem;
background: var(--bs-warning-bg-subtle, #fff3cd);
border-bottom: 1px solid var(--bs-border-color);
font-size: 0.85rem;
}
.fv-compile-error > summary {
cursor: pointer;
user-select: none;
padding: 0.25rem 0;
}
.fv-compile-error > pre {
margin: 0.5rem 0 0.75rem;
padding: 0.75rem 1rem;
max-height: 40vh;
overflow: auto;
background: var(--bs-secondary-bg);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.8rem;
white-space: pre-wrap;
word-break: break-word;
}
/* ── Image preview ───────────────────────────────────────────────────────────── */
.fv-image-wrap {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--bs-secondary-bg);
}
.fv-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
/* SVG renders in a sandboxed iframe; the SVG root fills the iframe viewport and
scales to its viewBox. White canvas so light-on-transparent SVGs stay legible. */
.fv-svg {
width: 100%;
height: 100%;
border: none;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
+420
View File
@@ -0,0 +1,420 @@
/* ── Home page ─────────────────────────────────────────────────────────────── */
home-page {
display: none;
flex-direction: column;
flex: 1;
overflow-y: auto;
}
.home-page {
padding: 1rem 2.5rem 2rem;
width: 100%;
box-sizing: border-box;
}
/* ── Debug toggle ──────────────────────────────────────────────────────────── */
.home-debug-bar {
display: flex;
justify-content: flex-end;
margin-bottom: 0.75rem;
}
.home-debug-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
font-weight: 500;
color: var(--bs-secondary-color);
cursor: pointer;
user-select: none;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.15s;
}
.home-debug-toggle:hover {
background: var(--bs-tertiary-bg);
}
.home-debug-toggle i { font-size: 0.82rem; }
/* ── Hero ──────────────────────────────────────────────────────────────────── */
.home-hero {
display: flex;
align-items: center;
gap: 1.25rem;
margin-bottom: 1.25rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--bs-border-color);
}
.home-hero-image {
flex-shrink: 0;
width: 72px;
height: 72px;
border-radius: 50%;
overflow: hidden;
border: 3px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
display: flex;
align-items: center;
justify-content: center;
}
.home-hero-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.home-hero-text {
flex: 1;
min-width: 0;
}
.home-hero-title {
font-size: 1.4rem;
font-weight: 700;
margin: 0 0 0.2rem;
line-height: 1.2;
}
.home-hero-desc {
font-size: 0.85rem;
color: var(--bs-secondary-color);
margin: 0 0 0.5rem;
line-height: 1.4;
}
.home-hero-status {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 0.82rem;
font-weight: 500;
padding: 0.3em 0.8em;
border-radius: 20px;
}
.home-hero-status--online {
color: var(--bs-success, #16a34a);
background: color-mix(in srgb, var(--bs-success, #22c55e) 10%, transparent);
}
.home-hero-status--warn {
color: #b45309;
background: color-mix(in srgb, #f59e0b 12%, transparent);
}
.home-hero-status--error {
color: var(--bs-danger, #dc2626);
background: color-mix(in srgb, var(--bs-danger, #ef4444) 10%, transparent);
}
.home-hero-status--loading {
color: var(--bs-secondary-color);
background: var(--bs-tertiary-bg);
}
.home-hero-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
animation: home-pulse 2s ease-in-out infinite;
}
@keyframes home-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* ── Banner (no models / all offline) ─────────────────────────────────────── */
.home-banner {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-radius: 10px;
margin-bottom: 1.75rem;
font-size: 0.88rem;
line-height: 1.45;
}
.home-banner--error {
background: color-mix(in srgb, var(--bs-danger) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--bs-danger) 30%, transparent);
color: var(--bs-body-color);
}
.home-banner-icon {
font-size: 1.5rem;
color: var(--bs-danger);
flex-shrink: 0;
}
.home-banner-body {
flex: 1;
}
/* ── Section title ─────────────────────────────────────────────────────────── */
.home-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bs-secondary-color);
margin-bottom: 1rem;
}
.home-section-title i { font-size: 0.9rem; }
/* ── Inbox embedded in home: no own scroll, no padding ─────────────────────── */
.home-page .inbox-grid {
overflow: visible;
flex: none;
padding: 0;
margin-bottom: 2rem;
}
.home-page .inbox-empty {
height: auto;
padding: 0.5rem 1rem;
flex-direction: row;
gap: 8px;
justify-content: flex-start;
margin-bottom: 1.5rem;
}
.home-page .inbox-empty i {
font-size: 0.9rem;
margin-bottom: 0;
opacity: 0.5;
}
.home-page .inbox-empty p {
font-size: 0.82rem;
}
/* ── Honcho tip ────────────────────────────────────────────────────────────── */
.home-tip {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, #f59e0b 40%, transparent);
background: color-mix(in srgb, #f59e0b 8%, transparent);
margin-bottom: 1.5rem;
font-size: 0.85rem;
}
.home-tip-icon {
font-size: 1.1rem;
color: #b45309;
flex-shrink: 0;
margin-top: 2px;
}
.home-tip-body {
display: flex;
flex-direction: column;
gap: 3px;
line-height: 1.4;
}
.home-tip-body strong {
font-weight: 600;
font-size: 0.88rem;
color: var(--bs-body-color);
}
.home-tip-body span {
color: var(--bs-secondary-color);
font-size: 0.8rem;
}
/* ── Guide cards ───────────────────────────────────────────────────────────── */
.home-guide {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.home-card {
border: 1px solid var(--bs-border-color);
border-radius: 10px;
padding: 0.85rem 1rem;
display: flex;
gap: 0.85rem;
align-items: flex-start;
background: var(--bs-body-bg);
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.home-card:hover {
box-shadow: 0 2px 10px rgba(0,0,0,0.07);
border-color: var(--home-card-color, var(--bs-border-color));
}
.home-card-icon {
width: 34px;
height: 34px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: color-mix(in srgb, var(--home-card-color, #0d6efd) 12%, transparent);
color: var(--home-card-color, #0d6efd);
font-size: 1rem;
}
.home-card-body { flex: 1; min-width: 0; }
.home-card-title {
font-size: 0.88rem;
font-weight: 600;
margin: 0 0 0.2rem;
}
.home-card-desc {
font-size: 0.78rem;
color: var(--bs-secondary-color);
margin: 0;
line-height: 1.4;
}
/* ── LLM Stats ─────────────────────────────────────────────────────────────── */
.home-section-period {
font-weight: 400;
font-size: 0.75rem;
color: var(--bs-secondary-color);
text-transform: none;
letter-spacing: 0;
margin-left: 2px;
}
.home-stats-range {
display: flex;
gap: 2px;
}
.home-stats-range-btn {
padding: 2px 8px;
font-size: 0.72rem;
font-weight: 500;
border: 1px solid var(--bs-border-color);
border-radius: 5px;
background: transparent;
color: var(--bs-secondary-color);
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
text-transform: none;
letter-spacing: 0;
}
.home-stats-range-btn:hover {
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
}
.home-stats-range-btn.active {
background: var(--bs-primary);
border-color: var(--bs-primary);
color: #fff;
}
.home-stats-loading,
.home-stats-empty {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--bs-secondary-color);
padding: 1.5rem 0.5rem;
margin-bottom: 2rem;
}
.home-stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 2rem;
}
.home-stat-card {
border: 1px solid var(--bs-border-color);
border-radius: 10px;
padding: 0.9rem 1rem 0.75rem;
background: var(--bs-body-bg);
}
.home-stat-card-title {
font-size: 0.78rem;
font-weight: 600;
color: var(--bs-secondary-color);
margin-bottom: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.home-stat-canvas-wrap {
position: relative;
height: 160px;
}
.home-stat-canvas-wrap canvas {
display: block;
}
/* ── Responsive ────────────────────────────────────────────────────────────── */
@media (max-width: 600px) {
.home-page {
padding: 1.25rem 1rem;
}
.home-body {
gap: 1.25rem;
}
.home-hero {
flex-direction: column;
text-align: center;
gap: 1rem;
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
}
.home-hero-image {
width: 90px;
height: 90px;
}
.home-hero-title { font-size: 1.4rem; }
.home-hero-desc { font-size: 0.85rem; }
.home-hero-status { font-size: 0.78rem; }
.home-guide {
grid-template-columns: 1fr;
}
.home-stats-grid {
grid-template-columns: 1fr;
}
}
+329
View File
@@ -0,0 +1,329 @@
/* ── Inbox card styles — shared between desktop and mobile ─────────────────── */
/* ── Card base ── */
.inbox-card {
border: 1px solid var(--bs-border-color);
border-radius: 10px;
background: var(--bs-body-bg);
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.inbox-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.approval-card { border-left: 4px solid var(--bs-warning); }
.clarification-card { border-left: 4px solid var(--bs-info); }
/* ── Card header ── */
.inbox-card-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
}
.inbox-card-header .badge {
font-size: 0.68rem;
font-weight: 600;
padding: 3px 8px;
letter-spacing: 0.3px;
text-transform: uppercase;
}
.inbox-card-origin {
font-size: 0.82rem;
font-weight: 600;
color: var(--bs-body-color);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.inbox-card-time {
font-size: 0.72rem;
color: var(--bs-secondary-color);
white-space: nowrap;
flex-shrink: 0;
}
/* ── Card body ── */
.inbox-card-body {
padding: 14px;
flex: 1;
display: flex;
flex-direction: column;
}
/* ── Approval: tool name + args ── */
.inbox-tool-name {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
font-size: 0.95rem;
}
.inbox-tool-name strong { font-size: 1rem; }
.inbox-agent-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--bs-secondary-color);
background: var(--bs-tertiary-bg);
border-radius: 4px;
padding: 2px 8px;
margin-left: auto;
white-space: nowrap;
border: 1px solid var(--bs-border-color);
}
.inbox-args-structured {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.inbox-arg-row {
display: flex;
gap: 8px;
font-size: 0.82rem;
align-items: baseline;
}
.inbox-arg-key {
color: var(--bs-secondary-color);
font-weight: 600;
min-width: 70px;
flex-shrink: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.inbox-arg-value {
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace;
font-size: 0.82rem;
word-break: break-all;
color: var(--bs-body-color);
}
.inbox-args-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--bs-secondary-color);
cursor: pointer;
background: none;
border: none;
padding: 2px 0;
margin-top: 4px;
transition: color 0.12s ease;
}
.inbox-args-toggle:hover { color: var(--bs-body-color); }
.inbox-args-raw {
font-size: 0.72rem;
background: var(--bs-tertiary-bg);
border-radius: 6px;
padding: 10px;
max-height: 180px;
overflow: auto;
margin: 6px 0 0;
white-space: pre-wrap;
display: none;
border: 1px solid var(--bs-border-color);
}
.inbox-args-raw.open { display: block; }
/* ── Clarification ── */
.inbox-card-title {
font-weight: 700;
font-size: 1rem;
margin-bottom: 6px;
color: var(--bs-body-color);
}
.inbox-question {
font-size: 0.88rem;
color: var(--bs-secondary-color);
margin-bottom: 12px;
line-height: 1.45;
}
.inbox-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.inbox-chip {
font-size: 0.78rem;
padding: 4px 14px;
border-radius: 20px;
border: 1px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
cursor: pointer;
transition: all 0.12s ease;
-webkit-tap-highlight-color: transparent;
}
.inbox-chip:hover {
background: var(--bs-info);
border-color: var(--bs-info);
color: #fff;
}
.inbox-answer-area {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.inbox-answer-input {
width: 100%;
border: 1px solid var(--bs-border-color);
border-radius: 8px;
padding: 10px 12px;
font-size: 0.85rem;
background: var(--bs-body-bg);
color: var(--bs-body-color);
resize: none;
min-height: 40px;
box-sizing: border-box;
font-family: inherit;
}
.inbox-answer-input:focus {
outline: none;
border-color: var(--bs-info);
box-shadow: 0 0 0 2px rgba(13, 202, 240, 0.15);
}
.inbox-answer-send {
align-self: flex-end;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
font-size: 0.82rem;
font-weight: 600;
border-radius: 8px;
border: none;
background: var(--bs-info);
color: #000;
cursor: pointer;
transition: filter 0.12s ease, transform 0.1s ease;
-webkit-tap-highlight-color: transparent;
}
.inbox-answer-send:hover { filter: brightness(1.1); }
.inbox-answer-send:active { transform: scale(0.96); }
/* ── Card footer ── */
.inbox-card-footer {
display: flex;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
}
.approval-footer {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
/* ── Bypass timed dropdown ── */
.inbox-bypass-wrap {
position: relative;
}
.inbox-bypass-wrap .btn {
width: 100%;
}
.inbox-bypass-menu {
display: none;
position: absolute;
bottom: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 8px;
overflow: hidden;
z-index: 200;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.inbox-bypass-menu.open { display: block; }
.inbox-bypass-menu button {
display: block;
width: 100%;
padding: 9px 14px;
text-align: left;
background: none;
border: none;
font-size: 0.82rem;
font-weight: 600;
color: var(--bs-body-color);
cursor: pointer;
transition: background 0.1s;
}
.inbox-bypass-menu button + button {
border-top: 1px solid var(--bs-border-color);
}
.inbox-bypass-menu button:hover {
background: var(--bs-tertiary-bg);
}
/* ── Empty state ── */
.inbox-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
color: var(--bs-secondary-color);
}
.inbox-empty i {
font-size: 2.5rem;
margin-bottom: 12px;
opacity: 0.5;
}
.inbox-empty p {
margin: 0;
font-size: 0.9rem;
}
+351
View File
@@ -0,0 +1,351 @@
/* ── LLM Providers page ─────────────────────────────────────────────────────── */
.pv-page {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
max-width: 100%;
}
.pv-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
min-height: 54px;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
flex-shrink: 0;
gap: 12px;
}
.pv-title {
margin: 0;
font-weight: 700;
font-size: 1.05rem;
display: flex;
align-items: center;
gap: 8px;
}
.pv-header-right {
display: flex;
align-items: center;
gap: 10px;
}
.pv-header-count {
font-size: 0.76rem;
font-weight: 500;
color: var(--bs-secondary-color);
}
/* ── Card list ── */
.pv-card-list {
padding: 16px 20px;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── Single card ── */
.pv-card {
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 10px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.pv-card:hover {
border-color: color-mix(in srgb, var(--pv-color, #0d6efd) 50%, var(--bs-border-color));
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
/* ── Row 1: icon + name + badge + count + actions ── */
.pv-card-row1 {
display: flex;
align-items: center;
gap: 10px;
}
.pv-card-icon {
width: 34px;
height: 34px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 1rem;
}
.pv-card-name {
font-weight: 600;
font-size: 0.9rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pv-card-type-badge {
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.2em 0.6em;
border-radius: 4px;
flex-shrink: 0;
white-space: nowrap;
color: var(--pv-color, #0d6efd);
background: color-mix(in srgb, var(--pv-color, #0d6efd) 12%, transparent);
}
.pv-card-count {
font-size: 0.74rem;
font-weight: 500;
color: var(--bs-secondary-color);
flex-shrink: 0;
white-space: nowrap;
display: flex;
align-items: center;
gap: 3px;
}
.pv-card-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
margin-left: auto;
}
.pv-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
background: transparent;
color: var(--bs-secondary-color, #9ca3af);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.1s;
}
.pv-btn-icon:hover {
background: var(--bs-tertiary-bg, #f0f2f5);
color: var(--bs-body-color, #374151);
}
.pv-btn-edit:hover {
color: var(--bs-primary, #0d6efd);
background: var(--bs-primary-bg-subtle, #eef2ff);
}
.pv-btn-delete:hover {
color: var(--bs-danger, #dc3545);
background: var(--bs-danger-bg-subtle, #fef0f0);
}
/* ── Row 2: description ── */
.pv-card-row2 {
padding-left: 44px;
}
.pv-card-desc {
font-size: 0.82rem;
color: var(--bs-secondary-color);
line-height: 1.4;
}
/* ── Row 3: tags (API key, base URL, date) ── */
.pv-card-row3 {
display: flex;
align-items: center;
gap: 8px;
padding-left: 44px;
flex-wrap: wrap;
}
.pv-card-tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.74rem;
font-weight: 500;
padding: 0.2em 0.6em;
border-radius: 5px;
white-space: nowrap;
}
.pv-card-tag i {
font-size: 0.7rem;
}
.pv-tag-ok {
background: color-mix(in srgb, var(--bs-success, #22c55e) 12%, transparent);
color: var(--bs-success, #16a34a);
}
.pv-tag-missing {
background: color-mix(in srgb, var(--bs-warning, #eab308) 12%, transparent);
color: var(--bs-warning, #ca8a04);
}
.pv-tag-url {
background: var(--bs-tertiary-bg, #f0f2f5);
color: var(--bs-secondary-color, #6b7280);
max-width: 260px;
overflow: hidden;
}
.pv-card-url-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pv-tag-date {
background: var(--bs-tertiary-bg, #f0f2f5);
color: var(--bs-secondary-color, #6b7280);
}
/* ── Empty state ── */
.pv-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: var(--bs-secondary-color, #9ca3af);
gap: 8px;
flex: 1;
}
.pv-empty i {
font-size: 2rem;
opacity: 0.4;
}
.pv-empty p {
margin: 0;
font-size: 0.9rem;
}
/* ── Modal ── */
.pv-modal {
width: 100%;
max-width: 480px;
max-height: calc(100vh - 100px);
overflow-y: auto;
padding: 1.5rem;
}
.pv-modal-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 1rem;
margin-bottom: 1.25rem;
}
.pv-modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--bs-secondary-color);
border-radius: 6px;
margin-left: auto;
cursor: pointer;
font-size: 1.1rem;
}
.pv-modal-close:hover {
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
}
.pv-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 0.5rem;
border-top: 1px solid var(--bs-border-color);
}
/* ── Mobile ── */
@media (max-width: 600px) {
.pv-header {
padding: 10px 14px;
min-height: 50px;
}
.pv-card-list {
padding: 10px 12px;
gap: 8px;
}
.pv-card {
padding: 12px;
gap: 6px;
}
.pv-card-row1 {
gap: 7px;
}
.pv-card-icon {
width: 30px;
height: 30px;
font-size: 0.9rem;
}
.pv-card-actions {
gap: 0;
}
.pv-btn-icon {
width: 28px;
height: 28px;
font-size: 0.8rem;
}
.pv-card-row2 {
padding-left: 37px;
}
.pv-card-row3 {
padding-left: 37px;
gap: 6px;
}
.pv-card-tag {
font-size: 0.7rem;
}
.pv-tag-url {
max-width: 180px;
}
}
+621
View File
@@ -0,0 +1,621 @@
/* ── LLM Requests page ──────────────────────────────────────────────────────── */
llm-requests-page {
display: none;
flex-direction: column;
flex: 1;
min-width: 0;
overflow-y: auto;
border-right: 1px solid var(--toolbar-border, #e5e9f0);
}
.llmr-page {
padding: 1.5rem 2rem;
width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Header ─────────────────────────────────────────────────────────────────── */
.llmr-header {
display: flex;
align-items: center;
gap: 0.75rem;
border-bottom: 1px solid var(--bs-border-color);
padding-bottom: 1rem;
}
.llmr-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.llmr-title i { color: var(--bs-secondary-color); }
.llmr-total-badge {
margin-left: auto;
font-size: 0.78rem;
color: var(--bs-secondary-color);
background: var(--bs-tertiary-bg);
padding: 2px 10px;
border-radius: 20px;
}
/* ── Filters ─────────────────────────────────────────────────────────────────── */
.llmr-filters {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
padding: 0.875rem 1rem;
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 8px;
}
.llmr-filter-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.llmr-filter-label {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bs-secondary-color);
}
.llmr-filter-group input {
width: 160px;
}
.llmr-filter-group input[type="date"] {
width: 140px;
}
.llmr-filter-actions {
display: flex;
gap: 0.5rem;
padding-bottom: 1px;
}
/* ── State (loading / empty / error) ────────────────────────────────────────── */
.llmr-state {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 3rem 1rem;
color: var(--bs-secondary-color);
font-size: 0.88rem;
}
.llmr-state i { font-size: 1.1rem; }
.llmr-state--error { color: var(--bs-danger); }
/* ── Table ───────────────────────────────────────────────────────────────────── */
.llmr-table-wrap {
overflow-x: auto;
}
.llmr-table {
font-size: 0.82rem;
margin: 0;
}
.llmr-table thead th {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bs-secondary-color);
border-bottom-width: 1px;
white-space: nowrap;
}
.llmr-table tbody tr:hover td { background: var(--bs-tertiary-bg); }
.llmr-row--error td { color: var(--bs-danger); }
.llmr-row--error-detail td {
font-size: 0.78rem;
color: var(--bs-danger);
padding-top: 0;
border-top: none;
background: color-mix(in srgb, var(--bs-danger) 5%, transparent);
}
.llmr-row--error-detail i { margin-right: 4px; }
.llmr-model {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--bs-font-monospace);
font-size: 0.78rem;
}
.llmr-date {
white-space: nowrap;
color: var(--bs-secondary-color);
}
.llmr-num {
font-variant-numeric: tabular-nums;
font-family: var(--bs-font-monospace);
font-size: 0.78rem;
}
.llmr-cache-hit { color: var(--bs-success); font-weight: 600; }
.llmr-badge-agent,
.llmr-badge-source {
display: inline-block;
font-size: 0.7rem;
font-weight: 500;
padding: 1px 7px;
border-radius: 4px;
background: var(--bs-tertiary-bg);
color: var(--bs-secondary-color);
font-family: var(--bs-font-monospace);
}
.llmr-badge-agent { color: #7c3aed; background: color-mix(in srgb, #7c3aed 10%, transparent); }
.llmr-badge-source { color: #0369a1; background: color-mix(in srgb, #0369a1 10%, transparent); }
/* ── Pagination ──────────────────────────────────────────────────────────────── */
.llmr-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 0.5rem 0 1rem;
}
.llmr-page-info {
font-size: 0.82rem;
color: var(--bs-secondary-color);
}
/* ── Clickable rows ──────────────────────────────────────────────────────────── */
.llmr-row--clickable { cursor: pointer; }
.llmr-row--clickable:hover td { background: var(--bs-tertiary-bg); }
/* ── Detail — back bar ───────────────────────────────────────────────────────── */
.llmr-detail-back {
display: flex;
align-items: center;
gap: 0.75rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--bs-border-color);
}
.llmr-detail-title {
font-size: 1rem;
font-weight: 600;
color: var(--bs-body-color);
display: flex;
align-items: center;
gap: 0.4rem;
}
.llmr-detail-id {
font-family: var(--bs-font-monospace);
font-weight: 400;
color: var(--bs-secondary-color);
}
/* ── Detail — stat bar ───────────────────────────────────────────────────────── */
.llmr-detail-statbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.875rem;
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 8px;
}
.llmr-detail-model {
font-family: var(--bs-font-monospace);
font-size: 0.78rem;
color: var(--bs-body-color);
font-weight: 500;
}
.llmr-detail-sep {
flex: 1;
}
.llmr-detail-stat {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
color: var(--bs-secondary-color);
font-variant-numeric: tabular-nums;
}
.llmr-detail-stat i { font-size: 0.78rem; }
.llmr-detail-stat--cache {
color: var(--bs-success);
font-weight: 600;
}
.llmr-detail-date {
font-size: 0.78rem;
color: var(--bs-secondary-color);
white-space: nowrap;
}
.llmr-detail-pill {
font-size: 0.7rem;
font-weight: 500;
padding: 1px 7px;
border-radius: 4px;
background: var(--bs-tertiary-bg);
color: var(--bs-secondary-color);
font-family: var(--bs-font-monospace);
}
.llmr-detail-pill--stack {
color: #b45309;
background: color-mix(in srgb, #b45309 10%, transparent);
}
.llmr-detail-error-badge {
font-size: 0.72rem;
font-weight: 600;
color: var(--bs-danger);
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.llmr-detail-error-box {
font-size: 0.82rem;
color: var(--bs-danger);
background: color-mix(in srgb, var(--bs-danger) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--bs-danger) 20%, transparent);
border-radius: 6px;
padding: 0.5rem 0.75rem;
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
/* ── Purged payload banner ───────────────────────────────────────────────────── */
.llmr-purged-banner {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.84rem;
color: var(--bs-warning-text-emphasis);
background: var(--bs-warning-bg-subtle);
border: 1px solid var(--bs-warning-border-subtle);
border-radius: 6px;
padding: 0.6rem 0.875rem;
}
/* ── Collapsible sections ────────────────────────────────────────────────────── */
.llmr-section {
border: 1px solid var(--bs-border-color);
border-radius: 8px;
overflow: hidden;
}
.llmr-section-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.875rem;
cursor: pointer;
user-select: none;
background: var(--bs-tertiary-bg);
transition: background 0.1s;
}
.llmr-section-header:hover { background: color-mix(in srgb, var(--bs-tertiary-bg) 80%, var(--bs-border-color)); }
.llmr-section-chevron { color: var(--bs-secondary-color); font-size: 0.7rem; }
.llmr-section-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bs-secondary-color);
}
.llmr-section-badge {
margin-left: auto;
font-size: 0.7rem;
font-weight: 500;
padding: 1px 8px;
border-radius: 20px;
background: var(--bs-border-color);
color: var(--bs-secondary-color);
}
.llmr-section-body {
display: none;
padding: 0.875rem;
border-top: 1px solid var(--bs-border-color);
background: var(--bs-body-bg);
}
.llmr-section--open .llmr-section-body { display: block; }
/* ── Key-value table ─────────────────────────────────────────────────────────── */
.llmr-kv-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.llmr-kv-key {
width: 200px;
padding: 3px 12px 3px 0;
font-family: var(--bs-font-monospace);
font-size: 0.78rem;
color: var(--bs-secondary-color);
vertical-align: top;
white-space: nowrap;
}
.llmr-kv-val {
padding: 3px 0;
word-break: break-word;
vertical-align: top;
}
/* ── System prompt ───────────────────────────────────────────────────────────── */
.llmr-system-md {
font-size: 0.88rem;
line-height: 1.6;
max-height: 60vh;
overflow-y: auto;
}
/* ── Conversation messages ───────────────────────────────────────────────────── */
.llmr-msg-list {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.llmr-msg {
border-radius: 6px;
border: 1px solid var(--bs-border-color);
border-left-width: 3px;
overflow: hidden;
}
.llmr-msg--user { border-left-color: #0369a1; }
.llmr-msg--assistant { border-left-color: #7c3aed; }
.llmr-msg--system { border-left-color: #b45309; }
.llmr-msg--unknown { border-left-color: var(--bs-secondary-color); }
.llmr-msg-role {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 4px 10px;
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
}
.llmr-msg--user .llmr-msg-role { color: #0369a1; }
.llmr-msg--assistant .llmr-msg-role { color: #7c3aed; }
.llmr-msg--system .llmr-msg-role { color: #b45309; }
.llmr-msg-body {
padding: 0.625rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* ── Text block (plain preformatted) ─────────────────────────────────────────── */
.llmr-text-block {
font-size: 0.8rem;
font-family: var(--bs-font-monospace);
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 0;
background: transparent;
border: none;
color: var(--bs-body-color);
max-height: 500px;
overflow-y: auto;
}
/* ── Tool blocks ─────────────────────────────────────────────────────────────── */
.llmr-tool-block {
border: 1px solid var(--bs-border-color);
border-radius: 5px;
overflow: hidden;
font-size: 0.8rem;
}
.llmr-tool-block--use { border-left: 3px solid #0891b2; }
.llmr-tool-block--result { border-left: 3px solid #059669; }
.llmr-tool-block--error { border-left: 3px solid var(--bs-danger); }
.llmr-tool-block-header {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 4px 8px;
background: var(--bs-tertiary-bg);
cursor: pointer;
user-select: none;
}
.llmr-tool-block-header:hover { background: color-mix(in srgb, var(--bs-tertiary-bg) 70%, var(--bs-border-color)); }
.llmr-tool-block--use .llmr-tool-block-header i:first-child { color: #0891b2; }
.llmr-tool-block--result .llmr-tool-block-header i:first-child { color: #059669; }
.llmr-tool-name {
font-family: var(--bs-font-monospace);
font-size: 0.78rem;
font-weight: 600;
}
.llmr-tool-id {
font-family: var(--bs-font-monospace);
font-size: 0.7rem;
color: var(--bs-secondary-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
}
.llmr-tool-toggle { color: var(--bs-secondary-color); font-size: 0.75rem; }
.llmr-tool-preview {
font-family: var(--bs-font-monospace);
font-size: 0.72rem;
color: var(--bs-secondary-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.llmr-tool-expanded {
border-top: 1px solid var(--bs-border-color);
}
.llmr-tool-section-label {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--bs-secondary-color);
padding: 4px 8px 2px;
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
display: flex;
align-items: center;
gap: 0.4rem;
}
.llmr-tool-section-label--result { color: #059669; }
/* ── Tool definitions list ───────────────────────────────────────────────────── */
.llmr-tool-def-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.llmr-tool-def {
border: 1px solid var(--bs-border-color);
border-left: 3px solid #0891b2;
border-radius: 5px;
overflow: hidden;
}
.llmr-tool-def-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 5px 8px;
background: var(--bs-tertiary-bg);
cursor: pointer;
user-select: none;
}
.llmr-tool-def-header:hover {
background: color-mix(in srgb, var(--bs-tertiary-bg) 70%, var(--bs-border-color));
}
.llmr-tool-def-desc {
font-size: 0.75rem;
color: var(--bs-secondary-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.llmr-tool-pre {
font-size: 0.75rem;
font-family: var(--bs-font-monospace);
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 0.5rem 0.75rem;
background: var(--bs-tertiary-bg);
border-top: 1px solid var(--bs-border-color);
max-height: 400px;
overflow-y: auto;
}
/* ── Reasoning block ─────────────────────────────────────────────────────────── */
.llmr-reasoning-block {
border: 1px solid #7c3aed44;
border-left: 3px solid #7c3aed;
border-radius: 5px;
overflow: hidden;
font-size: 0.8rem;
}
.llmr-reasoning-header {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 4px 8px;
background: color-mix(in srgb, #7c3aed 8%, var(--bs-tertiary-bg));
cursor: pointer;
user-select: none;
color: #7c3aed;
font-family: var(--bs-font-monospace);
font-size: 0.78rem;
font-weight: 600;
}
.llmr-reasoning-header:hover {
background: color-mix(in srgb, #7c3aed 15%, var(--bs-tertiary-bg));
}
.llmr-reasoning-body {
border-top: 1px solid #7c3aed44;
color: var(--bs-secondary-color);
}
+641
View File
@@ -0,0 +1,641 @@
:root {
--mobile-nav-height: 60px;
--mobile-chat-size: 56px;
--mobile-bg: #ffffff;
--mobile-nav-bg: #ffffff;
--mobile-nav-border: #dee2e6;
--mobile-nav-color: #6c757d;
--mobile-nav-active: #0d6efd;
--mobile-chat-bg: #0d6efd;
/* Grouped-list surfaces: a faintly recessed list "well" with raised cards
on top, so a card reads as elevated even where it shares the page colour. */
--mobile-list-bg: #f4f4f7;
--mobile-card-bg: #ffffff;
--mobile-card-border: #ececf1;
--mobile-card-shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 6px 16px rgba(16, 24, 40, 0.05);
/* Raised neutral surface for the chat composer + assistant bubbles, kept
grey/neutral on purpose so the mobile chat doesn't inherit the desktop's
indigo-tinted --msg-assistant-bg (which reads as purple in dark mode). */
--mobile-surface: #f2f2f7;
--mobile-surface-text: #1c1c1e;
}
[data-bs-theme="dark"] {
--mobile-bg: #1c1c1e;
--mobile-nav-bg: #1c1c1e;
--mobile-nav-border: #3a3a3c;
--mobile-nav-color: #8e8e93;
--mobile-nav-active: #6ea8fe;
--mobile-chat-bg: #0d6efd;
--mobile-surface: #2c2c2e;
--mobile-surface-text: #e5e5ea;
/* Dark: recess the list below the page colour, raise cards above it. */
--mobile-list-bg: #161618;
--mobile-card-bg: #2c2c2e;
--mobile-card-border: #3a3a3c;
--mobile-card-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.3);
}
body {
background: var(--mobile-bg);
color-scheme: light dark;
}
/* Account for iOS status bar overlay (black-translucent) */
#mobile-root {
padding-top: env(safe-area-inset-top);
}
/* Native shell (?native=true): the HTML bottom nav is hidden and the native
chrome owns the status bar + tab bar, so drop the web-side safe-area insets it
would otherwise double-apply. */
mobile-app[data-native] #mobile-root { padding-top: 0; }
mobile-app[data-native] .chat-page-input-area { padding-bottom: 10px; }
/* ── Section header ─────────────────────────────────── */
.mobile-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--mobile-nav-border);
flex-shrink: 0;
}
.mobile-section-title {
font-size: 1.05rem;
font-weight: 700;
color: var(--bs-body-color, #212529);
display: flex;
align-items: center;
gap: 8px;
}
.mobile-alert-error {
margin: 12px 16px;
padding: 10px 14px;
background: #f8d7da;
color: #842029;
border-radius: 8px;
font-size: 0.85rem;
}
/* Refresh button in section headers (agent-inbox.css isn't loaded on mobile). */
.inbox-refresh-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border: 1px solid var(--mobile-nav-border);
border-radius: 8px;
background: transparent;
color: var(--mobile-nav-color);
font-size: 1rem;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.inbox-refresh-btn:active { transform: scale(0.94); }
/* ── Inbox ──────────────────────────────────────────── */
.mobile-inbox {
display: flex;
flex-direction: column;
height: 100%;
}
.mobile-inbox-list {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.inbox-section-label {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--mobile-nav-color);
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
/* ── Footer buttons ──────────────────────────────────── */
.inbox-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px;
font-size: 0.9rem;
font-weight: 600;
border-radius: 10px;
border: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.inbox-btn-approve {
background: #198754;
color: #fff;
}
.inbox-btn-reject {
background: transparent;
color: #dc3545;
border: 1.5px solid #dc3545;
}
/* ── Empty state override (full-height inside mobile-inbox) ── */
.mobile-inbox .inbox-empty {
flex: 1;
height: auto;
padding: 40px 20px;
}
#mobile-root {
display: flex;
flex-direction: column;
height: 100%;
height: 100dvh;
}
.mobile-content {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── Coming soon ────────────────────────────────────── */
.mobile-coming-soon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
height: 100%;
color: var(--mobile-nav-color);
}
.mobile-coming-soon i { font-size: 2.5rem; opacity: 0.35; }
.mobile-coming-soon p {
margin: 0;
font-size: 0.95rem;
font-weight: 500;
opacity: 0.6;
}
/* ── Bottom nav ─────────────────────────────────────── */
.mobile-nav {
display: flex;
align-items: center;
justify-content: space-around;
height: var(--mobile-nav-height);
background: var(--mobile-nav-bg);
border-top: 1px solid var(--mobile-nav-border);
padding-bottom: env(safe-area-inset-bottom);
position: relative;
z-index: 100;
}
.mobile-nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
flex: 1;
height: 100%;
cursor: pointer;
color: var(--mobile-nav-color);
font-size: 0.62rem;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: color 0.15s;
}
.mobile-nav-item.active {
color: var(--mobile-nav-active);
}
.mobile-nav-item i {
font-size: 1.3rem;
line-height: 1;
}
/* ── Center chat FAB ────────────────────────────────── */
.mobile-nav-item.chat-btn {
position: relative;
top: -14px;
flex: 1.2;
}
.chat-fab {
width: var(--mobile-chat-size);
height: var(--mobile-chat-size);
border-radius: 50%;
background: var(--mobile-chat-bg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25);
transition: transform 0.15s, box-shadow 0.15s;
}
.chat-fab i {
font-size: 1.5rem;
color: #fff !important;
}
.mobile-nav-item.chat-btn.active .chat-fab {
box-shadow: 0 4px 14px rgba(13, 110, 253, 0.45);
transform: scale(1.06);
}
.mobile-nav-item.chat-btn span {
margin-top: 6px;
font-size: 0.6rem;
}
/* ── Chat page ──────────────────────────────────────── */
.chat-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Wider bubbles on narrow screens */
.chat-page .copilot-msg {
max-width: 96%;
/* Allow the bubble to shrink below its content's intrinsic width so inner
scroll containers (code/diff/table) collapse and scroll internally instead
of widening the bubble past the viewport. */
min-width: 0;
font-size: 0.92rem;
}
/* Assistant bubbles use a neutral surface instead of the desktop's indigo-tinted
--msg-assistant-bg, which clashed with the mobile neutral palette. */
.chat-page .copilot-msg.assistant {
background: var(--mobile-surface);
color: var(--mobile-surface-text);
}
.chat-page-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-page-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
/* Clip horizontally: wide content (code blocks, tables, diffs) scrolls inside
its own bubble box, so the message list itself never pans sideways. Without
this, `overflow-y: auto` makes the unset x-axis compute to `auto` too. */
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
height: 100%;
color: var(--mobile-nav-color);
}
.chat-page-empty i {
font-size: 2rem;
opacity: 0.4;
}
.chat-page-empty p {
margin: 0;
font-size: 0.9rem;
opacity: 0.6;
}
.chat-page-input-area {
padding: 10px 12px;
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--mobile-nav-border);
background: var(--mobile-bg);
flex-shrink: 0;
}
/* Unified composer: a single bordered box wrapping the textarea and the
toolbar below it, mirroring the desktop copilot (.copilot-composer). Uses
the neutral mobile surface so it matches the assistant bubbles. */
.chat-page-composer {
display: flex;
flex-direction: column;
border: 1px solid var(--mobile-nav-border);
border-radius: 0.75rem;
background: var(--mobile-surface);
transition: border-color 0.15s, box-shadow 0.15s;
}
.chat-page-composer:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
}
.chat-page-textarea {
display: block;
width: 100%;
box-sizing: border-box;
resize: none;
border: none;
outline: none;
background: transparent;
color: inherit;
font-size: 0.95rem;
line-height: 1.4;
padding: 12px 14px 6px;
min-height: 44px;
max-height: 120px;
overflow-y: auto;
}
/* ── Composer toolbar (below the textarea) ─────────────────────────────────── */
.chat-page-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
padding: 6px 8px;
}
.chat-page-toolbar-left,
.chat-page-toolbar-right {
display: flex;
align-items: center;
gap: 6px;
}
/* Model selector — a native <select> styled as a subtle pill, opening the
OS-native picker for the best touch ergonomics. */
.chat-page-model-pill {
appearance: none;
-webkit-appearance: none;
max-width: 150px;
padding: 5px 26px 5px 10px;
border: 1px solid transparent;
border-radius: 999px;
background-color: transparent;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 16 16' fill='%2394a3b8'%3E%3Cpath d='M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
font-size: 0.78rem;
color: var(--mobile-nav-color);
cursor: pointer;
}
.chat-page-model-pill:focus,
.chat-page-model-pill:hover {
border-color: rgba(99, 102, 241, 0.3);
}
/* ── Mic + send buttons ────────────────────────────────────────────────────── */
.chat-page-mic-btn,
.chat-page-send {
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 1rem;
color: #fff;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.chat-page-mic-btn {
background: var(--accent);
}
.chat-page-mic-btn:active { transform: scale(0.94); }
.chat-page-send {
background: var(--accent);
}
.chat-page-send:active { transform: scale(0.94); }
.chat-page-send--stop {
background: #dc2626;
}
.chat-page-mic-btn--recording {
background: #dc2626;
animation: chat-page-pulse 1s ease-in-out infinite;
}
@keyframes chat-page-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
/* MCP dropdown opens upward on mobile */
.chat-page-gear-dropdown {
bottom: 100%;
top: auto;
margin-bottom: 6px;
}
/* ── Chat header back button (inside a project) ─────── */
.chat-page-back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
margin-right: 2px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--mobile-nav-active);
font-size: 1.1rem;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
/* ── Projects ───────────────────────────────────────── */
.mobile-projects {
display: flex;
flex-direction: column;
height: 100%;
}
.mobile-projects-list {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 14px 16px calc(14px + env(safe-area-inset-bottom));
background: var(--mobile-list-bg);
display: flex;
flex-direction: column;
gap: 12px;
}
.mobile-projects .inbox-empty {
flex: 1;
height: auto;
padding: 40px 20px;
background: var(--mobile-list-bg);
}
.project-card {
display: flex;
align-items: center;
gap: 14px;
padding: 13px 14px;
border: 1px solid var(--mobile-card-border);
border-radius: 16px;
background: var(--mobile-card-bg);
box-shadow: var(--mobile-card-shadow);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform 0.12s ease, border-color 0.12s, box-shadow 0.12s;
}
.project-card:active {
transform: scale(0.985);
border-color: var(--mobile-nav-active);
box-shadow: 0 0 0 1px var(--mobile-nav-active);
}
/* Rounded gradient "app icon" tile — the main visual anchor for each card. */
.project-card-icon {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
color: #fff;
background: linear-gradient(135deg, var(--accent, #6366f1), var(--accent-hover, #4f46e5));
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35);
}
.project-card-main {
flex: 1;
min-width: 0;
}
.project-card-name {
font-size: 1rem;
font-weight: 650;
line-height: 1.25;
color: var(--mobile-surface-text, #1c1c1e);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-card-desc {
margin-top: 3px;
font-size: 0.82rem;
line-height: 1.35;
color: var(--mobile-nav-color);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.project-card-chevron {
color: var(--mobile-nav-color);
font-size: 0.95rem;
flex-shrink: 0;
opacity: 0.6;
}
/* ── File viewer (mobile) ───────────────────────────── */
/* Full-height column: fixed header + scrollable body. The body content classes
(.fv-md / .fv-code / .fv-pdf / .fv-image-wrap / .fv-state / ...) come from the
shared css/file-viewer.css, loaded by mobile.html. */
.mobile-file-viewer {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* Let the title (and its filename child) shrink so the ellipsis kicks in. */
.mobile-file-viewer .mobile-section-title { min-width: 0; flex: 1; }
.mobile-file-viewer .fv-body {
flex: 1;
min-height: 0;
}
/* Filename: monospace, single line with ellipsis, leaving room for the back
button. Full path is on the element's title attribute. */
.fv-mobile-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.92rem;
font-weight: 500;
/* Ellipsise the start so a long filename keeps its tail (extension) visible;
`<bdi>` around the name preserves left-to-right order. */
direction: rtl;
text-align: left;
}
/* Let the title group shrink so the name ellipsises and the download button
stays pinned to the right of the header. */
.mobile-file-viewer .mobile-section-title {
flex: 1 1 auto;
min-width: 0;
}
+58
View File
@@ -0,0 +1,58 @@
/* ── Models hub landing page ────────────────────────────────────────────────── */
.models-hub {
padding: 2rem 2.5rem;
width: 100%;
}
.models-hub-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.models-type-card {
border: 1px solid var(--bs-border-color);
border-radius: 10px;
padding: 1.25rem;
cursor: pointer;
transition: box-shadow 0.15s, border-color 0.15s;
background: var(--bs-body-bg);
text-align: left;
width: 100%;
}
.models-type-card:hover {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border-color: var(--bs-primary);
}
.models-type-card-icon {
font-size: 1.5rem;
margin-bottom: 0.6rem;
color: var(--bs-primary);
}
.models-type-card-title {
font-weight: 600;
font-size: 1rem;
margin-bottom: 0.35rem;
}
.models-type-card-desc {
font-size: 0.82rem;
color: var(--bs-secondary-color);
margin-bottom: 0.75rem;
line-height: 1.4;
}
.models-type-card-count {
font-size: 0.8rem;
font-weight: 500;
color: var(--bs-secondary-color);
}
.models-type-card-count.has-models {
color: var(--bs-success);
}
+36
View File
@@ -0,0 +1,36 @@
/* ── Image generation models section ───────────────────────────────────────── */
.ig-source-badge {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.18em 0.55em;
border-radius: 4px;
flex-shrink: 0;
white-space: nowrap;
}
.ig-source-plugin {
background: color-mix(in srgb, #7c3aed 14%, transparent);
color: #7c3aed;
}
.ig-source-cloud {
background: var(--bs-secondary-bg, #f0f2f5);
color: var(--bs-secondary-color, #6b7280);
}
.ig-priority-tag {
font-size: 0.72rem;
font-weight: 500;
color: var(--bs-secondary-color, #9ca3af);
}
.ig-card-desc {
font-size: 0.82rem;
color: var(--bs-secondary-color);
line-height: 1.4;
padding-left: 0;
padding-top: 2px;
}
+510
View File
@@ -0,0 +1,510 @@
/* ── LLM models section ─────────────────────────────────────────────────────── */
/* ── Page shell ── */
.llm-page {
padding: 0;
max-width: 100%;
display: flex;
flex-direction: column;
height: 100%;
}
.llm-page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
min-height: 54px;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
flex-shrink: 0;
gap: 12px;
}
.llm-header-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0 !important;
border-radius: 6px !important;
flex-shrink: 0;
}
.back-btn i {
font-size: 1rem;
line-height: 1;
}
.llm-page-title {
font-size: 1.05rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.llm-page-count {
font-size: 0.76rem;
color: var(--bs-secondary-color);
font-weight: 500;
}
/* ── Card list ── */
.llm-card-list {
padding: 16px 20px;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── Single card ── */
.llm-card {
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 10px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 6px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.llm-card:hover {
border-color: var(--bs-primary-border-subtle, #9ec5fe);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
/* ── Row 1: move btns + dots + name + badge + actions ── */
.llm-card-row1 {
display: flex;
align-items: center;
gap: 10px;
}
.llm-move-btns {
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.llm-move-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 20px;
border: none;
background: transparent;
color: var(--bs-secondary-color, #9ca3af);
cursor: pointer;
border-radius: 4px;
font-size: 0.6rem;
padding: 0;
line-height: 1;
transition: background 0.1s, color 0.1s;
}
.llm-move-btn:hover:not(:disabled) {
background: var(--bs-tertiary-bg, #f0f2f5);
color: var(--bs-body-color, #374151);
}
.llm-move-btn:active:not(:disabled) {
background: var(--bs-border-color, #e2e6ee);
}
.llm-move-btn:disabled {
opacity: 0.25;
cursor: default;
}
/* Strength & status dots */
.llm-strength-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
cursor: default;
flex-shrink: 0;
}
/* Name */
.llm-card-name {
font-weight: 600;
font-size: 0.9rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Badge "default" */
.llm-card-badge {
font-size: 0.58rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15em 0.5em;
border-radius: 4px;
background: var(--bs-primary-bg-subtle, #eef2ff);
color: var(--bs-primary, #4f46e5);
flex-shrink: 0;
white-space: nowrap;
}
/* Action buttons */
.llm-card-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
margin-left: auto;
}
.llm-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
background: transparent;
color: var(--bs-secondary-color, #9ca3af);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.1s;
}
.llm-btn-icon:hover {
background: var(--bs-tertiary-bg, #f0f2f5);
color: var(--bs-body-color, #374151);
}
.llm-btn-edit:hover {
color: var(--bs-primary, #0d6efd);
background: var(--bs-primary-bg-subtle, #eef2ff);
}
.llm-btn-delete:hover {
color: var(--bs-danger, #dc3545);
background: var(--bs-danger-bg-subtle, #fef0f0);
}
/* ── Row 2: meta info (indented under name) ── */
.llm-card-row2 {
display: flex;
align-items: center;
gap: 12px;
padding-left: 38px;
font-size: 0.78rem;
color: var(--bs-secondary-color, #6b7280);
flex-wrap: wrap;
}
.llm-provider-name {
font-weight: 500;
}
.llm-model-id {
font-family: var(--bs-font-monospace, monospace);
font-size: 0.74rem;
color: var(--bs-secondary-color, #9ca3af);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 280px;
}
.llm-price-tag {
font-size: 0.74rem;
font-weight: 600;
color: var(--bs-success, #16a34a);
white-space: nowrap;
}
/* ── Row 3: scope pills (indented) ── */
.llm-card-row3 {
display: flex;
align-items: center;
gap: 4px;
padding-left: 38px;
flex-wrap: wrap;
}
.llm-scope-pill {
display: inline-block;
font-size: 0.65rem;
font-weight: 500;
padding: 0.15em 0.5em;
border-radius: 4px;
background: var(--bs-secondary-bg, #f0f2f5);
color: var(--bs-secondary-color, #6b7280);
}
.llm-params-pill {
background: #6366f1 !important;
color: #fff !important;
}
/* ── Empty state ── */
.llm-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: var(--bs-secondary-color, #9ca3af);
gap: 8px;
flex: 1;
}
.llm-empty-state i {
font-size: 2rem;
opacity: 0.4;
}
.llm-empty-state p {
margin: 0;
font-size: 0.9rem;
}
/* ── Mobile tweaks ── */
@media (max-width: 600px) {
.llm-page-header {
padding: 10px 14px;
min-height: 50px;
}
.llm-card-list {
padding: 10px 12px;
gap: 8px;
}
.llm-card {
padding: 12px 12px;
gap: 5px;
}
.llm-card-row1 {
gap: 7px;
}
.llm-move-btn {
width: 24px;
height: 18px;
font-size: 0.55rem;
}
.llm-card-actions {
gap: 0;
}
.llm-btn-icon {
width: 28px;
height: 28px;
font-size: 0.8rem;
}
.llm-card-row2 {
padding-left: 31px;
gap: 8px;
font-size: 0.74rem;
}
.llm-card-row3 {
padding-left: 31px;
}
}
/* ── Modal ── */
.llm-modal {
width: 100%;
max-width: 520px;
max-height: calc(100vh - 100px);
overflow-y: auto;
}
.llm-modal-title {
font-weight: 600;
font-size: 1rem;
margin-bottom: 1.25rem;
}
.llm-modal-wide {
max-width: 680px;
}
/* ── Scope checkbox grid ── */
.llm-scope-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.35rem 1rem;
}
/* ── Provider picker grid ── */
.llm-provider-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.llm-provider-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem 0.75rem;
border: 1px solid var(--bs-border-color);
border-radius: 8px;
background: var(--bs-body-bg);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
text-align: center;
}
.llm-provider-card:hover {
border-color: var(--bs-primary);
background: var(--bs-primary-bg-subtle, rgba(13,110,253,0.06));
}
.llm-provider-card-name {
font-weight: 600;
font-size: 0.85rem;
}
/* ── OpenRouter model list ── */
.llm-or-model-list {
border: 1px solid var(--bs-border-color);
border-radius: 6px;
max-height: 240px;
overflow-y: auto;
}
.llm-or-model-row {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.45rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--bs-border-color);
transition: background 0.1s;
}
.llm-or-model-row:last-child {
border-bottom: none;
}
.llm-or-model-row:hover {
background: var(--bs-tertiary-bg, rgba(0,0,0,0.04));
}
.llm-or-model-row.selected {
background: var(--bs-primary-bg-subtle, rgba(13,110,253,0.1));
border-left: 3px solid var(--bs-primary);
}
.llm-or-model-name {
font-size: 0.82rem;
font-weight: 500;
}
.llm-or-model-meta {
display: flex;
gap: 0.75rem;
align-items: center;
}
.llm-or-price {
font-size: 0.72rem;
font-weight: 600;
color: var(--bs-success, #198754);
background: var(--bs-success-bg-subtle, rgba(25,135,84,0.1));
padding: 0.05em 0.35em;
border-radius: 3px;
}
/* ── TTS remote model picker ─────────────────────────────────────────────── */
.tts-model-pick-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 420px;
overflow-y: auto;
}
.tts-model-pick-item {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0.65rem 0.85rem;
border: 1px solid var(--bs-border-color);
border-radius: 8px;
background: var(--bs-body-bg);
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background 0.15s;
}
.tts-model-pick-item:hover {
border-color: var(--bs-primary);
background: var(--bs-primary-bg-subtle, rgba(13,110,253,0.06));
}
.tts-model-pick-name {
font-weight: 600;
font-size: 0.85rem;
}
.tts-model-pick-desc {
font-size: 0.78rem;
color: var(--bs-secondary-color);
margin-top: 0.2rem;
}
.tts-model-pick-langs {
font-size: 0.72rem;
color: var(--bs-secondary-color);
margin-top: 0.15rem;
font-style: italic;
}
.tts-model-pick-row1 {
display: flex;
align-items: baseline;
gap: 0.5rem;
width: 100%;
}
.tts-model-pick-cost {
margin-left: auto;
font-size: 0.75rem;
font-weight: 600;
color: var(--bs-secondary-color);
white-space: nowrap;
}
+84
View File
@@ -0,0 +1,84 @@
/* ── Custom element display + layout shell ──────────────────────────────────── */
tasks-page {
display: none; /* toggled by JS */
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--toolbar-border, #e5e9f0);
}
task-running-section,
task-cron-jobs-section,
task-scheduled-section,
task-history-section {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
}
llm-providers-page,
models-hub-page {
display: none; /* toggled by JS */
flex: 1;
min-width: 0;
overflow-y: auto;
border-right: 1px solid var(--toolbar-border, #e5e9f0);
}
models-llm-section,
models-transcribe-section,
models-image-section {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
}
projects-page {
display: none; /* toggled by JS */
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--toolbar-border, #e5e9f0);
}
project-list-section,
project-board-section {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
}
session-detail-page,
tic-sessions-page,
file-viewer-page {
display: none; /* toggled by JS */
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--toolbar-border, #e5e9f0);
}
/* ── Workspace placeholder ──────────────────────────────────────────────────── */
.app-workspace {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
justify-content: center;
color: var(--placeholder-color, #94a3b8);
font-size: 0.9rem;
}
+132
View File
@@ -0,0 +1,132 @@
/* ── Project list page ─────────────────────────────────────────────────────── */
.project-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
}
.project-page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
min-height: 54px;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
flex-shrink: 0;
}
.project-page-title {
margin: 0;
font-weight: 700;
font-size: 1.05rem;
display: flex;
align-items: center;
gap: 8px;
}
/* ── Grid ── */
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
padding: 20px;
align-content: start;
overflow-y: auto;
flex: 1;
}
/* ── Card ── */
.project-card {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 10px;
padding: 16px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.project-card:hover {
border-color: var(--bs-primary-border-subtle, #9ec5fe);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.project-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.project-card-title {
font-weight: 600;
font-size: 0.95rem;
line-height: 1.3;
flex: 1;
min-width: 0;
}
.project-card-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.project-card-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--bs-secondary-color);
opacity: 0.6;
cursor: pointer;
transition: opacity 0.12s, background 0.12s;
}
.project-card-icon-btn:hover {
opacity: 1;
background: var(--bs-secondary-bg);
}
.project-card-icon-btn--danger:hover {
color: var(--bs-danger, #dc3545);
background: rgba(220, 53, 69, 0.08);
}
.project-card-path {
font-size: 0.78rem;
color: var(--bs-secondary-color);
font-family: var(--bs-font-monospace, monospace);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-card-desc {
font-size: 0.82rem;
color: var(--bs-secondary-color);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.project-card-meta {
font-size: 0.72rem;
color: var(--bs-secondary-color);
margin-top: auto;
padding-top: 4px;
}
+252
View File
@@ -0,0 +1,252 @@
/* ── Tab bar ────────────────────────────────────────────────────────────────── */
.project-tab-bar {
display: flex;
gap: 0;
padding: 0 20px;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
flex-shrink: 0;
}
.project-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 9px 16px;
font-size: 0.82rem;
font-weight: 500;
color: var(--bs-secondary-color);
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.project-tab:hover {
color: var(--bs-body-color);
}
.project-tab--active {
color: var(--bs-primary, #0d6efd);
border-bottom-color: var(--bs-primary, #0d6efd);
}
/* ── Ticket list (scrollable container) ─────────────────────────────────────── */
.ticket-list {
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
padding: 20px;
gap: 0;
}
/* ── Section header ─────────────────────────────────────────────────────────── */
.ticket-section {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 24px;
}
.ticket-section-header {
display: flex;
align-items: center;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--bs-secondary-color);
padding: 4px 0 6px;
border-bottom: 1px solid var(--bs-border-color);
margin-bottom: 4px;
}
.ticket-section-header--running {
color: var(--bs-primary, #0d6efd);
border-bottom-color: var(--bs-primary-border-subtle, #9ec5fe);
}
.ticket-section-header--completed {
color: #198754;
border-bottom-color: rgba(25, 135, 84, 0.25);
}
[data-bs-theme="dark"] .ticket-section-header--completed {
color: #75b798;
}
.ticket-section-empty {
font-size: 0.8rem;
color: var(--bs-secondary-color);
opacity: 0.5;
padding: 8px 0;
}
/* ── Ticket card ────────────────────────────────────────────────────────────── */
.ticket-card {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-left: 3px solid var(--bs-border-color);
border-radius: 6px;
padding: 12px 16px;
transition: box-shadow 0.15s;
}
.ticket-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
}
.ticket-card--running {
border-left-color: var(--bs-primary, #0d6efd);
}
.ticket-card--done {
border-left-color: #198754;
}
.ticket-card--failed {
border-left-color: #dc3545;
}
.ticket-card-header {
display: flex;
align-items: center;
gap: 8px;
}
.ticket-card-title {
font-weight: 600;
font-size: 0.92rem;
line-height: 1.35;
flex: 1;
min-width: 0;
word-break: break-word;
}
.ticket-card-desc {
font-size: 0.8rem;
color: var(--bs-secondary-color);
line-height: 1.45;
white-space: pre-wrap;
cursor: pointer;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ticket-card-desc--expanded {
display: block;
-webkit-line-clamp: unset;
overflow: visible;
}
.ticket-card-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 0.74rem;
color: var(--bs-secondary-color);
flex-wrap: wrap;
}
.ticket-card-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
flex-wrap: wrap;
}
.ticket-card-btn {
font-size: 0.72rem;
padding: 2px 8px;
line-height: 1.5;
}
.ticket-card-running-label {
font-size: 0.78rem;
color: var(--bs-secondary-color);
}
.ticket-card-session-link {
font-size: 0.78rem;
font-family: var(--bs-font-monospace, monospace);
margin-left: auto;
}
.ticket-card-session-btn {
font-family: var(--bs-font-monospace, monospace);
font-size: 0.75rem;
}
/* ── Result / Error area ────────────────────────────────────────────────────── */
.ticket-card-result {
border-radius: 4px;
padding: 12px 14px;
overflow-y: auto;
margin-top: 4px;
}
.ticket-card-result--success {
background: rgba(25, 135, 84, 0.06);
border: 1px solid rgba(25, 135, 84, 0.2);
}
.ticket-card-result--error {
background: rgba(220, 53, 69, 0.06);
border: 1px solid rgba(220, 53, 69, 0.2);
}
.ticket-result-markdown {
font-size: 0.85rem;
line-height: 1.6;
color: var(--bs-body-color);
}
/* Constrain markdown elements inside result */
.ticket-result-markdown p:last-child {
margin-bottom: 0;
}
.ticket-result-markdown pre {
font-size: 0.8rem;
border-radius: 4px;
padding: 8px 10px;
background: var(--bs-tertiary-bg);
overflow-x: auto;
}
.ticket-result-markdown code:not(pre > code) {
font-size: 0.82em;
padding: 1px 4px;
border-radius: 3px;
background: var(--bs-tertiary-bg);
}
.ticket-result-error {
font-size: 0.78rem;
font-family: var(--bs-font-monospace, monospace);
line-height: 1.45;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #58151c;
}
[data-bs-theme="dark"] .ticket-result-error {
color: #ea868f;
}
+259
View File
@@ -0,0 +1,259 @@
/* ── Sidebar ───────────────────────────────────────────────────────────────── */
app-sidebar {
display: flex;
flex-direction: column;
width: var(--sidebar-width);
height: 100%;
background: var(--sidebar-bg);
flex-shrink: 0;
padding: 1.25rem 0 1rem;
overflow-y: auto;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0 1rem 1.25rem;
color: var(--sidebar-brand-color);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.sidebar-brand i {
font-size: 1.1rem;
opacity: 0.85;
}
.sidebar-brand-icon {
width: 22px;
height: 22px;
border-radius: 5px;
object-fit: cover;
flex-shrink: 0;
}
.sidebar-divider {
border-color: var(--sidebar-divider);
margin: 0.85rem 1rem;
opacity: 1;
}
.sidebar-section-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
background: none;
border: none;
padding: 0.2rem 1rem 0.2rem 0.85rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--sidebar-label-color);
cursor: pointer;
margin-top: 0.5rem;
}
.sidebar-section-toggle:hover {
color: var(--bs-body-color);
}
.sidebar-section-toggle i {
font-size: 0.65rem;
}
.sidebar-section-count {
margin-left: auto;
font-size: 0.65rem;
opacity: 0.6;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0 0.5rem;
}
.sidebar-nav--sessions .sidebar-link {
align-items: flex-start;
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.20rem 0.30rem;
color: var(--sidebar-text);
text-decoration: none;
font-size: 0.875rem;
border-radius: 0.25rem;
transition: background 0.12s, color 0.12s;
}
.sidebar-link i {
font-size: 0.95rem;
width: 1.1rem;
text-align: center;
opacity: 0.8;
}
.sidebar-link-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-link:hover {
background: var(--sidebar-hover);
color: var(--sidebar-text-active);
}
.sidebar-link.active {
background: var(--sidebar-active-bg);
color: var(--sidebar-text-active);
font-weight: 500;
border-radius: 0.25rem;
}
.sidebar-link.active i {
opacity: 1;
}
.sidebar-session-meta {
display: flex;
align-items: center;
gap: 0.4rem;
}
.sidebar-session-date {
font-size: 0.72rem;
opacity: 0.55;
line-height: 1.2;
}
.sidebar-agent-badge {
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
opacity: 0.6;
background: var(--bs-secondary-bg, rgba(128,128,128,0.15));
border-radius: 3px;
padding: 0 4px;
line-height: 1.6;
}
.sidebar-session-preview {
display: block;
font-size: 0.78rem;
opacity: 0.85;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
/* ── Submenu (Task Manager) ── */
.sidebar-link-chevron {
margin-left: auto;
font-size: 0.7rem !important;
width: auto !important;
opacity: 0.55;
transition: transform 0.15s;
}
.sidebar-submenu {
display: flex;
flex-direction: column;
gap: 0.05rem;
padding: 0.1rem 0 0.1rem 1.25rem;
}
.sidebar-sublink {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.18rem 0.40rem;
color: var(--sidebar-text);
text-decoration: none;
font-size: 0.84rem;
border-radius: 0.25rem;
transition: background 0.12s, color 0.12s;
}
.sidebar-sublink i {
font-size: 0.82rem;
width: 1rem;
text-align: center;
opacity: 0.7;
}
.sidebar-sublink:hover {
background: var(--sidebar-hover);
color: var(--sidebar-text-active);
}
.sidebar-sublink.active {
background: var(--sidebar-active-bg);
color: var(--sidebar-text-active);
font-weight: 500;
}
.sidebar-sublink.active i {
opacity: 1;
}
/* ── Project quick-links in sidebar ── */
.sidebar-project-link {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.18rem 0.40rem;
color: var(--sidebar-text);
text-decoration: none;
font-size: 0.82rem;
border-radius: 0.25rem;
transition: background 0.12s, color 0.12s;
cursor: pointer;
}
.sidebar-project-link:hover {
background: var(--sidebar-hover);
color: var(--sidebar-text-active);
}
.sidebar-project-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.81rem;
}
.sidebar-project-chat-btn {
display: none;
background: none;
border: none;
padding: 0 0.15rem;
color: inherit;
opacity: 0.6;
cursor: pointer;
font-size: 0.8rem;
line-height: 1;
}
.sidebar-project-link:hover .sidebar-project-chat-btn {
display: flex;
align-items: center;
}
+374
View File
@@ -0,0 +1,374 @@
/* ── Tasks page ──────────────────────────────────────────────────────────────── */
.task-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
}
.task-page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
min-height: 54px;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
flex-shrink: 0;
}
.task-page-title {
margin: 0;
font-weight: 700;
font-size: 1.05rem;
display: flex;
align-items: center;
gap: 8px;
}
/* ── Empty state ── */
.task-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 40px;
color: var(--bs-secondary-color);
}
.task-empty i {
font-size: 2.5rem;
margin-bottom: 12px;
opacity: 0.5;
}
.task-empty p {
margin: 0;
font-size: 0.9rem;
}
.task-empty code {
font-size: 0.82rem;
}
/* ── Grid ── */
.task-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
padding: 20px;
align-content: start;
overflow-y: auto;
flex: 1;
}
/* ── Card ── */
.task-card {
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 10px;
padding: 16px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.task-card:hover {
border-color: var(--bs-primary-border-subtle, #9ec5fe);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.task-card--disabled {
opacity: 0.6;
}
/* ── Card header ── */
.task-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.task-card-title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}
.task-card-title {
font-weight: 600;
font-size: 0.92rem;
line-height: 1.3;
}
/* ── Badges ── */
.task-badge {
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15em 0.55em;
border-radius: 4px;
white-space: nowrap;
}
.task-badge--oneshot {
background: var(--bs-secondary-bg, #e9ecef);
color: var(--bs-secondary-color, #6c757d);
}
.task-badge--cron {
background: rgba(99, 102, 241, 0.12);
color: var(--bs-primary, #6366f1);
}
.task-badge--sync {
background: rgba(99, 102, 241, 0.12);
color: var(--bs-primary, #6366f1);
}
.task-badge--async {
background: rgba(34, 197, 94, 0.12);
color: #16a34a;
}
.task-badge--running {
background: rgba(34, 197, 94, 0.15);
color: #15803d;
}
.task-badge--idle {
background: var(--bs-secondary-bg, rgba(128,128,128,0.12));
color: var(--bs-secondary-color, #6c757d);
}
.task-badge--disabled {
background: rgba(220, 53, 69, 0.1);
color: var(--bs-danger, #dc3545);
}
.task-badge--pending {
background: rgba(13, 110, 253, 0.12);
color: var(--bs-primary, #0d6efd);
}
/* ── Delete button ── */
.task-card-delete {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--bs-danger, #dc3545);
opacity: 0.5;
cursor: pointer;
flex-shrink: 0;
transition: all 0.12s;
}
.task-card-delete:hover {
opacity: 1;
background: var(--bs-danger-bg-subtle, rgba(220,53,69,0.08));
}
/* ── Card description ── */
.task-card-desc {
font-size: 0.82rem;
color: var(--bs-secondary-color);
line-height: 1.4;
}
/* ── Cron expression box ── */
.task-card-expr {
display: flex;
align-items: flex-start;
gap: 8px;
background: var(--bs-tertiary-bg, rgba(0,0,0,0.02));
border: 1px solid var(--bs-border-color);
border-radius: 6px;
padding: 8px 10px;
}
.task-card-expr i {
color: var(--bs-primary, #0d6efd);
flex-shrink: 0;
margin-top: 2px;
}
.task-card-expr-text {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.task-card-human {
font-size: 0.88rem;
font-weight: 500;
line-height: 1.35;
}
.task-card-raw {
font-family: var(--bs-font-monospace, monospace);
font-size: 0.7rem;
color: var(--bs-secondary-color);
word-break: break-all;
line-height: 1.3;
background: transparent;
padding: 0;
}
/* ── Meta info ── */
.task-card-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 16px;
font-size: 0.78rem;
}
.task-card-meta-item {
display: flex;
flex-direction: column;
gap: 1px;
}
.task-card-meta-label {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bs-secondary-color);
}
.task-card-meta-value {
color: var(--bs-body-color);
}
a.task-card-session-link {
color: var(--bs-primary);
text-decoration: none;
font-family: var(--bs-font-monospace);
}
a.task-card-session-link:hover {
text-decoration: underline;
}
.task-card-meta-item--full {
grid-column: 1 / -1;
}
.task-card-select {
font-size: 0.78rem;
padding: 2px 6px;
border: 1px solid var(--bs-border-color);
border-radius: 4px;
background: var(--bs-body-bg);
color: var(--bs-body-color);
cursor: pointer;
width: 100%;
max-width: 220px;
}
/* ── Card footer ── */
.task-card-footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding-top: 6px;
border-top: 1px solid var(--bs-border-color);
margin-top: auto;
}
.task-card-toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.task-card-toggle-label {
font-size: 0.78rem;
font-weight: 500;
user-select: none;
}
/* ── Running card highlight ── */
.task-card--running {
border-color: rgba(34, 197, 94, 0.4);
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.15);
}
.task-running-elapsed {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: rgba(34, 197, 94, 0.07);
border: 1px solid rgba(34, 197, 94, 0.2);
border-radius: 6px;
font-size: 0.92rem;
font-weight: 600;
color: #15803d;
}
.task-running-elapsed i {
font-size: 0.95rem;
}
.task-running-since {
font-size: 0.76rem;
font-weight: 400;
opacity: 0.7;
margin-left: auto;
}
/* ── Kill button ── */
.task-kill-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid rgba(220, 53, 69, 0.35);
border-radius: 5px;
background: transparent;
color: var(--bs-danger, #dc3545);
cursor: pointer;
font-size: 0.75rem;
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
line-height: 1;
}
.task-kill-btn:hover:not(:disabled) {
background: rgba(220, 53, 69, 0.1);
border-color: var(--bs-danger, #dc3545);
}
.task-kill-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
+83
View File
@@ -0,0 +1,83 @@
/* ── History table ──────────────────────────────────────────────────────────── */
.task-history-table-wrap {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.task-history-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.task-history-table thead th {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bs-secondary-color);
padding: 6px 10px;
border-bottom: 1px solid var(--bs-border-color);
text-align: left;
white-space: nowrap;
background: var(--bs-tertiary-bg);
position: sticky;
top: 0;
z-index: 1;
}
.task-history-row {
cursor: pointer;
border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0,0,0,0.06));
transition: background 0.1s;
}
.task-history-row:hover,
.task-history-row--expanded {
background: var(--bs-tertiary-bg);
}
.task-history-row td {
padding: 8px 10px;
vertical-align: middle;
}
.task-history-title {
font-weight: 500;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-history-chevron {
font-size: 0.72rem;
opacity: 0.5;
}
.task-history-detail td {
padding: 6px 10px 14px;
background: var(--bs-tertiary-bg);
}
.task-history-error {
font-size: 0.82rem;
color: var(--bs-danger, #dc3545);
margin-bottom: 6px;
}
.task-history-response pre {
font-size: 0.78rem;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
max-height: 240px;
overflow-y: auto;
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 6px;
padding: 10px 12px;
line-height: 1.5;
}
+71
View File
@@ -0,0 +1,71 @@
/* ── Top navigation bar ────────────────────────────────────────────────────── */
app-topbar {
display: flex;
align-items: center;
height: var(--topbar-height);
background: var(--sidebar-bg);
border-bottom: 1px solid var(--sidebar-divider);
padding: 0 1rem;
flex-shrink: 0;
user-select: none;
}
.topbar-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--sidebar-brand-color);
letter-spacing: 0.02em;
}
.topbar-spacer {
flex: 1;
}
.topbar-theme-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--sidebar-text);
cursor: pointer;
transition: color 0.15s, background 0.15s;
padding: 0;
}
.topbar-theme-btn:hover {
color: var(--sidebar-text-active);
background: var(--sidebar-hover);
}
.topbar-theme-btn i {
font-size: 0.85rem;
}
.topbar-copilot-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 5px;
background: transparent;
color: #6366f1;
cursor: pointer;
transition: color 0.15s, background 0.15s;
padding: 0;
margin-right: 0.25rem;
}
.topbar-copilot-btn:hover {
background: var(--sidebar-hover);
}
.topbar-copilot-btn i {
font-size: 0.9rem;
}
+131
View File
@@ -0,0 +1,131 @@
/* ── Layout variables ──────────────────────────────────────────────────────── */
:root {
--topbar-height: 32px;
--sidebar-width: 220px;
--copilot-width: 420px;
/* Brand accent */
--accent: #6366f1;
--accent-hover: #4f46e5;
/* Sidebar — indigo, dark in both modes */
--sidebar-bg: #1a1740;
--sidebar-hover: rgba(255, 255, 255, 0.06);
--sidebar-active-bg: rgba(99, 102, 241, 0.22);
--sidebar-label-color: #4a4880;
--sidebar-text: #9b99d4;
--sidebar-text-active: #e0e7ff;
--sidebar-divider: rgba(255, 255, 255, 0.07);
--sidebar-brand-color: #e0e7ff;
/* Main area — light mode */
--toolbar-border: #e2e8f0;
--placeholder-color: #94a3b8;
--copilot-bg: #f8fafc;
/* Copilot messages — light mode */
--msg-assistant-bg: #e8ecf8;
--msg-assistant-text: #1e293b;
--msg-user-bg: #6366f1;
--msg-user-text: #ffffff;
/* Card surfaces */
--card-bg: #ffffff;
--card-border: #dde1f0;
--card-shadow: 0 1px 3px rgba(99, 102, 241, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05);
--card-radius: 3px;
/* Bootstrap overrides */
--bs-primary: #6366f1;
--bs-primary-rgb: 99, 102, 241;
--bs-link-color: #6366f1;
--bs-link-color-rgb: 99, 102, 241;
--bs-body-bg: #eef0f8;
--bs-body-bg-rgb: 238, 240, 248;
--bs-secondary-bg: #e4e7f2;
--bs-tertiary-bg: #f4f5fb;
--bs-border-color: #dde1f0;
}
[data-bs-theme="dark"] {
--sidebar-bg: #0e0c22;
--sidebar-hover: rgba(255, 255, 255, 0.05);
--sidebar-active-bg: rgba(99, 102, 241, 0.18);
--sidebar-label-color: #2a2860;
--sidebar-text: #6360b8;
--sidebar-text-active: #c7d2fe;
--sidebar-divider: rgba(255, 255, 255, 0.06);
--sidebar-brand-color: #c7d2fe;
--toolbar-border: #1e1b3a;
--placeholder-color: #4a4880;
--copilot-bg: #13112a;
--msg-assistant-bg: #1a1830;
--msg-assistant-text: #c7d2fe;
--msg-user-bg: #4f46e5;
--msg-user-text: #ffffff;
--bs-primary: #818cf8;
--bs-primary-rgb: 129, 140, 248;
--bs-link-color: #818cf8;
--bs-link-color-rgb: 129, 140, 248;
--bs-body-bg: #111128;
--bs-body-bg-rgb: 17, 17, 40;
--bs-secondary-bg: #1a1838;
--bs-tertiary-bg: #1e1c3e;
--bs-border-color: #2a2850;
/* Card surfaces — dark mode */
--card-bg: #1d1b3e;
--card-border: #2e2b58;
--card-shadow: 0 2px 10px rgba(0, 0, 0, 0.5), 0 1px 3px rgba(0, 0, 0, 0.4);
}
/* ── Reset ─────────────────────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', var(--bs-font-sans-serif);
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
/* ── Root layout ───────────────────────────────────────────────────────────── */
#app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.app-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Global card surface ────────────────────────────────────────────────────── */
.agent-card,
.llm-card,
.model-card,
.models-type-card,
.inbox-card,
.approval-card,
.clarification-card,
.approval-rule-card,
.task-card {
background: var(--card-bg) !important;
border-color: var(--card-border) !important;
border-radius: var(--card-radius) !important;
box-shadow: var(--card-shadow) !important;
}
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Skald</title>
<link rel="icon" href="/assets/icons/favicon.ico" sizes="any" />
<link rel="icon" href="/assets/icons/icon-192.png" type="image/png" />
<!-- Redirect mobile browsers to the mobile UI -->
<script>
(function () {
var isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || window.innerWidth < 768;
if (isMobile) window.location.replace('/mobile.html');
})();
</script>
<!-- Apply theme before render to avoid flash. localStorage wins over OS preference. -->
<script>
(function () {
const saved = localStorage.getItem('theme');
const dark = saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (!localStorage.getItem('theme')) {
document.documentElement.setAttribute('data-bs-theme', e.matches ? 'dark' : 'light');
}
});
})();
</script>
<!-- Font -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" />
<!-- Bootstrap 5 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" />
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="css/variables.css" />
<link rel="stylesheet" href="css/topbar.css" />
<link rel="stylesheet" href="css/sidebar.css" />
<link rel="stylesheet" href="css/copilot.css" />
<link rel="stylesheet" href="css/copilot-messages.css" />
<link rel="stylesheet" href="css/copilot-input.css" />
<link rel="stylesheet" href="css/dialogs.css" />
<link rel="stylesheet" href="css/page-shell.css" />
<link rel="stylesheet" href="css/models-hub.css" />
<link rel="stylesheet" href="css/tasks/base.css" />
<link rel="stylesheet" href="css/tasks/history.css" />
<link rel="stylesheet" href="css/models-llm.css" />
<link rel="stylesheet" href="css/models-image.css" />
<link rel="stylesheet" href="css/llm-providers.css" />
<link rel="stylesheet" href="css/agents.css" />
<link rel="stylesheet" href="css/approval-groups.css" />
<link rel="stylesheet" href="css/approval-rules.css" />
<link rel="stylesheet" href="css/inbox-cards.css" />
<link rel="stylesheet" href="css/config.css" />
<link rel="stylesheet" href="css/agent-inbox.css" />
<link rel="stylesheet" href="css/home.css" />
<link rel="stylesheet" href="css/llm-requests.css" />
<link rel="stylesheet" href="css/projects/base.css" />
<link rel="stylesheet" href="css/projects/board.css" />
<link rel="stylesheet" href="css/file-viewer.css" />
<script src="/vendor/cronstrue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script type="importmap">
{
"imports": {
"lit": "/vendor/lit-all.min.js",
"lit/directives/unsafe-html.js": "/vendor/lit-all.min.js",
"marked": "/vendor/marked.esm.js",
"dompurify": "/vendor/purify.es.mjs",
"cronstrue": "/vendor/cronstrue.mjs"
}
}
</script>
</head>
<body>
<div id="app">
<app-topbar></app-topbar>
<div class="app-body">
<app-sidebar></app-sidebar>
<div class="app-workspace" id="app-workspace"></div>
<agents-page></agents-page>
<llm-providers-page></llm-providers-page>
<models-hub-page></models-hub-page>
<tasks-page></tasks-page>
<approval-groups-page></approval-groups-page>
<approval-rules-page></approval-rules-page>
<config-page></config-page>
<home-page></home-page>
<agent-inbox-page></agent-inbox-page>
<llm-requests-page></llm-requests-page>
<session-detail-page style="display:none"></session-detail-page>
<tic-sessions-page style="display:none"></tic-sessions-page>
<projects-page style="display:none"></projects-page>
<file-viewer-page style="display:none"></file-viewer-page>
<app-copilot></app-copilot>
</div>
</div>
<script type="module" src="app.js"></script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
import { LitElement } from 'lit';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
marked.use({ breaks: true, gfm: true });
/**
* An http(s) link whose origin differs from the page's is "external" and
* should open in a new tab. Relative paths, hash anchors (e.g. the app's
* `#file_viewer?...` routing), and other schemes (mailto:, tel:) are left
* untouched so in-app navigation and native handlers keep working.
*/
function isExternalLink(href) {
if (!href) return false;
try {
const url = new URL(href, window.location.href);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
return url.origin !== window.location.origin;
} catch {
return false;
}
}
// Open external links in a new tab. `rel` is in DOMPurify's default allow-list;
// `target` is whitelisted via ADD_ATTR in renderMarkdown(). Runs once per module load.
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
if (data.tagName !== 'a' || !node.hasAttribute('href')) return;
if (isExternalLink(node.getAttribute('href'))) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
export function renderMarkdown(text) {
// `target` is not in DOMPurify's default attribute allow-list, so the
// external-link hook above needs it whitelisted here to survive sanitization.
return DOMPurify.sanitize(marked.parse(text ?? ''), { ADD_ATTR: ['target'] });
}
// Disable Shadow DOM so Bootstrap CSS flows through naturally.
export class LightElement extends LitElement {
createRenderRoot() { return this; }
}
+766
View File
@@ -0,0 +1,766 @@
import { LightElement } from './base.js';
// Slash commands handled entirely server-side: they reply with a `Done` and never
// echo back as a `user_message`, so they are the only commands rendered
// optimistically on send. Everything else — custom commands and unknown ones — is
// telnet-style: the bubble appears only when the backend persists it and re-emits a
// `user_message` (custom commands carry their typed form as the echoed content).
// (`/new` and `/clear` are intercepted earlier and never reach the echo.)
const SYSTEM_SLASH_COMMANDS = new Set([
'/help', '/models', '/model', '/context', '/cost',
'/compact', '/resettools', '/sethome',
]);
/**
* Base class for chat UI components (desktop copilot, mobile chat page).
*
* Contains all WebSocket logic, message state, and approval handling.
* Subclasses implement the render() method and override the DOM hooks:
* - _scrollToBottom()
* - _getInputContent() → returns current input value
* - _clearInput() → empties the input
* - _onMessagePushed(item) → called after each push (scroll, focus, etc.)
*/
export class ChatSession extends LightElement {
static properties = {
_messages: { state: true },
_waiting: { state: true },
_expanded: { state: true },
_providers: { state: true },
_selectedClient: { state: true },
_rejectingId: { state: true },
_rejectNote: { state: true },
_clarificationAnswer: { state: true },
// Voice recording state (shared by every chat surface).
_hasTranscribe: { state: true },
_recording: { state: true },
// Pending attachments for the message being composed (shown as chips above
// the textarea; uploaded to disk on selection, sent with the next message).
_attachments: { state: true },
};
// Live events whose arrival implies a turn is in flight (used to restore the
// STOP button when reconnecting mid-turn).
static _STREAMING_EVENTS = new Set([
'thinking', 'tool_start', 'agent_start', 'pending_write', 'approval_required',
]);
constructor() {
super();
this._messages = [];
this._waiting = false;
this._expanded = new Set();
this._ws = null;
this._providers = [];
this._selectedClient = null;
this._rejectingId = null;
this._rejectNote = '';
this._clarificationAnswer = '';
// Runtime-selected source. When null, falls back to the static `_wsSource`.
// Lets a single chat component switch between sessions (e.g. copilot tabs).
this._activeSource = null;
// Voice recording state. Shared so every surface (desktop copilot + mobile
// chat) can expose the same mic button. The desktop-only Ctrl+Space push-
// to-talk shortcut is wired in `app-copilot`; `_shortcutRecording` tracks
// whether a recording session was started by that shortcut.
this._hasTranscribe = false;
this._recording = false;
this._shortcutRecording = false;
this._mediaRecorder = null;
this._audioChunks = [];
// Each entry: { name, path, mimetype, filesize, uploading? }. While an upload
// is in flight the entry has `uploading: true` and no `path` yet.
this._attachments = [];
}
async connectedCallback() {
super.connectedCallback();
// Fire-and-forget: availability of a transcription provider determines
// whether the mic button is rendered at all.
this._checkTranscribe();
await Promise.all([this._loadProviders(), this._loadHistory()]);
this._connectWS();
}
// ── Source identity — override in subclass ────────────────────────────────────
// Static default source for this component. Subclasses override (e.g. 'mobile').
get _wsSource() { return 'web'; }
// Effective source: the runtime-selected one, or the static default.
get _source() { return this._activeSource ?? this._wsSource; }
/**
* Switch the live connection to a different source: tear down the current WS,
* swap source, reload that source's history, and reconnect. Used to move
* between sessions (e.g. General ↔ a project chat) without remounting.
*/
async _switchSource(source) {
if (this._ws) { this._ws.onclose = null; this._ws.close(); this._ws = null; }
this._activeSource = source;
this._messages = [];
this._waiting = false;
await this._loadHistory();
this._connectWS();
}
// ── Data loading ──────────────────────────────────────────────────────────────
async _loadProviders() {
try {
const res = await fetch('/api/llm/models/selector');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { models, default: def } = await res.json();
this._providers = models;
this._selectedClient = def;
} catch (e) {
console.error('Failed to load LLM models:', e);
}
}
async _loadHistory() {
try {
const res = await fetch(`/api/${this._source}/messages`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const items = await res.json();
if (items.length > 0) {
this._messages = items;
const expanded = new Set(this._expanded);
for (const m of items) {
if (m.kind === 'tool' && m.status === 'pending') expanded.add(m.tool_call_id);
}
this._expanded = expanded;
this._scrollToBottom();
// Set flag so ws.onopen sends a resume if there are pending tools
// (approval/clarification waiting) or interrupted tools (status=error+Interrupted).
this._hasPendingTools = items.some(
m => m.kind === 'tool' && (m.status === 'pending' || (m.status === 'error' && m.error === 'Interrupted.'))
);
}
} catch (e) {
console.warn('Could not load history:', e.message);
}
}
// ── WebSocket ─────────────────────────────────────────────────────────────────
_connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/api/ws?source=${this._source}`);
this._ws = ws;
ws.onopen = () => {
if (this._hasPendingTools) {
ws.send(JSON.stringify({ type: 'resume' }));
this._hasPendingTools = false;
}
};
ws.onmessage = (ev) => this._handleServerMsg(JSON.parse(ev.data));
ws.onclose = () => setTimeout(() => this._connectWS(), 2000);
}
async _startNewSession() {
if (this._ws) {
this._ws.onclose = null;
this._ws.close();
this._ws = null;
}
this._messages = [];
this._waiting = false;
try {
const res = await fetch(`/api/sessions?source=${this._source}`, { method: 'POST' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
this._pushError('Could not clear session: ' + e.message);
}
this._connectWS();
}
// ── Message handling ──────────────────────────────────────────────────────────
_handleServerMsg(msg) {
console.debug('[WS ←]', msg.type, msg);
// Receiving a live streaming event means a turn is active — restore the STOP
// button even if we reconnected mid-turn and missed the start. `done`/`error`
// reset it below.
if (!this._waiting && ChatSession._STREAMING_EVENTS.has(msg.type)) {
this._waiting = true;
}
switch (msg.type) {
// Sent on (re)connect: authoritative running state for this session.
case 'turn_running':
this._waiting = msg.running;
break;
case 'pending_write':
this._push({
kind: 'pending_write',
request_id: msg.request_id,
tool_call_id: msg.tool_call_id,
path: msg.path,
old_content: msg.old_content ?? '',
new_content: msg.new_content,
status: 'pending',
});
break;
case 'thinking':
this._push({ kind: 'thinking', message_id: msg.message_id, content: msg.content,
input_tokens: msg.input_tokens, output_tokens: msg.output_tokens });
break;
case 'done':
this._waiting = false;
this._push({ kind: 'assistant', content: msg.content,
input_tokens: msg.input_tokens, output_tokens: msg.output_tokens });
break;
case 'tool_start': {
// On resume, the server re-emits ToolStart for tools already in history.
// Update in place rather than pushing a duplicate card.
const existingIdx = this._messages.findIndex(
m => (m.kind === 'tool') && m.tool_call_id === msg.tool_call_id
);
if (existingIdx >= 0) {
this._updateTool(msg.tool_call_id, { status: 'running', result: null, error: null });
} else {
this._push({
kind: 'tool',
tool_call_id: msg.tool_call_id,
name: msg.name,
label_short: msg.label_short,
label_full: msg.label_full,
path: msg.path,
arguments: msg.arguments,
status: 'running',
result: null,
error: null,
});
}
break;
}
case 'tool_done':
this._updateTool(msg.tool_call_id, { status: 'done', result: msg.result, result_type: msg.result_type });
break;
case 'tool_error':
this._updateTool(msg.tool_call_id, { status: 'error', error: msg.error });
break;
case 'tool_cancelled':
// Stopped by the user via /stop — distinct from an error.
this._updateTool(msg.tool_call_id, { status: 'cancelled' });
break;
case 'tool_rejected':
// Denied by an approval policy or a human — distinct from an error.
this._updateTool(msg.tool_call_id, { status: 'rejected', error: msg.reason });
break;
case 'approval_required':
this._updateTool(msg.tool_call_id, { status: 'pending', request_id: msg.request_id });
this._expanded = new Set([...this._expanded, msg.tool_call_id]);
this.updateComplete.then(() => this._scrollToBottom());
break;
case 'approval_resolved': {
const { request_id, tool_call_id, approved } = msg;
this._updatePendingWrite(request_id, { status: approved ? 'approved' : 'rejected' });
if (tool_call_id != null) {
if (approved) {
this._updateTool(tool_call_id, { status: 'running', request_id: null });
} else {
this._updateTool(tool_call_id, { status: 'rejected', error: 'Rifiutato.' });
}
const expanded = new Set(this._expanded);
expanded.delete(tool_call_id);
this._expanded = expanded;
}
break;
}
case 'agent_question':
// Link the question form to the tool card by updating status + storing request_id.
this._updateTool(msg.tool_call_id, {
status: 'pending',
request_id: msg.request_id,
question: msg.question,
question_title: msg.title,
suggested_answers: msg.suggested_answers ?? [],
});
this._expanded = new Set([...this._expanded, msg.tool_call_id]);
this.updateComplete.then(() => this._scrollToBottom());
break;
case 'agent_start':
this._push({
kind: 'agent',
stack_id: msg.stack_id,
agent_id: msg.agent_id,
parent_agent_id: msg.parent_agent_id,
prompt_preview: msg.prompt_preview,
depth: msg.depth,
done: false,
});
break;
case 'agent_done': {
this._updateAgent(msg.stack_id, { done: true });
const agentMsg = this._messages.find(m => m.kind === 'agent' && m.stack_id === msg.stack_id);
if (agentMsg) {
this._push({
kind: 'agent_end',
agent_id: msg.agent_id,
parent_agent_id: msg.parent_agent_id,
result_preview: msg.result_preview,
depth: agentMsg.depth,
});
}
break;
}
case 'truncated':
this._pushError(`Risposta troncata dal limite di token (↓${msg.output_tokens?.toLocaleString() ?? '?'} tok).`);
break;
case 'error':
this._waiting = false;
this._pushError(msg.message);
break;
case 'file_changed':
window.dispatchEvent(new CustomEvent('file-changed', { detail: { path: msg.path } }));
break;
case 'open_file': {
// Agent-driven file open. Every kind — HTML included — routes through the
// file-viewer page, which renders HTML live in an origin-isolated iframe.
const p = msg.path ?? '';
if (p && typeof window.openFile === 'function') window.openFile(p);
break;
}
case 'model_fallback':
this._push({ kind: 'info', content: `⚡ Model fallback: ${msg.from}${msg.to}` });
break;
case 'user_message':
// Telnet-style echo: the backend emits this when the message is persisted
// to history, so we render the bubble here — for the sending client and
// every other client alike. No dedup needed: regular messages are never
// rendered optimistically (only slash commands are, and those are never
// echoed). `message_id` is the real chat_history row id.
this._push({
kind: 'user',
content: msg.content,
attachments: msg.attachments ?? [],
message_id: msg.message_id,
});
break;
case 'new_session':
this._messages = [];
this._waiting = false;
break;
case 'client_selected':
// Backend is the single source of truth for the pinned model. Updates
// arrive here regardless of which client (dropdown, /model command,
// another tab) originated the change — so the dropdown/select stays
// in sync. We set the field directly; Lit re-renders because
// `_selectedClient` is `state: true`.
this._selectedClient = msg.client;
break;
case 'llm_failed':
this._waiting = false;
this._pushError(`LLM unavailable. Tried: ${msg.tried.join(', ')}. ${msg.last_error}`);
break;
}
}
_push(item) {
console.debug('[push]', item.kind, item);
this._messages = [...this._messages, item];
this._onMessagePushed(item);
}
_pushError(text) {
this._push({ kind: 'error', content: text });
}
_updateAgent(stack_id, patch) {
const idx = this._messages.findIndex(m => m.kind === 'agent' && m.stack_id === stack_id);
if (idx < 0) return;
const updated = [...this._messages];
updated[idx] = { ...updated[idx], ...patch };
this._messages = updated;
}
_updateTool(tool_call_id, patch) {
const idx = this._messages.findIndex(
m => (m.kind === 'tool' || m.kind === 'pending_write') && m.tool_call_id === tool_call_id
);
if (idx < 0) return;
const updated = [...this._messages];
updated[idx] = { ...updated[idx], ...patch };
this._messages = updated;
}
_updatePendingWrite(request_id, patch) {
const idx = this._messages.findIndex(
m => m.kind === 'pending_write' && m.request_id === request_id
);
if (idx < 0) return;
// Once resolved (approved or rejected) remove the block entirely —
// the tool card already shows the outcome.
if (patch.status === 'approved' || patch.status === 'rejected') {
this._messages = this._messages.filter((_, i) => i !== idx);
return;
}
const updated = [...this._messages];
updated[idx] = { ...updated[idx], ...patch };
this._messages = updated;
}
// ── User input ────────────────────────────────────────────────────────────────
async _send() {
const content = this._getInputContent();
// Text is required; attachments are a complement, never sent on their own.
// Sending is allowed while a turn is in flight: the message is queued and
// injected into the running turn at its next round boundary.
if (!content) return;
// Don't send while an attachment is still streaming to disk, or its path
// would be missing from the message.
if (this._attachments.some(a => a.uploading)) return;
this._clearInput();
if (content === '/new' || content === '/clear') {
this._attachments = [];
await this._startNewSession();
return;
}
// Strip client-only fields; the server persists these as message metadata.
const attachments = this._attachments.map(({ name, path, mimetype, filesize }) =>
({ name, path, mimetype, filesize }));
this._attachments = [];
// System slash commands reply with a `Done` and never echo back as a
// `user_message`, so render them optimistically. Regular messages and custom
// slash commands use telnet-style echo: no local push — the bubble appears only
// when the backend persists the message and sends it back as a `user_message`
// event (for a custom command, carrying the typed form as its content), placing
// it correctly (e.g. after the current round's tools when injected mid-turn).
if (SYSTEM_SLASH_COMMANDS.has(content.split(/\s+/)[0])) {
this._push({ kind: 'user', content, attachments });
}
this._waiting = true;
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ content, attachments }));
} else {
this._pushError('Not connected — reconnecting, please retry.');
this._waiting = false;
}
}
// ── Attachments ────────────────────────────────────────────────────────────
/**
* Upload the given files to `data/uploads/{session}/` and add them as chips.
* Each file is streamed to disk server-side; while in flight its chip shows a
* spinner. Accepts a FileList or array of File.
*/
async _addFiles(files) {
const list = Array.from(files || []).filter(Boolean);
if (list.length === 0) return;
// Optimistic placeholders so the chips appear immediately.
const pending = list.map(f => ({ name: f.name, filesize: f.size, mimetype: f.type, uploading: true }));
this._attachments = [...this._attachments, ...pending];
const form = new FormData();
for (const f of list) form.append('files', f, f.name);
try {
const res = await fetch(`/api/${this._source}/uploads`, { method: 'POST', body: form });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const saved = await res.json(); // [{ name, path, mimetype, filesize }]
// Replace the placeholders with the saved entries (preserve other chips).
this._attachments = this._attachments.filter(a => !pending.includes(a)).concat(saved);
} catch (e) {
console.error('upload failed:', e);
// Drop the failed placeholders and surface the error.
this._attachments = this._attachments.filter(a => !pending.includes(a));
this._pushError('Upload failed: ' + e.message);
}
}
_removeAttachment(i) {
this._attachments = this._attachments.filter((_, idx) => idx !== i);
}
/** Handler for a paste event: uploads any files on the clipboard. */
_onPaste(e) {
const files = e.clipboardData?.files;
if (files && files.length) {
e.preventDefault();
this._addFiles(files);
}
}
/** Handler for a drop event on the composer: uploads the dropped files. */
_onDrop(e) {
const files = e.dataTransfer?.files;
if (files && files.length) {
e.preventDefault();
this._addFiles(files);
}
}
/**
* Pin a client (model) for the current source. Mirrors the state locally for
* instant feedback, then notifies the backend, which is the single source of
* truth — it broadcasts `client_selected` back to every client of the source
* (this tab included), so the dropdown/select re-syncs from authoritative
* state. Pass `'auto'` to clear the pin.
*/
_selectClient(client) {
this._selectedClient = client;
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'select_client', client }));
}
}
_cancel() {
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'cancel' }));
}
this._waiting = false;
}
// ── Approval — pending_write (WS) ─────────────────────────────────────────────
_approve(msg) {
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'approve_write', request_id: msg.request_id }));
}
this._updatePendingWrite(msg.request_id, { status: 'approved' });
}
_approveWriteBypass(msg, bypassSecs) {
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'approve_write', request_id: msg.request_id, bypass_secs: bypassSecs }));
}
this._updatePendingWrite(msg.request_id, { status: 'approved' });
}
_startReject(msg) {
this._rejectingId = msg.request_id;
this._rejectNote = '';
}
_confirmReject(msg) {
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'reject_write', request_id: msg.request_id, note: this._rejectNote }));
}
this._updatePendingWrite(msg.request_id, { status: 'rejected' });
this._rejectingId = null;
}
// ── Approval — tool (WS, live) ────────────────────────────────────────────────
_approveWsTool(msg) {
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'approve_tool', request_id: msg.request_id }));
}
this._updateTool(msg.tool_call_id, { status: 'running', request_id: null });
this._rejectingId = null;
}
_approveWsToolBypass(msg, bypassSecs) {
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'approve_tool', request_id: msg.request_id, bypass_secs: bypassSecs }));
}
this._updateTool(msg.tool_call_id, { status: 'running', request_id: null });
this._rejectingId = null;
}
_rejectWsTool(msg) {
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'reject_tool', request_id: msg.request_id, note: this._rejectNote }));
}
this._updateTool(msg.tool_call_id, { status: 'rejected', error: "Rifiutato dall'utente." });
this._rejectingId = null;
}
// ── Approval — tool (REST, from history) ─────────────────────────────────────
async _approveTool(msg) {
this._updateTool(msg.tool_call_id, { status: 'running' });
try {
const res = await fetch(`/api/tools/${msg.tool_call_id}/resolve`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'approve' }),
});
if (!res.ok) {
this._updateTool(msg.tool_call_id, { status: 'error', error: `Approval failed: ${await res.text()}` });
return;
}
const data = await res.json();
this._updateTool(msg.tool_call_id, { status: data.status, result: data.result, result_type: data.result_type });
if (this._ws?.readyState === WebSocket.OPEN) this._ws.send(JSON.stringify({ type: 'resume' }));
} catch (e) {
this._updateTool(msg.tool_call_id, { status: 'error', error: String(e) });
}
}
async _rejectTool(msg) {
this._updateTool(msg.tool_call_id, { status: 'running' });
try {
const res = await fetch(`/api/tools/${msg.tool_call_id}/resolve`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'reject', note: this._rejectNote }),
});
if (!res.ok) {
this._updateTool(msg.tool_call_id, { status: 'error', error: `Rejection failed: ${await res.text()}` });
return;
}
this._updateTool(msg.tool_call_id, { status: 'error', error: 'Rejected by user.' });
this._rejectingId = null;
if (this._ws?.readyState === WebSocket.OPEN) this._ws.send(JSON.stringify({ type: 'resume' }));
} catch (e) {
this._updateTool(msg.tool_call_id, { status: 'error', error: String(e) });
}
}
// ── Clarification ─────────────────────────────────────────────────────────────
_answerQuestion(msg) {
const answer = this._clarificationAnswer.trim();
if (!answer) return;
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ type: 'answer_question', request_id: msg.request_id, answer }));
}
this._updateTool(msg.tool_call_id, { status: 'running', request_id: null });
this._clarificationAnswer = '';
}
// ── DOM hooks (override in subclass) ─────────────────────────────────────────
/** Called after every _push(). Override to handle scrolling, focus, etc. */
_onMessagePushed(_item) {}
/** Returns the chat input textarea element. Subclasses must override. */
_inputEl() { return null; }
/** Returns the current value of the chat input. */
_getInputContent() { return this._inputEl()?.value.trim() ?? ''; }
/** Clears the chat input and resets its auto-resize height. */
_clearInput() {
const el = this._inputEl();
if (!el) return;
el.value = '';
el.style.height = 'auto';
}
/** Auto-resizes a textarea to fit its content (capped by CSS max-height). */
_autoResize(el) {
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}
/** Scrolls the message list to the bottom. */
_scrollToBottom() {}
// ── Voice recording (shared by every chat surface) ────────────────────────────
async _checkTranscribe() {
try {
const r = await fetch('/api/transcribe/has');
this._hasTranscribe = r.status === 204;
} catch {
this._hasTranscribe = false;
}
}
async _startRecording(fromShortcut = false) {
if (this._recording) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this._audioChunks = [];
this._shortcutRecording = fromShortcut;
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: '';
this._mediaRecorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream);
this._mediaRecorder.addEventListener('dataavailable', e => {
if (e.data.size > 0) this._audioChunks.push(e.data);
});
this._mediaRecorder.addEventListener('stop', () => {
stream.getTracks().forEach(t => t.stop());
this._submitAudio();
});
this._mediaRecorder.start();
this._recording = true;
} catch (err) {
console.error('mic error:', err);
}
}
_stopRecording() {
if (!this._recording || !this._mediaRecorder) return;
this._mediaRecorder.stop();
this._recording = false;
}
/** Toggle button handler: start or stop a recording (button-initiated). */
_toggleRecording() {
if (this._recording) {
this._shortcutRecording = false;
this._stopRecording();
} else {
this._startRecording(false);
}
}
async _submitAudio() {
if (this._audioChunks.length === 0) return;
const mimeType = this._mediaRecorder?.mimeType ?? 'audio/webm';
const blob = new Blob(this._audioChunks, { type: mimeType });
// Derive file extension from mimeType, e.g. "audio/webm;codecs=opus" → "webm"
const ext = mimeType.split('/')[1]?.split(';')[0] ?? 'webm';
const form = new FormData();
form.append('audio', blob, `recording.${ext}`);
try {
const resp = await fetch('/api/transcribe/audio', { method: 'POST', body: form });
if (!resp.ok) throw new Error(await resp.text());
const { text } = await resp.json();
if (text) {
const ta = this._inputEl();
if (ta) {
ta.value = (ta.value ? ta.value + ' ' : '') + text;
this._autoResize(ta);
ta.focus();
}
}
} catch (err) {
console.error('transcription error:', err);
}
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Singleton client for the `/api/file/watch` WebSocket.
*
* One persistent connection for the whole app. Multi-subscriber: if several
* components ask to watch the same path, only one subscribe message is sent
* over the wire; the OS watcher is shared. Unsubscribe ref-counts down and
* only sends `unsubscribe` when the last consumer for a path goes away.
*
* Auto-reconnects on close (2 s backoff) and re-issues every active
* subscription on reconnect, so consumers don't have to handle disconnects.
*
* Usage:
* import { fileWatcher } from '../lib/file-watcher.js';
* const unsub = await fileWatcher.watch('docs/index.md', (path) => { ... });
* unsub(); // stop watching
*/
class FileWatcher {
constructor() {
this._ws = null;
this._subscriptions = new Map(); // path -> Set<callback>
this._reconnectTimer = null;
this._connectPromise = null;
}
_ensureConnected() {
if (this._ws && this._ws.readyState === WebSocket.OPEN) return Promise.resolve();
if (this._connectPromise) return this._connectPromise;
this._connectPromise = new Promise((resolve, reject) => {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/api/file/watch`);
this._ws = ws;
ws.onopen = () => {
this._connectPromise = null;
// Re-subscribe everything (covers both first connect and reconnect).
for (const path of this._subscriptions.keys()) {
ws.send(JSON.stringify({ op: 'subscribe', path }));
}
resolve();
};
ws.onmessage = (e) => {
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
if (msg.type === 'changed') {
const cbs = this._subscriptions.get(msg.path);
if (cbs) cbs.forEach(cb => { try { cb(msg.path); } catch { /* swallow */ } });
}
// 'subscribed' / 'unsubscribed' / 'error' acks are informational;
// we don't currently surface them to consumers.
};
ws.onerror = () => {
if (this._connectPromise) {
this._connectPromise = null;
reject(new Error('file-watch WS error'));
}
};
ws.onclose = () => {
this._ws = null;
this._connectPromise = null;
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
this._ensureConnected().catch(() => { /* silent retry */ });
}, 2000);
};
});
return this._connectPromise;
}
async watch(path, cb) {
await this._ensureConnected();
let cbs = this._subscriptions.get(path);
const isNew = !cbs;
if (!cbs) {
cbs = new Set();
this._subscriptions.set(path, cbs);
}
cbs.add(cb);
if (isNew && this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ op: 'subscribe', path }));
}
return () => this.unwatch(path, cb);
}
unwatch(path, cb) {
const cbs = this._subscriptions.get(path);
if (!cbs) return;
cbs.delete(cb);
if (cbs.size === 0) {
this._subscriptions.delete(path);
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ op: 'unsubscribe', path }));
}
}
}
}
export const fileWatcher = new FileWatcher();
+397
View File
@@ -0,0 +1,397 @@
import { html, nothing } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { renderMarkdown } from './base.js';
/**
* InboxMixin — shared fetch, action, and render logic for the agent inbox.
* Used by AgentInboxPage (full page) and HomePage (embedded section).
*/
export const InboxMixin = (Base) => class extends Base {
static get properties() {
return {
...super.properties,
_inboxData: { state: true },
_inboxError: { state: true },
_inboxLoading: { state: true },
};
}
constructor() {
super();
this._inboxData = null;
this._inboxError = null;
this._inboxLoading = false;
this._expanded = new Set();
this._bypassOpen = new Set();
}
// ── Data ──────────────────────────────────────────────────────────────────
async _loadInbox() {
try {
const res = await fetch('/api/inbox');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this._inboxData = await res.json();
this._inboxError = null;
window.dispatchEvent(new CustomEvent('inbox-count', { detail: { count: this._inboxData.total } }));
} catch (e) {
this._inboxError = e.message;
}
}
// ── Actions ───────────────────────────────────────────────────────────────
async _resolveApproval(requestId, action, note = '', bypassSecs = null, bypassScope = null, toolCallId = null) {
try {
const body = { action, note };
if (bypassSecs !== null) {
body.bypass_secs = bypassSecs;
body.bypass_scope = bypassScope;
}
// Live items resolve by request_id (and support bypass); DB-persisted
// (post-restart) items carry request_id 0 → resolve by the durable,
// source-agnostic tool_call_id (bypass buttons are hidden for them).
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(body),
});
if (!res.ok) throw new Error(await res.text());
await this._loadInbox();
} catch (e) {
this._inboxError = e.message;
}
}
_rejectWithNote(requestId, toolCallId = null) {
const note = prompt('Rejection reason (optional):') ?? '';
this._resolveApproval(requestId, 'reject', note, null, null, toolCallId);
}
/** Approve + set a timed or session bypass scoped to the tool's category or MCP server. */
_approveWithBypass(item, bypassSecs) {
const scope = item.tool_category ? 'category'
: item.mcp_server ? 'mcp_server'
: 'all';
this._resolveApproval(item.request_id, 'approve', '', bypassSecs, scope);
}
/** Human-readable bypass scope label, e.g. "filesystem" or "Gmail". */
_bypassLabel(item) {
if (item.tool_category) return item.tool_category;
if (item.mcp_server) return item.mcp_server;
return 'session';
}
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._loadInbox();
} catch (e) {
this._inboxError = e.message;
}
}
/**
* Resolve a server-initiated MCP elicitation. On `accept` with a field, the
* input value is packed into `content` ({ [field]: value }); the secret is
* sent once and never echoed back into the UI. `decline`/`cancel` send no value.
*/
async _resolveElicitation(item, action, inputEl) {
let content = null;
if (action === 'accept' && item.field_name) {
content = { [item.field_name]: inputEl ? inputEl.value : '' };
}
try {
const res = await fetch(`/api/inbox/elicitations/${item.request_id}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, content }),
});
if (!res.ok) throw new Error(await res.text());
await this._loadInbox();
} catch (e) {
this._inboxError = e.message;
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
_toggleRaw(id) {
if (this._expanded.has(id)) this._expanded.delete(id);
else this._expanded.add(id);
this.requestUpdate();
}
_toggleBypassMenu(id) {
if (this._bypassOpen.has(id)) this._bypassOpen.delete(id);
else this._bypassOpen.add(id);
this.requestUpdate();
}
_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',
});
}
_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;
}
// ── Card renderers ────────────────────────────────────────────────────────
_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);
const rawJson = JSON.stringify(args, null, 2);
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' : ''}">${rawJson}</pre>
</div>
<div class="inbox-card-footer approval-footer">
<button class="btn btn-success"
@click=${() => this._resolveApproval(item.request_id, 'approve', '', null, null, item.tool_call_id)}>
<i class="bi bi-check-lg"></i> Approve
</button>
<button class="btn btn-outline-danger"
@click=${() => this._rejectWithNote(item.request_id, item.tool_call_id)}>
<i class="bi bi-x-lg"></i> Reject
</button>
${item.request_id ? html`
<div class="inbox-bypass-wrap">
<button class="btn btn-outline-secondary"
@click=${() => this._toggleBypassMenu(id)}>
<i class="bi bi-clock-history"></i> ×${this._bypassLabel(item)}
</button>
<div class="inbox-bypass-menu ${this._bypassOpen.has(id) ? 'open' : ''}">
<button @click=${() => { this._bypassOpen.delete(id); this._approveWithBypass(item, 15 * 60); }}>
15 min
</button>
<button @click=${() => { this._bypassOpen.delete(id); this._approveWithBypass(item, 60 * 60); }}>
1 ora
</button>
</div>
</div>
<button class="btn btn-outline-secondary"
@click=${() => this._approveWithBypass(item, 0)}
title="Approva e non chiedere più per questa sessione">
<i class="bi bi-shield-check"></i> Sessione
</button>
` : nothing}
</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 copilot-markdown">${unsafeHTML(renderMarkdown(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>
`;
}
_renderElicitationCard(item) {
const masked = item.sensitive;
const confirm = item.is_confirmation;
return html`
<div class="inbox-card elicitation-card">
<div class="inbox-card-header">
<span class="badge bg-secondary">
<i class="bi ${masked ? 'bi-shield-lock' : 'bi-question-circle'}"></i>
${confirm ? 'Conferma' : 'Input'}
</span>
<span class="inbox-card-origin" title="${item.server_name}">${item.server_name}</span>
<span class="inbox-card-time">${this._fmt(item.created_at)}</span>
</div>
<div class="inbox-card-body">
<div class="inbox-question">${item.message}</div>
${confirm ? nothing : html`
<div class="inbox-answer-area">
<input class="inbox-answer-input inbox-secret-input"
type="${masked ? 'password' : 'text'}"
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
placeholder="${masked ? '••••••••' : 'Value…'}"
@keydown=${(e) => {
if (e.key === 'Enter') {
e.preventDefault();
this._resolveElicitation(item, 'accept', e.target);
}
}}>
</div>
`}
</div>
<div class="inbox-card-footer approval-footer">
<button class="btn btn-success"
@click=${(e) => {
const inp = e.target.closest('.inbox-card')?.querySelector('.inbox-secret-input');
this._resolveElicitation(item, 'accept', inp);
}}>
<i class="bi bi-check-lg"></i> ${confirm ? 'Conferma' : 'Invia'}
</button>
<button class="btn btn-outline-danger"
@click=${() => this._resolveElicitation(item, 'decline', null)}>
<i class="bi bi-x-lg"></i> Rifiuta
</button>
</div>
</div>
`;
}
// ── Section renderer (used by both full page and home embed) ─────────────
_renderInboxSection() {
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`
${this._inboxError ? html`
<div class="alert alert-danger mx-3 mt-3">${this._inboxError}</div>
` : nothing}
${total === 0 ? html`
<div class="inbox-empty">
<i class="bi bi-inbox"></i>
<p>No pending requests</p>
</div>
` : html`
<div class="inbox-grid">
${approvals.length > 0 ? html`
<div class="inbox-section-header">
<h6>Approvals</h6>
<span class="badge bg-warning text-dark">${approvals.length}</span>
<span class="section-line"></span>
</div>
${approvals.map(item => this._renderApprovalCard(item))}
` : nothing}
${clarifications.length > 0 ? html`
<div class="inbox-section-header">
<h6>Questions</h6>
<span class="badge bg-info text-dark">${clarifications.length}</span>
<span class="section-line"></span>
</div>
${clarifications.map(item => this._renderClarificationCard(item))}
` : nothing}
${elicitations.length > 0 ? html`
<div class="inbox-section-header">
<h6>Secrets</h6>
<span class="badge bg-secondary">${elicitations.length}</span>
<span class="section-line"></span>
</div>
${elicitations.map(item => this._renderElicitationCard(item))}
` : nothing}
</div>
`}
`;
}
};
+21
View File
@@ -0,0 +1,21 @@
/**
* Global file-opener helper.
*
* `window.openFile(path)` is the single entry point for "show this file to the
* user in the file-viewer page". It navigates to
* `#file_viewer?path=<encodeURIComponent(path)>`, which the hash router in
* `sidebar.js` resolves to the `<file-viewer-page>` element. Back/forward
* browser navigation works naturally.
*
* Components that want to open a file should call `openFile(path)` rather than
* set the hash directly — this keeps the URL format in one place.
*
* Agent-driven opening (the future `show_file_to_user` tool) will set the same
* hash from the WS payload, so manual and agent-driven paths funnel together.
*/
export function openFile(path) {
if (!path) return;
location.hash = `file_viewer?path=${encodeURIComponent(path)}`;
}
window.openFile = openFile;
+20
View File
@@ -0,0 +1,20 @@
{
"name": "Skald",
"short_name": "Agent",
"start_url": "/mobile.html",
"display": "standalone",
"background_color": "#1c1c1e",
"theme_color": "#0d6efd",
"icons": [
{
"src": "/assets/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/assets/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Agent" />
<title>Skald</title>
<link rel="icon" href="/assets/icons/favicon.ico" sizes="any" />
<link rel="icon" href="/assets/icons/icon-192.png" type="image/png" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/assets/icons/apple-touch-icon.png" />
<script>
(function () {
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', e => {
document.documentElement.setAttribute('data-bs-theme', e.matches ? 'dark' : 'light');
});
})();
</script>
<!-- Bootstrap -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="css/variables.css" />
<link rel="stylesheet" href="css/copilot-messages.css" />
<link rel="stylesheet" href="css/inbox-cards.css" />
<link rel="stylesheet" href="css/file-viewer.css" />
<link rel="stylesheet" href="css/mobile.css" />
<script type="importmap">
{
"imports": {
"lit": "/vendor/lit-all.min.js",
"lit/directives/unsafe-html.js": "/vendor/lit-all.min.js",
"marked": "/vendor/marked.esm.js",
"dompurify": "/vendor/purify.es.mjs"
}
}
</script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; }
mobile-app { display: flex; flex-direction: column; height: 100%; }
</style>
</head>
<body>
<mobile-app></mobile-app>
<script type="module" src="components/mobile-app.js"></script>
</body>
</html>
+1157
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
// ESM wrapper for cronstrue (loaded as global via <script>)
const m = globalThis.cronstrue;
export const toString = m.toString;
export default m;
+120
View File
File diff suppressed because one or more lines are too long
+2503
View File
File diff suppressed because it is too large Load Diff
+1471
View File
File diff suppressed because it is too large Load Diff
+18936
View File
File diff suppressed because it is too large Load Diff
+474
View File
@@ -0,0 +1,474 @@
@charset "utf-8";
.toastui-editor-dark.toastui-editor-defaultUI {
border-color: #494c56;
color: #eee;
}
.toastui-editor-dark .toastui-editor-md-container,
.toastui-editor-dark .toastui-editor-ww-container {
background-color: #121212;
}
.toastui-editor-dark .toastui-editor-defaultUI-toolbar {
background-color: #232428;
border-bottom-color: #303238;
}
.toastui-editor-dark .toastui-editor-toolbar-icons {
background-position-y: -49px;
border-color: #232428;
}
.toastui-editor-dark .toastui-editor-toolbar-icons:not(:disabled):hover {
background-color: #36383f;
border-color: #36383f;
}
.toastui-editor-dark .toastui-editor-toolbar-divider {
background-color: #303238;
}
.toastui-editor-dark .toastui-editor-tooltip {
background-color: #535662;
}
.toastui-editor-dark .toastui-editor-tooltip .arrow {
background-color: #535662;
}
.toastui-editor-dark .toastui-editor-defaultUI-toolbar .scroll-sync::before {
color: #8f939f;
}
.toastui-editor-dark .toastui-editor-defaultUI-toolbar .scroll-sync.active::before {
color: #67ccff;
}
.toastui-editor-dark .toastui-editor-defaultUI-toolbar .switch {
background-color: #2b4455;
}
.toastui-editor-dark .toastui-editor-defaultUI-toolbar input:checked + .switch {
background-color: #2b4455;
}
.toastui-editor-dark .toastui-editor-defaultUI-toolbar .switch::before {
background-color: #8f939f;
}
.toastui-editor-dark .toastui-editor-defaultUI-toolbar input:checked + .switch::before {
background-color: #67ccff;
}
.toastui-editor-dark .toastui-editor-main .toastui-editor-md-splitter {
background-color: #303238;
}
.toastui-editor-dark .toastui-editor-mode-switch {
border-top-color: #393b42;
background-color: #121212;
}
.toastui-editor-dark .toastui-editor-mode-switch .tab-item {
border-color: #393b42;
background-color: #232428;
color: #757a86;
}
.toastui-editor-dark .toastui-editor-mode-switch .tab-item.active {
border-top-color: #121212;
background-color: #121212;
color: #eee;
}
.toastui-editor-dark .toastui-editor-popup,
.toastui-editor-dark .toastui-editor-context-menu {
background-color: #121212;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08);
border-color: #494c56;
}
.toastui-editor-dark .toastui-editor-popup-add-heading ul li:hover {
background-color: #36383f;
}
.toastui-editor-dark .toastui-editor-popup-body label {
color: #9a9da3;
}
.toastui-editor-dark .toastui-editor-popup-body input[type='text'] {
background-color: transparent;
color: #eee;
border-color: #303238;
}
.toastui-editor-dark .toastui-editor-popup-body input[type='text']:focus {
outline-color: #67ccff;
}
.toastui-editor-dark .toastui-editor-popup-body input[type='text'].disabled {
color: #969aa5;
border-color: #303238;
background-color: rgba(48, 50, 56, 0.4);
}
.toastui-editor-dark .toastui-editor-popup-add-image .toastui-editor-tabs .tab-item {
border-bottom-color: #292e37;
color: #eee;
}
.toastui-editor-dark .toastui-editor-popup-add-image .toastui-editor-tabs .tab-item:hover {
border-bottom-color: #3c424d;
}
.toastui-editor-dark .toastui-editor-popup-add-image .toastui-editor-tabs .tab-item.active {
color: #67ccff;
border-bottom-color: #67ccff;
}
.toastui-editor-dark .toastui-editor-popup-body .toastui-editor-file-name {
border-color: #303238;
color: #eee;
}
.toastui-editor-dark .toastui-editor-popup-body .toastui-editor-file-select-button {
border-color: #303238;
background-color: #232428;
color: #eee;
}
.toastui-editor-dark .toastui-editor-popup-body .toastui-editor-file-select-button:hover {
border-color: #494c56;
}
.toastui-editor-dark.toastui-editor-defaultUI .toastui-editor-close-button {
color: #eee;
border-color: #303238;
background-color: #232428;
}
.toastui-editor-dark.toastui-editor-defaultUI .toastui-editor-close-button:hover {
border-color: #494c56;
}
.toastui-editor-dark.toastui-editor-defaultUI .toastui-editor-ok-button {
color: #121212;
background-color: #67ccff;
}
.toastui-editor-dark.toastui-editor-defaultUI .toastui-editor-ok-button:hover {
color: #121212;
background-color: #32baff;
}
.toastui-editor-dark .toastui-editor-popup-add-table .toastui-editor-table-cell {
border-color: #303238;
background-color: #121212;
}
.toastui-editor-dark .toastui-editor-popup-add-table .toastui-editor-table-cell.header {
border-color: #303238;
background-color: #232428;
}
.toastui-editor-dark .toastui-editor-popup-add-table .toastui-editor-table-selection-layer {
border-color: rgba(103, 204, 255, 0.4);
background-color: rgba(103, 204, 255, 0.1);
}
.toastui-editor-dark .toastui-editor-popup-add-table .toastui-editor-table-description {
color: #eee
}
.toastui-editor-dark .toastui-editor-md-tab-container {
background-color: #232428;
border-bottom-color: #303238;
}
.toastui-editor-dark .toastui-editor-md-tab-container .tab-item {
border-color: #393b42;
background-color: #2d2f34;
color: #757a86;
}
.toastui-editor-dark .toastui-editor-md-tab-container .tab-item.active {
border-bottom-color: #121212;
background-color: #121212;
color: #eee;
}
.toastui-editor-dark .toastui-editor-context-menu .menu-group {
border-bottom-color: #303238;
color: #eee;
}
.toastui-editor-dark .toastui-editor-context-menu .menu-item span::before {
background-position-y: -126px;
}
.toastui-editor-dark .toastui-editor-context-menu li:not(.disabled):hover {
background-color: #36383f;
}
.toastui-editor-dark .toastui-editor-context-menu li.disabled {
color: #969aa5;
}
.toastui-editor-dark .toastui-editor-dropdown-toolbar {
border-color: #494c56;
background-color: #232428;
}
.toastui-editor-dark .ProseMirror,
.toastui-editor-dark .toastui-editor-contents p,
.toastui-editor-dark .toastui-editor-contents h1,
.toastui-editor-dark .toastui-editor-contents h2,
.toastui-editor-dark .toastui-editor-contents h3,
.toastui-editor-dark .toastui-editor-contents h4,
.toastui-editor-dark .toastui-editor-contents h5,
.toastui-editor-dark .toastui-editor-contents h6 {
color: #fff;
}
.toastui-editor-dark .toastui-editor-contents h1,
.toastui-editor-dark .toastui-editor-contents h2 {
border-color: #fff;
}
.toastui-editor-dark .toastui-editor-contents del {
color: #777980;
}
.toastui-editor-dark .toastui-editor-contents blockquote {
border-color: #303135;
}
.toastui-editor-dark .toastui-editor-contents blockquote p,
.toastui-editor-dark .toastui-editor-contents blockquote ul,
.toastui-editor-dark .toastui-editor-contents blockquote ol {
color: #777980;
}
.toastui-editor-dark .toastui-editor-contents pre {
background-color: #232428;
}
.toastui-editor-dark .toastui-editor-contents pre code {
background-color: transparent;
color: #fff;
}
.toastui-editor-dark .toastui-editor-contents code {
color: #c1798b;
background-color: #35262a;
}
.toastui-editor-dark .toastui-editor-contents div {
color: #fff;
}
.toastui-editor-dark .toastui-editor-ww-code-block-language {
border-color: #303238;
background-color: #121212;
}
.toastui-editor-dark .toastui-editor-ww-code-block-language input {
color: #fff;
}
.toastui-editor-dark .toastui-editor-contents .toastui-editor-ww-code-block:after {
background-color: #232428;
border: 1px solid #393b42;
color: #eee;
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMzAgMzAiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDMwIDMwOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6I2ZmZjt9Cjwvc3R5bGU+CjxnPgoJPGc+CgkJPGc+CgkJCTxnPgoJCQkJPGc+CgkJCQkJPHBhdGggY2xhc3M9InN0MCIgZD0iTTE1LjUsMTIuNWwyLDJMMTIsMjBoLTJ2LTJMMTUuNSwxMi41eiBNMTgsMTBsMiwybC0xLjUsMS41bC0yLTJMMTgsMTB6Ii8+CgkJCQk8L2c+CgkJCTwvZz4KCQk8L2c+Cgk8L2c+CjwvZz4KPC9zdmc+Cg==');
}
.toastui-editor-dark .toastui-editor-contents .toastui-editor-custom-block-editor {
background: #392d31;
color: #fff;
border-color: #327491;
}
.toastui-editor-dark .toastui-editor-custom-block.ProseMirror-selectednode .toastui-editor-custom-block-view {
color: #fff;
border-color: #327491;
}
.toastui-editor-dark .toastui-editor-custom-block-view button {
background-color: #232428;
border-color: #393b42;
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMzAgMzAiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDMwIDMwOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6I2ZmZjt9Cjwvc3R5bGU+CjxnPgoJPGc+CgkJPGc+CgkJCTxnPgoJCQkJPGc+CgkJCQkJPHBhdGggY2xhc3M9InN0MCIgZD0iTTE1LjUsMTIuNWwyLDJMMTIsMjBoLTJ2LTJMMTUuNSwxMi41eiBNMTgsMTBsMiwybC0xLjUsMS41bC0yLTJMMTgsMTB6Ii8+CgkJCQk8L2c+CgkJCTwvZz4KCQk8L2c+Cgk8L2c+CjwvZz4KPC9zdmc+Cg==');
}
.toastui-editor-dark .toastui-editor-custom-block-view button:hover {
background-color: #232428;
border-color: #595c68;
}
.toastui-editor-dark .toastui-editor-custom-block-view .info {
color: #65acca;
}
.toastui-editor-dark .toastui-editor-contents table {
border-color: #303238;
}
.toastui-editor-dark .toastui-editor-contents table th,
.toastui-editor-dark .toastui-editor-contents table td {
border-color: #303238;
}
.toastui-editor-dark .toastui-editor-contents table th {
background-color: #3a3c42;
}
.toastui-editor-dark .toastui-editor-contents table td,
.toastui-editor-dark .toastui-editor-contents table td p {
color: #fff;
}
.toastui-editor-dark .toastui-editor-contents td.toastui-editor-cell-selected {
background-color: rgba(103, 204, 255, 0.5);
}
.toastui-editor-dark .toastui-editor-contents th.toastui-editor-cell-selected {
background-color: rgba(103, 204, 255, 0.3);
}
.toastui-editor-dark table.ProseMirror-selectednode {
outline-color: #67ccff;
}
.toastui-editor-dark .html-block.ProseMirror-selectednode {
outline-color: #67ccff;
}
.toastui-editor-dark .toastui-editor-contents ul,
.toastui-editor-dark .toastui-editor-contents menu,
.toastui-editor-dark .toastui-editor-contents ol,
.toastui-editor-dark .toastui-editor-contents dir {
color: #55575f;
}
.toastui-editor-dark .toastui-editor-contents ul > li::before {
background-color: #55575f;
}
.toastui-editor-dark .toastui-editor-contents hr {
border-color: #55575f;
}
.toastui-editor-dark .toastui-editor-contents a {
color: #4b96e6;
}
.toastui-editor-dark .toastui-editor-contents a:hover {
color: #1f70de;
}
.toastui-editor-dark .toastui-editor-contents .image-link:hover::before {
border-color: #393b42;
background-color: #232428;
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgdmlld0JveD0iMCAwIDIwIDIwIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj4KICAgICAgICA8ZyBzdHJva2U9IiNFRUUiIHN0cm9rZS13aWR0aD0iMS41Ij4KICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICA8Zz4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNy42NjUgMTUuMDdsLTEuODE5LS4wMDJjLTEuNDg2IDAtMi42OTItMS4yMjgtMi42OTItMi43NDR2LS4xOTJjMC0xLjUxNSAxLjIwNi0yLjc0NCAyLjY5Mi0yLjc0NGgzLjg0NmMxLjQ4NyAwIDIuNjkyIDEuMjI5IDIuNjkyIDIuNzQ0di4xOTIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xMDQ1IC0xNzQzKSB0cmFuc2xhdGUoMTA0MCAxNzM4KSB0cmFuc2xhdGUoNSA1KSBzY2FsZSgxIC0xKSByb3RhdGUoNDUgMzcuMjkzIDApIi8+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTEyLjMyNiA0LjkzNGwxLjgyMi4wMDJjMS40ODcgMCAyLjY5MyAxLjIyOCAyLjY5MyAyLjc0NHYuMTkyYzAgMS41MTUtMS4yMDYgMi43NDQtMi42OTMgMi43NDRoLTMuODQ1Yy0xLjQ4NyAwLTIuNjkyLTEuMjI5LTIuNjkyLTIuNzQ0VjcuNjgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xMDQ1IC0xNzQzKSB0cmFuc2xhdGUoMTA0MCAxNzM4KSB0cmFuc2xhdGUoNSA1KSBzY2FsZSgxIC0xKSByb3RhdGUoNDUgMzAuOTk2IDApIi8+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPgo=');
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08);
}
.toastui-editor-dark .toastui-editor-contents .task-list-item::before {
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgdmlld0JveD0iMCAwIDE4IDE4Ij4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgc3Ryb2tlPSIjNTU1NzVGIj4KICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTAzMCAtMzE2KSB0cmFuc2xhdGUoNzg4IDE5MikgdHJhbnNsYXRlKDI0MiAxMjQpIj4KICAgICAgICAgICAgICAgICAgICA8cmVjdCB3aWR0aD0iMTciIGhlaWdodD0iMTciIHg9Ii41IiB5PSIuNSIgcng9IjIiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+Cg==');
background-color: transparent;
}
.toastui-editor-dark .toastui-editor-contents .task-list-item.checked::before {
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgdmlld0JveD0iMCAwIDE4IDE4Ij4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgZmlsbD0iIzRCOTZFNiI+CiAgICAgICAgICAgIDxnPgogICAgICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE2IDBjMS4xMDUgMCAyIC44OTUgMiAydjE0YzAgMS4xMDUtLjg5NSAyLTIgMkgyYy0xLjEwNSAwLTItLjg5NS0yLTJWMkMwIC44OTUuODk1IDAgMiAwaDE0em0tMS43OTMgNS4yOTNjLS4zOS0uMzktMS4wMjQtLjM5LTEuNDE0IDBMNy41IDEwLjU4NSA1LjIwNyA4LjI5M2wtLjA5NC0uMDgzYy0uMzkyLS4zMDUtLjk2LS4yNzgtMS4zMi4wODMtLjM5LjM5LS4zOSAxLjAyNCAwIDEuNDE0bDMgMyAuMDk0LjA4M2MuMzkyLjMwNS45Ni4yNzggMS4zMi0uMDgzbDYtNiAuMDgzLS4wOTRjLjMwNS0uMzkyLjI3OC0uOTYtLjA4My0xLjMyeiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEwNTAgLTI5NikgdHJhbnNsYXRlKDc4OCAxOTIpIHRyYW5zbGF0ZSgyNjIgMTA0KSIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8L2c+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4K');
}
.toastui-editor-dark .toastui-editor-md-delimiter,
.toastui-editor-dark .toastui-editor-md-code.toastui-editor-md-delimiter,
.toastui-editor-dark .toastui-editor-md-thematic-break,
.toastui-editor-dark .toastui-editor-md-link,
.toastui-editor-dark .toastui-editor-md-table,
.toastui-editor-dark .toastui-editor-md-block-quote {
color: #55575f;
}
.toastui-editor-dark .toastui-editor-md-meta,
.toastui-editor-dark .toastui-editor-md-html {
color: #55575f;
}
.toastui-editor-dark .toastui-editor-md-link.toastui-editor-md-link-url.toastui-editor-md-marked-text {
color: #777980;
}
.toastui-editor-dark .toastui-editor-md-block-quote .toastui-editor-md-marked-text,
.toastui-editor-dark .toastui-editor-md-list-item .toastui-editor-md-meta {
color: #b3b5bc;
}
.toastui-editor-dark .toastui-editor-md-link.toastui-editor-md-link-desc.toastui-editor-md-marked-text,
.toastui-editor-dark .toastui-editor-md-list-item-style.toastui-editor-md-list-item-odd {
color: #4b96e6;
}
.toastui-editor-dark .toastui-editor-md-list-item-style.toastui-editor-md-list-item-even {
color: #ef6767;
}
.toastui-editor-dark .toastui-editor-md-table .toastui-editor-md-table-cell {
color: #fff;
}
.toastui-editor-dark .toastui-editor-md-code.toastui-editor-md-marked-text {
color: #c1798b;
}
.toastui-editor-dark .toastui-editor-md-code {
background-color: #35262a;
}
.toastui-editor-dark .toastui-editor-md-code-block-line-background {
background-color: #232428;
}
.toastui-editor-dark .toastui-editor-md-code-block .toastui-editor-md-meta {
color: #aaa;
}
.toastui-editor-dark .toastui-editor-md-custom-block {
color: #fff;
}
.toastui-editor-dark .toastui-editor-md-custom-block-line-background {
background-color: #392d31;
}
.toastui-editor-dark .toastui-editor-md-custom-block .toastui-editor-md-delimiter {
color: #327491;
}
.toastui-editor-dark .toastui-editor-md-custom-block .toastui-editor-md-meta {
color: #65acca;
}
.toastui-editor-dark .toastui-editor-contents .toastui-editor-md-preview-highlight::after {
background-color: rgba(255, 250, 193, 0.5);
}
.toastui-editor-dark .toastui-editor-contents th.toastui-editor-md-preview-highlight,
.toastui-editor-dark .toastui-editor-contents td.toastui-editor-md-preview-highlight {
background-color: rgba(255, 250, 193, 0.5);
}
.toastui-editor-dark .toastui-editor-contents th.toastui-editor-md-preview-highlight {
color: #fff;
}
.toastui-editor-dark .toastui-editor-contents th.toastui-editor-md-preview-highlight,
.toastui-editor-dark .toastui-editor-contents td.toastui-editor-md-preview-highlight {
background-color: rgba(255, 250, 193, 0.25);
}
.toastui-editor-dark .toastui-editor-contents .toastui-editor-md-preview-highlight::after {
background-color: rgba(255, 250, 193, 0.25);
}
+1597
View File
File diff suppressed because one or more lines are too long
+35880
View File
File diff suppressed because it is too large Load Diff
+412
View File
@@ -0,0 +1,412 @@
trix-editor {
border: 1px solid #bbb;
border-radius: 3px;
margin: 0;
padding: 0.4em 0.6em;
min-height: 5em;
outline: none; }
trix-toolbar * {
box-sizing: border-box; }
trix-toolbar .trix-button-row {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
overflow-x: auto; }
trix-toolbar .trix-button-group {
display: flex;
margin-bottom: 10px;
border: 1px solid #bbb;
border-top-color: #ccc;
border-bottom-color: #888;
border-radius: 3px; }
trix-toolbar .trix-button-group:not(:first-child) {
margin-left: 1.5vw; }
@media (max-width: 768px) {
trix-toolbar .trix-button-group:not(:first-child) {
margin-left: 0; } }
trix-toolbar .trix-button-group-spacer {
flex-grow: 1; }
@media (max-width: 768px) {
trix-toolbar .trix-button-group-spacer {
display: none; } }
trix-toolbar .trix-button {
position: relative;
float: left;
color: rgba(0, 0, 0, 0.6);
font-size: 0.75em;
font-weight: 600;
white-space: nowrap;
padding: 0 0.5em;
margin: 0;
outline: none;
border: none;
border-bottom: 1px solid #ddd;
border-radius: 0;
background: transparent; }
trix-toolbar .trix-button:not(:first-child) {
border-left: 1px solid #ccc; }
trix-toolbar .trix-button.trix-active {
background: #cbeefa;
color: black; }
trix-toolbar .trix-button:not(:disabled) {
cursor: pointer; }
trix-toolbar .trix-button:disabled {
color: rgba(0, 0, 0, 0.125); }
@media (max-width: 768px) {
trix-toolbar .trix-button {
letter-spacing: -0.01em;
padding: 0 0.3em; } }
trix-toolbar .trix-button--icon {
font-size: inherit;
width: 2.6em;
height: 1.6em;
max-width: calc(0.8em + 4vw);
text-indent: -9999px; }
@media (max-width: 768px) {
trix-toolbar .trix-button--icon {
height: 2em;
max-width: calc(0.8em + 3.5vw); } }
trix-toolbar .trix-button--icon::before {
display: inline-block;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0.6;
content: "";
background-position: center;
background-repeat: no-repeat;
background-size: contain; }
@media (max-width: 768px) {
trix-toolbar .trix-button--icon::before {
right: 6%;
left: 6%; } }
trix-toolbar .trix-button--icon.trix-active::before {
opacity: 1; }
trix-toolbar .trix-button--icon:disabled::before {
opacity: 0.125; }
trix-toolbar .trix-button--icon-attach::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M10.5%2018V7.5c0-2.25%203-2.25%203%200V18c0%204.125-6%204.125-6%200V7.5c0-6.375%209-6.375%209%200V18%22%20stroke%3D%22%23000%22%20stroke-width%3D%222%22%20stroke-miterlimit%3D%2210%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
top: 8%;
bottom: 4%; }
trix-toolbar .trix-button--icon-bold::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M6.522%2019.242a.5.5%200%200%201-.5-.5V5.35a.5.5%200%200%201%20.5-.5h5.783c1.347%200%202.46.345%203.24.982.783.64%201.216%201.562%201.216%202.683%200%201.13-.587%202.129-1.476%202.71a.35.35%200%200%200%20.049.613c1.259.56%202.101%201.742%202.101%203.22%200%201.282-.483%202.334-1.363%203.063-.876.726-2.132%201.12-3.66%201.12h-5.89ZM9.27%207.347v3.362h1.97c.766%200%201.347-.17%201.733-.464.38-.291.587-.716.587-1.27%200-.53-.183-.928-.513-1.198-.334-.273-.838-.43-1.505-.43H9.27Zm0%205.606v3.791h2.389c.832%200%201.448-.177%201.853-.497.399-.315.614-.786.614-1.423%200-.62-.22-1.077-.63-1.385-.418-.313-1.053-.486-1.905-.486H9.27Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-italic::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M9%205h6.5v2h-2.23l-2.31%2010H13v2H6v-2h2.461l2.306-10H9V5Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-link::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M18.948%205.258a4.337%204.337%200%200%200-6.108%200L11.217%206.87a.993.993%200%200%200%200%201.41c.392.39%201.027.39%201.418%200l1.623-1.613a2.323%202.323%200%200%201%203.271%200%202.29%202.29%200%200%201%200%203.251l-2.393%202.38a3.021%203.021%200%200%201-4.255%200l-.05-.049a1.007%201.007%200%200%200-1.418%200%20.993.993%200%200%200%200%201.41l.05.049a5.036%205.036%200%200%200%207.091%200l2.394-2.38a4.275%204.275%200%200%200%200-6.072Zm-13.683%2013.6a4.337%204.337%200%200%200%206.108%200l1.262-1.255a.993.993%200%200%200%200-1.41%201.007%201.007%200%200%200-1.418%200L9.954%2017.45a2.323%202.323%200%200%201-3.27%200%202.29%202.29%200%200%201%200-3.251l2.344-2.331a2.579%202.579%200%200%201%203.631%200c.392.39%201.027.39%201.419%200a.993.993%200%200%200%200-1.41%204.593%204.593%200%200%200-6.468%200l-2.345%202.33a4.275%204.275%200%200%200%200%206.072Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-strike::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M6%2014.986c.088%202.647%202.246%204.258%205.635%204.258%203.496%200%205.713-1.728%205.713-4.463%200-.275-.02-.536-.062-.781h-3.461c.398.293.573.654.573%201.123%200%201.035-1.074%201.787-2.646%201.787-1.563%200-2.773-.762-2.91-1.924H6ZM6.432%2010h3.763c-.632-.314-.914-.715-.914-1.273%200-1.045.977-1.739%202.432-1.739%201.475%200%202.52.723%202.617%201.914h2.764c-.05-2.548-2.11-4.238-5.39-4.238-3.145%200-5.392%201.719-5.392%204.316%200%20.363.04.703.12%201.02ZM4%2011a1%201%200%201%200%200%202h15a1%201%200%201%200%200-2H4Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-quote::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M4.581%208.471c.44-.5%201.056-.834%201.758-.995C8.074%207.17%209.201%207.822%2010%208.752c1.354%201.578%201.33%203.555.394%205.277-.941%201.731-2.788%203.163-4.988%203.56a.622.622%200%200%201-.653-.317c-.113-.205-.121-.49.16-.764.294-.286.567-.566.791-.835.222-.266.413-.54.524-.815.113-.28.156-.597.026-.908-.128-.303-.39-.524-.72-.69a3.02%203.02%200%200%201-1.674-2.7c0-.905.283-1.59.72-2.088Zm9.419%200c.44-.5%201.055-.834%201.758-.995%201.734-.306%202.862.346%203.66%201.276%201.355%201.578%201.33%203.555.395%205.277-.941%201.731-2.789%203.163-4.988%203.56a.622.622%200%200%201-.653-.317c-.113-.205-.122-.49.16-.764.294-.286.567-.566.791-.835.222-.266.412-.54.523-.815.114-.28.157-.597.026-.908-.127-.303-.39-.524-.72-.69a3.02%203.02%200%200%201-1.672-2.701c0-.905.283-1.59.72-2.088Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-heading-1::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.5%207.5v-3h-12v3H14v13h3v-13h4.5ZM9%2013.5h3.5v-3h-10v3H6v7h3v-7Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-code::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3.293%2011.293a1%201%200%200%200%200%201.414l4%204a1%201%200%201%200%201.414-1.414L5.414%2012l3.293-3.293a1%201%200%200%200-1.414-1.414l-4%204Zm13.414%205.414%204-4a1%201%200%200%200%200-1.414l-4-4a1%201%200%201%200-1.414%201.414L18.586%2012l-3.293%203.293a1%201%200%200%200%201.414%201.414Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-bullet-list::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%207.5a1.5%201.5%200%201%200%200-3%201.5%201.5%200%200%200%200%203ZM8%206a1%201%200%200%201%201-1h11a1%201%200%201%201%200%202H9a1%201%200%200%201-1-1Zm1%205a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm0%206a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm-2.5-5a1.5%201.5%200%201%201-3%200%201.5%201.5%200%200%201%203%200ZM5%2019.5a1.5%201.5%200%201%200%200-3%201.5%201.5%200%200%200%200%203Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-number-list::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3%204h2v4H4V5H3V4Zm5%202a1%201%200%200%201%201-1h11a1%201%200%201%201%200%202H9a1%201%200%200%201-1-1Zm1%205a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm0%206a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm-3.5-7H6v1l-1.5%202H6v1H3v-1l1.667-2H3v-1h2.5ZM3%2017v-1h3v4H3v-1h2v-.5H4v-1h1V17H3Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-undo::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3%2014a1%201%200%200%200%201%201h6a1%201%200%201%200%200-2H6.257c2.247-2.764%205.151-3.668%207.579-3.264%202.589.432%204.739%202.356%205.174%205.405a1%201%200%200%200%201.98-.283c-.564-3.95-3.415-6.526-6.825-7.095C11.084%207.25%207.63%208.377%205%2011.39V8a1%201%200%200%200-2%200v6Zm2-1Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-redo::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21%2014a1%201%200%200%201-1%201h-6a1%201%200%201%201%200-2h3.743c-2.247-2.764-5.151-3.668-7.579-3.264-2.589.432-4.739%202.356-5.174%205.405a1%201%200%200%201-1.98-.283c.564-3.95%203.415-6.526%206.826-7.095%203.08-.513%206.534.614%209.164%203.626V8a1%201%200%201%201%202%200v6Zm-2-1Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-decrease-nesting-level::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%206a1%201%200%200%201%201-1h12a1%201%200%201%201%200%202H6a1%201%200%200%201-1-1Zm4%205a1%201%200%201%200%200%202h9a1%201%200%201%200%200-2H9Zm-3%206a1%201%200%201%200%200%202h12a1%201%200%201%200%200-2H6Zm-3.707-5.707a1%201%200%200%200%200%201.414l2%202a1%201%200%201%200%201.414-1.414L4.414%2012l1.293-1.293a1%201%200%200%200-1.414-1.414l-2%202Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-increase-nesting-level::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%206a1%201%200%200%201%201-1h12a1%201%200%201%201%200%202H6a1%201%200%200%201-1-1Zm4%205a1%201%200%201%200%200%202h9a1%201%200%201%200%200-2H9Zm-3%206a1%201%200%201%200%200%202h12a1%201%200%201%200%200-2H6Zm-2.293-2.293%202-2a1%201%200%200%200%200-1.414l-2-2a1%201%200%201%200-1.414%201.414L3.586%2012l-1.293%201.293a1%201%200%201%200%201.414%201.414Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-dialogs {
position: relative; }
trix-toolbar .trix-dialog {
position: absolute;
top: 0;
left: 0;
right: 0;
font-size: 0.75em;
padding: 15px 10px;
background: #fff;
box-shadow: 0 0.3em 1em #ccc;
border-top: 2px solid #888;
border-radius: 5px;
z-index: 5; }
trix-toolbar .trix-input--dialog {
font-size: inherit;
font-weight: normal;
padding: 0.5em 0.8em;
margin: 0 10px 0 0;
border-radius: 3px;
border: 1px solid #bbb;
background-color: #fff;
box-shadow: none;
outline: none;
-webkit-appearance: none;
-moz-appearance: none; }
trix-toolbar .trix-input--dialog.validate:invalid {
box-shadow: #F00 0px 0px 1.5px 1px; }
trix-toolbar .trix-button--dialog {
font-size: inherit;
padding: 0.5em;
border-bottom: none; }
trix-toolbar .trix-dialog--link {
max-width: 600px; }
trix-toolbar .trix-dialog__link-fields {
display: flex;
align-items: baseline; }
trix-toolbar .trix-dialog__link-fields .trix-input {
flex: 1; }
trix-toolbar .trix-dialog__link-fields .trix-button-group {
flex: 0 0 content;
margin: 0; }
trix-editor [data-trix-mutable]:not(.attachment__caption-editor) {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none; }
trix-editor [data-trix-mutable]::-moz-selection,
trix-editor [data-trix-cursor-target]::-moz-selection, trix-editor [data-trix-mutable] ::-moz-selection {
background: none; }
trix-editor [data-trix-mutable]::selection,
trix-editor [data-trix-cursor-target]::selection, trix-editor [data-trix-mutable] ::selection {
background: none; }
trix-editor .attachment__caption-editor:focus[data-trix-mutable]::-moz-selection {
background: highlight; }
trix-editor .attachment__caption-editor:focus[data-trix-mutable]::selection {
background: highlight; }
trix-editor [data-trix-mutable].attachment.attachment--file {
box-shadow: 0 0 0 2px highlight;
border-color: transparent; }
trix-editor [data-trix-mutable].attachment img {
box-shadow: 0 0 0 2px highlight; }
trix-editor .attachment {
position: relative; }
trix-editor .attachment:hover {
cursor: default; }
trix-editor .attachment--preview .attachment__caption:hover {
cursor: text; }
trix-editor .attachment__progress {
position: absolute;
z-index: 1;
height: 20px;
top: calc(50% - 10px);
left: 5%;
width: 90%;
opacity: 0.9;
transition: opacity 200ms ease-in; }
trix-editor .attachment__progress[value="100"] {
opacity: 0; }
trix-editor .attachment__caption-editor {
display: inline-block;
width: 100%;
margin: 0;
padding: 0;
font-size: inherit;
font-family: inherit;
line-height: inherit;
color: inherit;
text-align: center;
vertical-align: top;
border: none;
outline: none;
-webkit-appearance: none;
-moz-appearance: none; }
trix-editor .attachment__toolbar {
position: absolute;
z-index: 1;
top: -0.9em;
left: 0;
width: 100%;
text-align: center; }
trix-editor .trix-button-group {
display: inline-flex; }
trix-editor .trix-button {
position: relative;
float: left;
color: #666;
white-space: nowrap;
font-size: 80%;
padding: 0 0.8em;
margin: 0;
outline: none;
border: none;
border-radius: 0;
background: transparent; }
trix-editor .trix-button:not(:first-child) {
border-left: 1px solid #ccc; }
trix-editor .trix-button.trix-active {
background: #cbeefa; }
trix-editor .trix-button:not(:disabled) {
cursor: pointer; }
trix-editor .trix-button--remove {
text-indent: -9999px;
display: inline-block;
padding: 0;
outline: none;
width: 1.8em;
height: 1.8em;
line-height: 1.8em;
border-radius: 50%;
background-color: #fff;
border: 2px solid highlight;
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25); }
trix-editor .trix-button--remove::before {
display: inline-block;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0.7;
content: "";
background-image: url("data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.41%2017.59%205%2012%2010.59%206.41%205%205%206.41%2010.59%2012%205%2017.59%206.41%2019%2012%2013.41%2017.59%2019%2019%2017.59%2013.41%2012z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 90%; }
trix-editor .trix-button--remove:hover {
border-color: #333; }
trix-editor .trix-button--remove:hover::before {
opacity: 1; }
trix-editor .attachment__metadata-container {
position: relative; }
trix-editor .attachment__metadata {
position: absolute;
left: 50%;
top: 2em;
transform: translate(-50%, 0);
max-width: 90%;
padding: 0.1em 0.6em;
font-size: 0.8em;
color: #fff;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 3px; }
trix-editor .attachment__metadata .attachment__name {
display: inline-block;
max-width: 100%;
vertical-align: bottom;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; }
trix-editor .attachment__metadata .attachment__size {
margin-left: 0.2em;
white-space: nowrap; }
.trix-content {
line-height: 1.5;
overflow-wrap: break-word;
word-break: break-word; }
.trix-content * {
box-sizing: border-box;
margin: 0;
padding: 0; }
.trix-content h1 {
font-size: 1.2em;
line-height: 1.2; }
.trix-content blockquote {
border: 0 solid #ccc;
border-left-width: 0.3em;
margin-left: 0.3em;
padding-left: 0.6em; }
.trix-content [dir=rtl] blockquote,
.trix-content blockquote[dir=rtl] {
border-width: 0;
border-right-width: 0.3em;
margin-right: 0.3em;
padding-right: 0.6em; }
.trix-content li {
margin-left: 1em; }
.trix-content [dir=rtl] li {
margin-right: 1em; }
.trix-content pre {
display: inline-block;
width: 100%;
vertical-align: top;
font-family: monospace;
font-size: 0.9em;
padding: 0.5em;
white-space: pre;
background-color: #eee;
overflow-x: auto; }
.trix-content img {
max-width: 100%;
height: auto; }
.trix-content .attachment {
display: inline-block;
position: relative;
max-width: 100%; }
.trix-content .attachment a {
color: inherit;
text-decoration: none; }
.trix-content .attachment a:hover, .trix-content .attachment a:visited:hover {
color: inherit; }
.trix-content .attachment__caption {
text-align: center; }
.trix-content .attachment__caption .attachment__name + .attachment__size::before {
content: ' \2022 '; }
.trix-content .attachment--preview {
width: 100%;
text-align: center; }
.trix-content .attachment--preview .attachment__caption {
color: #666;
font-size: 0.9em;
line-height: 1.2; }
.trix-content .attachment--file {
color: #333;
line-height: 1;
margin: 0 2px 2px 2px;
padding: 0.4em 1em;
border: 1px solid #bbb;
border-radius: 5px; }
.trix-content .attachment-gallery {
display: flex;
flex-wrap: wrap;
position: relative; }
.trix-content .attachment-gallery .attachment {
flex: 1 0 33%;
padding: 0 0.5em;
max-width: 33%; }
.trix-content .attachment-gallery.attachment-gallery--2 .attachment, .trix-content .attachment-gallery.attachment-gallery--4 .attachment {
flex-basis: 50%;
max-width: 50%; }
+12360
View File
File diff suppressed because it is too large Load Diff