First Version
This commit is contained in:
+54
@@ -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 |
@@ -0,0 +1,72 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { InboxMixin } from '../lib/inbox-mixin.js';
|
||||
|
||||
export class AgentInboxPage extends InboxMixin(LightElement) {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
...super.properties,
|
||||
_open: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._pollTimer = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'inbox';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) {
|
||||
this._loadInbox();
|
||||
this._startPolling();
|
||||
} else {
|
||||
this._stopPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopPolling();
|
||||
}
|
||||
|
||||
_startPolling() {
|
||||
this._stopPolling();
|
||||
this._pollTimer = setInterval(() => this._loadInbox(), 8000);
|
||||
}
|
||||
|
||||
_stopPolling() {
|
||||
if (this._pollTimer) {
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const approvals = this._inboxData?.approvals ?? [];
|
||||
const clarifications = this._inboxData?.clarifications ?? [];
|
||||
const elicitations = this._inboxData?.elicitations ?? [];
|
||||
const total = approvals.length + clarifications.length + elicitations.length;
|
||||
|
||||
return html`
|
||||
<div class="page-panel">
|
||||
<div class="page-panel-header">
|
||||
<h5 class="mb-0">
|
||||
Agent Inbox
|
||||
${total > 0 ? html`<span class="badge bg-danger ms-2">${total}</span>` : nothing}
|
||||
</h5>
|
||||
<button class="inbox-refresh-btn" title="Refresh" @click=${() => this._loadInbox()}>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
${this._renderInboxSection()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { html } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement, renderMarkdown } from '../lib/base.js';
|
||||
|
||||
const STRENGTH_COLORS = {
|
||||
very_high: '#ef4444',
|
||||
high: '#f97316',
|
||||
average: '#eab308',
|
||||
low: '#84cc16',
|
||||
very_low: '#22c55e',
|
||||
};
|
||||
|
||||
const STRENGTH_LABELS = {
|
||||
very_high: 'Very High',
|
||||
high: 'High',
|
||||
average: 'Average',
|
||||
low: 'Low',
|
||||
very_low: 'Very Low',
|
||||
};
|
||||
|
||||
export class AgentsPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_agents: { state: true },
|
||||
_detail: { state: true }, // null | { meta, prompt, models }
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._agents = [];
|
||||
this._detail = null;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'agents';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open && this._agents.length === 0) this._loadList();
|
||||
if (!this._open) this._detail = null;
|
||||
});
|
||||
}
|
||||
|
||||
async _loadList() {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/agents');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._agents = await res.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _openDetail(agent) {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/agents/${agent.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._detail = await res.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_back() {
|
||||
this._detail = null;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────
|
||||
|
||||
_strengthDot(strength, size = '0.62rem') {
|
||||
if (!strength) return html`<span style="opacity:0.3;font-size:${size}">—</span>`;
|
||||
return html`
|
||||
<span class="agent-strength-dot"
|
||||
style="background:${STRENGTH_COLORS[strength] ?? '#888'}"
|
||||
title=${STRENGTH_LABELS[strength] ?? strength}></span>
|
||||
`;
|
||||
}
|
||||
|
||||
_scopePill(scope) {
|
||||
return html`<span class="agent-scope-pill">${scope}</span>`;
|
||||
}
|
||||
|
||||
// ── List view ─────────────────────────────────────────────────────────────
|
||||
|
||||
_renderCard(agent) {
|
||||
return html`
|
||||
<div class="agent-card" @click=${() => this._openDetail(agent)}>
|
||||
<div class="agent-card-body">
|
||||
${agent.icon ? html`
|
||||
<img class="agent-card-icon" src="/api/agents/${agent.id}/icon" alt="${agent.name}" loading="lazy">
|
||||
` : ''}
|
||||
<div class="agent-card-content">
|
||||
<div class="agent-card-header">
|
||||
<span class="agent-card-name">${agent.name}</span>
|
||||
<span class="agent-card-id text-muted">${agent.id}</span>
|
||||
</div>
|
||||
<p class="agent-card-desc text-muted">${agent.friendly_description ?? agent.description}</p>
|
||||
<div class="agent-card-meta">
|
||||
${agent.strength ? html`
|
||||
<span class="agent-meta-item">
|
||||
${this._strengthDot(agent.strength)}
|
||||
<span>${STRENGTH_LABELS[agent.strength] ?? agent.strength}</span>
|
||||
</span>
|
||||
` : ''}
|
||||
${agent.scope ? html`${this._scopePill(agent.scope)}` : ''}
|
||||
${agent.client ? html`
|
||||
<span class="agent-meta-item text-muted" style="font-size:0.75rem">
|
||||
<i class="bi bi-pin-fill me-1" style="font-size:0.65rem"></i>${agent.client}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderSection(title, agents) {
|
||||
if (agents.length === 0) return '';
|
||||
return html`
|
||||
<section class="agent-group">
|
||||
<h3 class="agent-group-title">${title}</h3>
|
||||
<div class="agent-grid">
|
||||
${agents.map(a => this._renderCard(a))}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderList() {
|
||||
if (this._loading) return html`<div class="text-muted py-4 text-center">Loading…</div>`;
|
||||
if (this._error) return html`<div class="alert alert-danger py-2" style="font-size:0.85rem">${this._error}</div>`;
|
||||
if (this._agents.length === 0) return html`<p class="text-muted">No agents found.</p>`;
|
||||
// Group by role: chat entry-points, dispatchable task executors, and
|
||||
// runtime-internal system agents (e.g. tic).
|
||||
const chat = this._agents.filter(a => a.type === 'chat');
|
||||
const task = this._agents.filter(a => a.type === 'task');
|
||||
const system = this._agents.filter(a => a.type === 'system');
|
||||
return html`
|
||||
${this._renderSection('Chat', chat)}
|
||||
${this._renderSection('Task Executors', task)}
|
||||
${this._renderSection('System', system)}
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Detail view ───────────────────────────────────────────────────────────
|
||||
|
||||
_renderModelRow(m, i) {
|
||||
const isFirst = i === 0;
|
||||
return html`
|
||||
<tr class="${isFirst ? 'agent-model-row--first' : ''}">
|
||||
<td class="agent-model-rank text-muted">${i + 1}</td>
|
||||
<td>${this._strengthDot(m.strength)}</td>
|
||||
<td>
|
||||
<span class="fw-semibold">${m.name}</span>
|
||||
${m.is_default ? html`<span class="badge bg-primary ms-1" style="font-size:0.6rem">default</span>` : ''}
|
||||
</td>
|
||||
<td class="text-muted agent-model-id">${m.model_id}</td>
|
||||
<td>
|
||||
${(m.scope ?? []).map(s => this._scopePill(s))}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderDetail() {
|
||||
if (this._loading && !this._detail) return html`<div class="text-muted py-4 text-center">Loading…</div>`;
|
||||
if (!this._detail) return '';
|
||||
|
||||
const { meta, prompt, models } = this._detail;
|
||||
|
||||
return html`
|
||||
<div class="agent-detail">
|
||||
<!-- Header -->
|
||||
<div class="agent-detail-header">
|
||||
<button class="btn btn-sm btn-link px-0" @click=${() => this._back()}>
|
||||
<i class="bi bi-arrow-left me-1"></i>Agents
|
||||
</button>
|
||||
<div class="agent-detail-title-row">
|
||||
${meta.icon ? html`
|
||||
<img class="agent-detail-icon" src="/api/agents/${meta.id}/icon" alt="${meta.name}">
|
||||
` : ''}
|
||||
<div>
|
||||
<h2 class="agent-detail-title">${meta.name}</h2>
|
||||
<p class="text-muted mb-0" style="font-size:0.9rem">${meta.friendly_description ?? meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
|
||||
<div class="agent-detail-body">
|
||||
<!-- Meta -->
|
||||
<section class="agent-section">
|
||||
<h3 class="agent-section-title">Metadata</h3>
|
||||
<table class="agent-meta-table">
|
||||
<tbody>
|
||||
<tr><td class="agent-meta-key">ID</td><td><code>${meta.id}</code></td></tr>
|
||||
${meta.strength ? html`
|
||||
<tr><td class="agent-meta-key">Strength</td>
|
||||
<td class="d-flex align-items-center gap-2">
|
||||
${this._strengthDot(meta.strength)}
|
||||
${STRENGTH_LABELS[meta.strength] ?? meta.strength}
|
||||
</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
${meta.scope ? html`
|
||||
<tr><td class="agent-meta-key">Scope</td><td>${this._scopePill(meta.scope)}</td></tr>
|
||||
` : ''}
|
||||
${meta.client ? html`
|
||||
<tr><td class="agent-meta-key">Pinned model</td><td><code>${meta.client}</code></td></tr>
|
||||
` : ''}
|
||||
${meta.inject_memory?.length ? html`
|
||||
<tr><td class="agent-meta-key">Memory files</td>
|
||||
<td>${meta.inject_memory.map(f => html`<div style="font-size:0.8rem"><code>${f}</code></div>`)}</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Model resolution order -->
|
||||
<section class="agent-section">
|
||||
<h3 class="agent-section-title">Model resolution order</h3>
|
||||
<p class="text-muted mb-2" style="font-size:0.8rem">
|
||||
Models sorted by how well they match this agent's requirements.
|
||||
The system uses the first available model from the top.
|
||||
</p>
|
||||
${models.length === 0
|
||||
? html`<p class="text-muted" style="font-size:0.85rem">No models configured.</p>`
|
||||
: html`
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm agent-model-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Strength</th>
|
||||
<th>Name</th>
|
||||
<th>Model ID</th>
|
||||
<th>Scope</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${models.map((m, i) => this._renderModelRow(m, i))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- System prompt -->
|
||||
<section class="agent-section">
|
||||
<h3 class="agent-section-title">System prompt</h3>
|
||||
<div class="agent-prompt-body markdown-body">
|
||||
${unsafeHTML(renderMarkdown(prompt))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Root render ───────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="agents-page">
|
||||
${this._detail
|
||||
? this._renderDetail()
|
||||
: html`
|
||||
<div class="agents-page-header">
|
||||
<h2 class="llm-page-title">Agents</h2>
|
||||
</div>
|
||||
|
||||
<div class="agent-info-banner">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-1"><strong>Read-only view.</strong> Agents are defined by files in <code>agents/</code>
|
||||
— to add, remove, or modify an agent, edit the corresponding <code>AGENT.md</code> file in that
|
||||
directory.</p>
|
||||
<p class="mb-0">You can also ask <strong>Copilot</strong> (top bar) to create a new agent for you
|
||||
— just describe what it should do and it will set up all the files automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._renderList()}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
export class ApprovalGroupsPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_groups: { state: true },
|
||||
_rules: { state: true },
|
||||
_error: { state: true },
|
||||
_groupEditId: { state: true },
|
||||
_groupForm: { state: true },
|
||||
_groupSaving: { state: true },
|
||||
_duplicateOf: { state: true }, // group being duplicated, or null
|
||||
_dupForm: { state: true }, // { id, name }
|
||||
_dupSaving: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._groups = [];
|
||||
this._rules = [];
|
||||
this._error = null;
|
||||
this._groupEditId = null;
|
||||
this._groupForm = { id: '', name: '', description: '' };
|
||||
this._groupSaving = false;
|
||||
this._duplicateOf = null;
|
||||
this._dupForm = { id: '', name: '' };
|
||||
this._dupSaving = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', async (e) => {
|
||||
this._open = e.detail.page === 'approval';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (!this._open) return;
|
||||
await this._load();
|
||||
const match = window.location.hash.match(/^#approval\/(.+)$/);
|
||||
if (match) {
|
||||
const group = this._groups.find(g => g.id === decodeURIComponent(match[1]));
|
||||
if (group) { this._navigateTo(group); return; }
|
||||
}
|
||||
});
|
||||
window.addEventListener('approval-navigate', (e) => {
|
||||
if (e.detail.group !== null) return;
|
||||
// Returning from rules view — show groups again
|
||||
this._open = true;
|
||||
this.style.display = 'flex';
|
||||
this._load();
|
||||
});
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (!this._open) return;
|
||||
const match = window.location.hash.match(/^#approval\/(.+)$/);
|
||||
if (!match) return;
|
||||
const group = this._groups.find(g => g.id === decodeURIComponent(match[1]));
|
||||
if (group) this._navigateTo(group);
|
||||
});
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const [gRes, rRes] = await Promise.all([
|
||||
fetch('/api/tool-permission-groups'),
|
||||
fetch('/api/approval/rules'),
|
||||
]);
|
||||
if (!gRes.ok) throw new Error(`Groups: HTTP ${gRes.status}`);
|
||||
if (!rRes.ok) throw new Error(`Rules: HTTP ${rRes.status}`);
|
||||
const groups = await gRes.json();
|
||||
this._groups = groups.sort((a, b) => {
|
||||
if (a.id === 'default') return -1;
|
||||
if (b.id === 'default') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
this._rules = await rRes.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
_rulesForGroup(groupId) {
|
||||
return this._rules.filter(r => (r.group_id ?? 'default') === groupId);
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
|
||||
_navigateTo(group) {
|
||||
this._open = false;
|
||||
this.style.display = 'none';
|
||||
window.location.hash = `approval/${group.id}`;
|
||||
window.dispatchEvent(new CustomEvent('approval-navigate', { detail: { group } }));
|
||||
}
|
||||
|
||||
// ── Group management ──────────────────────────────────────────────────────────
|
||||
|
||||
_startNewGroup() {
|
||||
this._groupEditId = 'new';
|
||||
this._groupForm = { id: '', name: '', description: '' };
|
||||
this._duplicateOf = null;
|
||||
}
|
||||
|
||||
_startEditGroup(group) {
|
||||
this._groupEditId = group.id;
|
||||
this._groupForm = { id: group.id, name: group.name, description: group.description ?? '' };
|
||||
this._duplicateOf = null;
|
||||
}
|
||||
|
||||
_cancelGroupEdit() { this._groupEditId = null; }
|
||||
|
||||
_patchGroup(field, value) {
|
||||
this._groupForm = { ...this._groupForm, [field]: value };
|
||||
}
|
||||
|
||||
async _saveGroup() {
|
||||
const isNew = this._groupEditId === 'new';
|
||||
if (!this._groupForm.name.trim()) { this._error = 'Group name is required.'; return; }
|
||||
if (isNew && !this._groupForm.id.trim()) { this._error = 'Group ID is required.'; return; }
|
||||
this._groupSaving = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const body = isNew
|
||||
? { id: this._groupForm.id.trim(), name: this._groupForm.name.trim(), description: this._groupForm.description.trim() || null }
|
||||
: { name: this._groupForm.name.trim(), description: this._groupForm.description.trim() || null };
|
||||
const url = isNew ? '/api/tool-permission-groups' : `/api/tool-permission-groups/${this._groupEditId}`;
|
||||
const res = await fetch(url, {
|
||||
method: isNew ? 'POST' : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._groupEditId = null;
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._groupSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _deleteGroup(group) {
|
||||
const count = this._rulesForGroup(group.id).length;
|
||||
const msg = count > 0
|
||||
? `Delete group "${group.name}" and its ${count} rule${count === 1 ? '' : 's'}?`
|
||||
: `Delete group "${group.name}"?`;
|
||||
if (!confirm(msg)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/tool-permission-groups/${group.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Duplicate group ───────────────────────────────────────────────────────────
|
||||
|
||||
_startDuplicate(group) {
|
||||
this._duplicateOf = group;
|
||||
this._dupForm = {
|
||||
id: `${group.id}_copy`,
|
||||
name: `Copy of ${group.name}`,
|
||||
};
|
||||
this._groupEditId = null; // close any open create/rename form
|
||||
}
|
||||
|
||||
_cancelDuplicate() { this._duplicateOf = null; }
|
||||
|
||||
async _saveDuplicate() {
|
||||
if (!this._dupForm.name.trim()) { this._error = 'Name is required.'; return; }
|
||||
if (!this._dupForm.id.trim()) { this._error = 'ID is required.'; return; }
|
||||
this._dupSaving = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/tool-permission-groups/${this._duplicateOf.id}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: this._dupForm.id.trim(), name: this._dupForm.name.trim() }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._duplicateOf = null;
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._dupSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group form ────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderGroupForm() {
|
||||
const isNew = this._groupEditId === 'new';
|
||||
const f = this._groupForm;
|
||||
return html`
|
||||
<div class="apr-form">
|
||||
<div class="apr-form-header">
|
||||
<i class="bi bi-collection"></i>
|
||||
<span>${isNew ? 'New group' : 'Rename group'}</span>
|
||||
<button class="apr-form-close" @click=${() => this._cancelGroupEdit()}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="apr-form-body">
|
||||
<div class="row g-3">
|
||||
${isNew ? html`
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">ID <span class="text-danger">*</span></label>
|
||||
<input
|
||||
class="form-control form-control-sm font-monospace"
|
||||
placeholder="e.g. cron_strict"
|
||||
.value=${f.id}
|
||||
@input=${(e) => this._patchGroup('id', e.target.value)}
|
||||
/>
|
||||
<div class="form-text" style="font-size:0.75rem">Lowercase slug, no spaces. Cannot be changed later.</div>
|
||||
</div>
|
||||
` : nothing}
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Name <span class="text-danger">*</span></label>
|
||||
<input
|
||||
class="form-control form-control-sm"
|
||||
placeholder="e.g. Cron strict"
|
||||
.value=${f.name}
|
||||
@input=${(e) => this._patchGroup('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Description <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Short description…"
|
||||
.value=${f.description}
|
||||
@input=${(e) => this._patchGroup('description', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="apr-form-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelGroupEdit()}>Cancel</button>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._saveGroup()} ?disabled=${this._groupSaving}>
|
||||
${this._groupSaving
|
||||
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
|
||||
: html`<i class="bi bi-check-lg me-1"></i>Save`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Duplicate form ────────────────────────────────────────────────────────────
|
||||
|
||||
_renderDuplicateForm() {
|
||||
const src = this._duplicateOf;
|
||||
const f = this._dupForm;
|
||||
return html`
|
||||
<div class="apr-form">
|
||||
<div class="apr-form-header">
|
||||
<i class="bi bi-copy"></i>
|
||||
<span>Duplicate <strong>${src.name}</strong></span>
|
||||
<button class="apr-form-close" @click=${() => this._cancelDuplicate()}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="apr-form-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">New name <span class="text-danger">*</span></label>
|
||||
<input
|
||||
class="form-control form-control-sm"
|
||||
.value=${f.name}
|
||||
@input=${(e) => { this._dupForm = { ...this._dupForm, name: e.target.value }; }}
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">New ID <span class="text-danger">*</span></label>
|
||||
<input
|
||||
class="form-control form-control-sm font-monospace"
|
||||
.value=${f.id}
|
||||
@input=${(e) => { this._dupForm = { ...this._dupForm, id: e.target.value }; }}
|
||||
/>
|
||||
<div class="form-text" style="font-size:0.75rem">Lowercase slug, no spaces. Cannot be changed later.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="apr-form-body" style="padding:0;margin-top:0.5rem">
|
||||
<div class="alert alert-info py-2 mb-0" style="font-size:0.8rem">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
All <strong>${this._rulesForGroup(src.id).length}</strong> rule${this._rulesForGroup(src.id).length === 1 ? '' : 's'} from <em>${src.name}</em> will be copied.
|
||||
</div>
|
||||
</div>
|
||||
<div class="apr-form-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._cancelDuplicate()}>Cancel</button>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._saveDuplicate()} ?disabled=${this._dupSaving}>
|
||||
${this._dupSaving
|
||||
? html`<span class="spinner-border spinner-border-sm me-1"></span>Duplicating…`
|
||||
: html`<i class="bi bi-copy me-1"></i>Duplicate`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Group card ────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderGroupCard(group) {
|
||||
const count = this._rulesForGroup(group.id).length;
|
||||
const isDefault = group.id === 'default';
|
||||
return html`
|
||||
<div class="apr-card apr-group-card" @click=${() => this._navigateTo(group)}>
|
||||
<div class="apr-card-row1">
|
||||
${isDefault ? html`<span class="apr-group-default-badge">Default</span>` : nothing}
|
||||
<span class="apr-group-name">${group.name}</span>
|
||||
<span class="apr-priority-badge ms-auto" title="${count} rule${count === 1 ? '' : 's'}">
|
||||
<i class="bi bi-list-ul"></i>
|
||||
${count}
|
||||
</span>
|
||||
<div class="apr-card-actions" @click=${(e) => e.stopPropagation()}>
|
||||
<button class="apr-btn-icon" title="Duplicate"
|
||||
@click=${(e) => { e.stopPropagation(); this._startDuplicate(group); }}>
|
||||
<i class="bi bi-copy"></i>
|
||||
</button>
|
||||
<button class="apr-btn-icon apr-btn-edit" title="Rename"
|
||||
@click=${(e) => { e.stopPropagation(); this._startEditGroup(group); }}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button
|
||||
class="apr-btn-icon apr-btn-delete"
|
||||
title=${isDefault ? 'Cannot delete the default group' : 'Delete group'}
|
||||
?disabled=${isDefault}
|
||||
@click=${(e) => { e.stopPropagation(); if (!isDefault) this._deleteGroup(group); }}
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${group.description ? html`
|
||||
<div class="apr-card-row3">
|
||||
<span class="apr-tag"><i class="bi bi-text-left"></i>${group.description}</span>
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Groups view ───────────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="apr-page">
|
||||
<div class="apr-header">
|
||||
<h2 class="apr-title">
|
||||
<i class="bi bi-shield-check me-2"></i>Security
|
||||
</h2>
|
||||
<div class="apr-header-right">
|
||||
<span class="apr-header-count">${this._groups.length} group${this._groups.length === 1 ? '' : 's'}</span>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._startNewGroup()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>New group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-info-banner" style="margin: 14px 20px 0">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-1">
|
||||
<strong>Permission groups</strong> are named sets of approval rules.
|
||||
A session's active <strong>Agent Profile</strong> determines which group applies —
|
||||
that group's rules are evaluated first, with the <strong>Default</strong> group as fallback.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
Click a group to view and manage its rules.
|
||||
The <strong>Default</strong> group cannot be deleted, but its rules can be edited freely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mt-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${this._groupEditId !== null ? this._renderGroupForm() : nothing}
|
||||
${this._duplicateOf !== null ? this._renderDuplicateForm() : nothing}
|
||||
|
||||
<div class="apr-card-list">
|
||||
${this._groups.length === 0 ? html`
|
||||
<div class="apr-empty">
|
||||
<i class="bi bi-collection"></i>
|
||||
<p>No groups yet.</p>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._startNewGroup()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Create first group
|
||||
</button>
|
||||
</div>
|
||||
` : this._groups.map(g => this._renderGroupCard(g))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
export class ConfigPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_properties: { state: true },
|
||||
_values: { state: true }, // { [key]: string }
|
||||
_saving: { state: true }, // Set<key>
|
||||
_saved: { state: true }, // Set<key> (brief flash)
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._properties = [];
|
||||
this._values = {};
|
||||
this._saving = new Set();
|
||||
this._saved = new Set();
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'config';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._load();
|
||||
});
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/config');
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
this._properties = data.sets ?? [];
|
||||
const vals = {};
|
||||
for (const s of this._properties)
|
||||
for (const p of s.properties) vals[p.key] = p.value ?? '';
|
||||
this._values = vals;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
_setValue(key, val) {
|
||||
this._values = { ...this._values, [key]: val };
|
||||
}
|
||||
|
||||
async _save(prop) {
|
||||
const key = prop.key;
|
||||
const value = this._values[key] ?? '';
|
||||
|
||||
this._saving = new Set([...this._saving, key]);
|
||||
this.requestUpdate();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/config/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
this._saved = new Set([...this._saved, key]);
|
||||
setTimeout(() => {
|
||||
this._saved = new Set([...this._saved].filter(k => k !== key));
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
alert(`Error saving ${prop.name}: ${e.message}`);
|
||||
} finally {
|
||||
this._saving = new Set([...this._saving].filter(k => k !== key));
|
||||
}
|
||||
}
|
||||
|
||||
_renderInput(prop) {
|
||||
const val = this._values[prop.key] ?? '';
|
||||
|
||||
if (prop.property_type === 'bool') {
|
||||
const effective = val !== '' ? val : (prop.default_value ?? 'true');
|
||||
const checked = effective !== 'false';
|
||||
return html`
|
||||
<div class="form-check form-switch config-bool-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="cfg-${prop.key}"
|
||||
.checked=${checked}
|
||||
@change=${e => { this._setValue(prop.key, e.target.checked ? 'true' : 'false'); this._save(prop); }} />
|
||||
<label class="form-check-label" for="cfg-${prop.key}">
|
||||
${checked ? 'Enabled' : 'Disabled'}
|
||||
</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (prop.property_type === 'int') {
|
||||
return html`
|
||||
<input type="number" step="1" min="1"
|
||||
class="form-control form-control-sm config-input"
|
||||
.value=${val}
|
||||
placeholder=${prop.default_value ?? ''}
|
||||
@input=${e => this._setValue(prop.key, e.target.value)} />`;
|
||||
}
|
||||
|
||||
if (prop.property_type === 'security_group') {
|
||||
const groups = prop.options ?? [];
|
||||
return html`
|
||||
<select class="form-select form-select-sm config-input"
|
||||
.value=${val}
|
||||
@change=${e => this._setValue(prop.key, e.target.value)}>
|
||||
<option value="">— default —</option>
|
||||
${groups.map(g => html`
|
||||
<option value=${g.id} ?selected=${val === g.id}>${g.name}</option>`)}
|
||||
</select>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<input type="text"
|
||||
class="form-control form-control-sm config-input"
|
||||
.value=${val}
|
||||
placeholder=${prop.default_value ?? ''}
|
||||
@input=${e => this._setValue(prop.key, e.target.value)} />`;
|
||||
}
|
||||
|
||||
_renderSet(set) {
|
||||
return html`
|
||||
<div class="config-set">
|
||||
<div class="config-set-header">
|
||||
<div class="config-set-name">${set.name}</div>
|
||||
<div class="config-set-desc">${set.description}</div>
|
||||
</div>
|
||||
<div class="config-rows">
|
||||
${set.properties.map(p => this._renderRow(p))}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderRow(prop) {
|
||||
const saving = this._saving.has(prop.key);
|
||||
const saved = this._saved.has(prop.key);
|
||||
|
||||
return html`
|
||||
<div class="config-row">
|
||||
<div class="config-row-meta">
|
||||
<div class="config-row-name">${prop.name}</div>
|
||||
<div class="config-row-desc">${prop.description}</div>
|
||||
</div>
|
||||
<div class="config-row-control">
|
||||
${this._renderInput(prop)}
|
||||
${prop.property_type !== 'bool' ? html`
|
||||
<button class="btn btn-sm ${saved ? 'btn-success' : 'btn-primary'} config-save-btn"
|
||||
?disabled=${saving}
|
||||
@click=${() => this._save(prop)}>
|
||||
${saving
|
||||
? html`<span class="spinner-border spinner-border-sm"></span>`
|
||||
: saved ? 'Saved' : 'Save'}
|
||||
</button>` : nothing}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="config-page">
|
||||
<div class="config-page-header">
|
||||
<h2 class="llm-page-title">Config</h2>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger">${this._error}</div>` : nothing}
|
||||
|
||||
${this._properties.length === 0 && !this._error ? html`
|
||||
<p class="text-muted mt-2">Loading…</p>` : nothing}
|
||||
|
||||
<div class="config-sets">
|
||||
${this._properties.map(s => this._renderSet(s))}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { renderMarkdown } from '../lib/base.js';
|
||||
import { openFile } from '../lib/open-file.js';
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render a tool label (with backtick-wrapped arguments) as Lit nodes. Each
|
||||
* backtick segment becomes a `<code>`; the segment that exactly matches `path`
|
||||
* — the file a file-targeting tool acts on, supplied by the backend
|
||||
* `target_path` — instead becomes a link that opens it in the file viewer.
|
||||
* Lit auto-escapes text, so no manual HTML escaping is needed.
|
||||
*/
|
||||
function renderLabel(label, path) {
|
||||
const out = [];
|
||||
let rest = label || '';
|
||||
while (rest.length) {
|
||||
const open = rest.indexOf('`');
|
||||
if (open === -1) { out.push(rest); break; }
|
||||
if (open > 0) out.push(rest.slice(0, open));
|
||||
rest = rest.slice(open + 1);
|
||||
const close = rest.indexOf('`');
|
||||
if (close === -1) { out.push('`' + rest); break; }
|
||||
out.push(renderPath(rest.slice(0, close), path));
|
||||
rest = rest.slice(close + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A backtick segment: a clickable file link when it is the call's target path, else plain `<code>`. */
|
||||
function renderPath(seg, path) {
|
||||
if (!path || seg !== path) return html`<code>${seg}</code>`;
|
||||
const open = (e) => { e.stopPropagation(); openFile(seg); };
|
||||
return html`<span class="copilot-tool-path" role="button" tabindex="0"
|
||||
title="Open in viewer"
|
||||
@click=${open}
|
||||
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); } }}
|
||||
>${seg}</span>`;
|
||||
}
|
||||
|
||||
export function truncate(s, max = 400) {
|
||||
if (!s) return '';
|
||||
const str = typeof s === 'string' ? s : JSON.stringify(s, null, 2);
|
||||
return str.length > max ? str.slice(0, max) + '\n…' : str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-prints a structured (`result_type === 'json'`) tool result for display.
|
||||
* The backend stores `structuredContent` as a compact JSON string; re-indent it
|
||||
* for readability, falling back to the raw string if it isn't valid JSON.
|
||||
*/
|
||||
function prettyJson(s) {
|
||||
try { return JSON.stringify(JSON.parse(s), null, 2); }
|
||||
catch { return s; }
|
||||
}
|
||||
|
||||
// ── Diff ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function renderDiff(oldText, newText) {
|
||||
const oldLines = (oldText || '').split('\n');
|
||||
const newLines = (newText || '').split('\n');
|
||||
|
||||
const m = oldLines.length, n = newLines.length;
|
||||
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = 1; i <= m; i++)
|
||||
for (let j = 1; j <= n; j++)
|
||||
dp[i][j] = oldLines[i-1] === newLines[j-1]
|
||||
? dp[i-1][j-1] + 1
|
||||
: Math.max(dp[i-1][j], dp[i][j-1]);
|
||||
|
||||
const ops = [];
|
||||
let i = m, j = n;
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && oldLines[i-1] === newLines[j-1]) {
|
||||
ops.push({ type: 'eq', text: oldLines[i-1] }); i--; j--;
|
||||
} else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
|
||||
ops.push({ type: 'add', text: newLines[j-1] }); j--;
|
||||
} else {
|
||||
ops.push({ type: 'del', text: oldLines[i-1] }); i--;
|
||||
}
|
||||
}
|
||||
ops.reverse();
|
||||
|
||||
const result = [];
|
||||
let eqBuf = [];
|
||||
const flushEq = () => {
|
||||
if (eqBuf.length === 0) return;
|
||||
if (eqBuf.length <= 6) {
|
||||
result.push(html`<span class="diff-unchanged">${eqBuf.join('\n')}\n</span>`);
|
||||
} else {
|
||||
result.push(html`<span class="diff-unchanged">${eqBuf.slice(0, 3).join('\n')}\n</span>`);
|
||||
result.push(html`<span class="diff-ellipsis">⋯ ${eqBuf.length - 6} unchanged lines ⋯</span>`);
|
||||
result.push(html`<span class="diff-unchanged">\n${eqBuf.slice(-3).join('\n')}\n</span>`);
|
||||
}
|
||||
eqBuf = [];
|
||||
};
|
||||
for (const op of ops) {
|
||||
if (op.type === 'eq') {
|
||||
eqBuf.push(op.text);
|
||||
} else {
|
||||
flushEq();
|
||||
const cls = op.type === 'add' ? 'diff-added' : 'diff-removed';
|
||||
result.push(html`<span class="${cls}">${op.text}\n</span>`);
|
||||
}
|
||||
}
|
||||
flushEq();
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Message renderers ────────────────────────────────────────────────────────
|
||||
|
||||
export function renderPendingWrite(host, msg) {
|
||||
console.debug('[renderPendingWrite]', msg.path, 'old_len=' + (msg.old_content?.length ?? 0), 'new_len=' + (msg.new_content?.length ?? 0));
|
||||
const isRejecting = host._rejectingId === msg.request_id;
|
||||
return html`
|
||||
<div class="copilot-approval copilot-approval--${msg.status}">
|
||||
<div class="copilot-approval-header">
|
||||
<i class="bi bi-pencil-square"></i>
|
||||
<span class="copilot-approval-path copilot-tool-path" role="button" tabindex="0"
|
||||
title="Open in viewer"
|
||||
@click=${() => openFile(msg.path)}
|
||||
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(msg.path); } }}
|
||||
>${msg.path}</span>
|
||||
${msg.status === 'pending'
|
||||
? html`<span class="badge bg-warning text-dark ms-auto">Pending approval</span>`
|
||||
: msg.status === 'approved'
|
||||
? html`<span class="badge bg-success ms-auto">Approved</span>`
|
||||
: html`<span class="badge bg-danger ms-auto">Rejected</span>`}
|
||||
</div>
|
||||
|
||||
<pre class="copilot-diff">${renderDiff(msg.old_content, msg.new_content)}</pre>
|
||||
|
||||
${msg.status === 'pending' ? html`
|
||||
<div class="copilot-approval-actions">
|
||||
${isRejecting ? html`
|
||||
<textarea
|
||||
class="form-control form-control-sm copilot-reject-note"
|
||||
rows="2"
|
||||
placeholder="Optional: explain why you rejected this (sent to the LLM)"
|
||||
.value=${host._rejectNote}
|
||||
@input=${(e) => { host._rejectNote = e.target.value; }}
|
||||
></textarea>
|
||||
<div class="copilot-approval-btns">
|
||||
<button class="btn btn-sm btn-danger" @click=${() => host._confirmReject(msg)}>
|
||||
<i class="bi bi-x-circle me-1"></i>Confirm reject
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => { host._rejectingId = null; }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="copilot-approval-btns">
|
||||
<button class="btn btn-sm btn-success" @click=${() => host._approve(msg)}>
|
||||
<i class="bi bi-check-circle me-1"></i>Approve
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @click=${() => host._startReject(msg)}>
|
||||
<i class="bi bi-x-circle me-1"></i>Reject
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip similar approvals for 15 minutes"
|
||||
@click=${() => host._approveWriteBypass(msg, 900)}>
|
||||
<i class="bi bi-clock me-1"></i>15 min
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip all approvals for this session"
|
||||
@click=${() => host._approveWriteBypass(msg, 0)}>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Session
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderTool(host, msg) {
|
||||
const isOpen = host._expanded.has(msg.tool_call_id);
|
||||
const argsStr = truncate(msg.arguments);
|
||||
const isPending = msg.status === 'pending';
|
||||
const isRejecting = isPending && host._rejectingId === msg.tool_call_id;
|
||||
|
||||
const statusIcon =
|
||||
msg.status === 'running'
|
||||
? html`<span class="spinner-border spinner-border-sm" role="status"></span>`
|
||||
: isPending
|
||||
? html`<span class="spinner-border spinner-border-sm text-warning" role="status" title="Awaiting approval"></span>`
|
||||
: msg.status === 'done'
|
||||
? html`<i class="bi bi-check-circle-fill text-success"></i>`
|
||||
: msg.status === 'cancelled'
|
||||
? html`<i class="bi bi-slash-circle-fill text-secondary" title="Cancelled by user"></i>`
|
||||
: msg.status === 'rejected'
|
||||
? html`<i class="bi bi-shield-fill-x text-warning" title="Denied by policy"></i>`
|
||||
: html`<i class="bi bi-x-circle-fill text-danger"></i>`;
|
||||
|
||||
return html`
|
||||
<div class="copilot-tool ${isPending ? 'copilot-tool--pending' : ''}">
|
||||
<button class="copilot-tool-header" @click=${() => host._toggleExpand(msg.tool_call_id)}>
|
||||
<span class="copilot-tool-status">${statusIcon}</span>
|
||||
<span class="copilot-tool-name">${renderLabel(msg.label_full || msg.name, msg.path)}</span>
|
||||
${isPending ? html`<span class="badge bg-warning text-dark ms-2">Pending approval</span>` : nothing}
|
||||
<i class="bi bi-chevron-${isOpen ? 'up' : 'down'} ms-auto"></i>
|
||||
</button>
|
||||
${isOpen ? html`
|
||||
<div class="copilot-tool-body">
|
||||
${!(isPending && msg.name === 'ask_user_clarification') ? html`
|
||||
<div class="copilot-tool-section">
|
||||
<span class="copilot-tool-label">args</span>
|
||||
<pre class="copilot-tool-pre">${argsStr}</pre>
|
||||
</div>
|
||||
` : nothing}
|
||||
${isPending ? (msg.name === 'ask_user_clarification' ? html`
|
||||
<div class="copilot-approval-actions">
|
||||
${msg.question_title ? html`<div class="copilot-clarification-title">${msg.question_title}</div>` : nothing}
|
||||
<div class="copilot-clarification-question copilot-markdown">${unsafeHTML(renderMarkdown(msg.question ?? msg.arguments?.question ?? ''))}</div>
|
||||
${(msg.suggested_answers ?? []).length > 0 ? html`
|
||||
<div class="copilot-clarification-chips">
|
||||
${(msg.suggested_answers ?? []).map(s => html`
|
||||
<button class="btn btn-sm btn-outline-secondary copilot-chip"
|
||||
@click=${() => { host._clarificationAnswer = s; }}>
|
||||
${s}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
<div class="copilot-clarification-input-row">
|
||||
<textarea
|
||||
class="form-control form-control-sm copilot-reject-note"
|
||||
rows="2"
|
||||
placeholder="Type your answer…"
|
||||
.value=${host._clarificationAnswer}
|
||||
@input=${(e) => { host._clarificationAnswer = e.target.value; }}
|
||||
@keydown=${(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); host._answerQuestion(msg); } }}
|
||||
></textarea>
|
||||
<button class="btn btn-sm btn-primary ms-2"
|
||||
@click=${() => host._answerQuestion(msg)}
|
||||
?disabled=${!host._clarificationAnswer.trim()}>
|
||||
<i class="bi bi-send me-1"></i>Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="copilot-approval-actions">
|
||||
${isRejecting ? html`
|
||||
<textarea
|
||||
class="form-control form-control-sm copilot-reject-note"
|
||||
rows="2"
|
||||
placeholder="Reason for rejection (optional, sent to the LLM)"
|
||||
.value=${host._rejectNote}
|
||||
@input=${(e) => { host._rejectNote = e.target.value; }}
|
||||
></textarea>
|
||||
<div class="copilot-approval-btns">
|
||||
<button class="btn btn-sm btn-danger"
|
||||
@click=${() => msg.request_id != null ? host._rejectWsTool(msg) : host._rejectTool(msg)}>
|
||||
<i class="bi bi-x-circle me-1"></i>Confirm reject
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
@click=${() => { host._rejectingId = null; }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="copilot-approval-btns">
|
||||
<button class="btn btn-sm btn-success"
|
||||
@click=${(e) => { e.stopPropagation(); msg.request_id != null ? host._approveWsTool(msg) : host._approveTool(msg); }}>
|
||||
<i class="bi bi-check-circle me-1"></i>Approve
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
@click=${(e) => { e.stopPropagation(); host._rejectingId = msg.tool_call_id; host._rejectNote = ''; }}>
|
||||
<i class="bi bi-x-circle me-1"></i>Reject
|
||||
</button>
|
||||
${msg.request_id != null ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip similar approvals for 15 minutes"
|
||||
@click=${(e) => { e.stopPropagation(); host._approveWsToolBypass(msg, 900); }}>
|
||||
<i class="bi bi-clock me-1"></i>15 min
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Approve and skip all approvals for this session"
|
||||
@click=${(e) => { e.stopPropagation(); host._approveWsToolBypass(msg, 0); }}>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Session
|
||||
</button>
|
||||
` : nothing}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`) : msg.status !== 'running' ? (
|
||||
msg.status === 'done' && msg.result_type === 'json' ? html`
|
||||
<div class="copilot-tool-section">
|
||||
<span class="copilot-tool-label copilot-tool-label--done">result · json</span>
|
||||
<pre class="copilot-tool-pre copilot-tool-pre--done copilot-tool-pre--json">${
|
||||
truncate(prettyJson(msg.result))
|
||||
}</pre>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="copilot-tool-section">
|
||||
<span class="copilot-tool-label copilot-tool-label--${msg.status}">
|
||||
${msg.status === 'done' ? 'result' : 'error'}
|
||||
</span>
|
||||
<pre class="copilot-tool-pre copilot-tool-pre--${msg.status}">${
|
||||
truncate(msg.status === 'done' ? msg.result : msg.error)
|
||||
}</pre>
|
||||
</div>
|
||||
`) : nothing}
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderAgent(msg) {
|
||||
const icon = msg.done ? 'check2-all' : 'arrow-right-circle';
|
||||
return html`
|
||||
<div class="copilot-agent" style="--agent-depth:${Math.min(msg.depth, 4)}">
|
||||
<div class="copilot-agent-header">
|
||||
<i class="bi bi-${icon}"></i>
|
||||
<span>
|
||||
<strong>${msg.parent_agent_id ?? 'main'}</strong>
|
||||
<i class="bi bi-arrow-right mx-1" style="font-size:0.7rem"></i>
|
||||
<strong>${msg.agent_id}</strong>
|
||||
</span>
|
||||
${msg.done ? html`<span class="copilot-agent-badge done">done</span>` : html`<span class="copilot-agent-badge running">running…</span>`}
|
||||
</div>
|
||||
${msg.prompt_preview ? html`
|
||||
<pre class="copilot-agent-preview">${msg.prompt_preview}</pre>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderAgentEnd(msg) {
|
||||
return html`
|
||||
<div class="copilot-agent-end" style="--agent-depth:${Math.min(msg.depth, 4)}">
|
||||
<div class="copilot-agent-header">
|
||||
<i class="bi bi-arrow-return-left"></i>
|
||||
<span>
|
||||
<strong>${msg.agent_id}</strong>
|
||||
<i class="bi bi-arrow-right mx-1" style="font-size:0.7rem"></i>
|
||||
<strong>${msg.parent_agent_id ?? 'main'}</strong>
|
||||
</span>
|
||||
<span class="copilot-agent-badge done">finished</span>
|
||||
</div>
|
||||
${msg.result_preview ? html`
|
||||
<pre class="copilot-agent-preview copilot-agent-preview--result">${msg.result_preview}</pre>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function failedBadge() {
|
||||
return html`<span class="copilot-failed-badge" title="This message is not sent to the LLM">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// ── Attachment chips ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Bootstrap icon class for a file based on its MIME type / extension. */
|
||||
function attachmentIcon(att) {
|
||||
const m = (att.mimetype || '').toLowerCase();
|
||||
const n = (att.name || '').toLowerCase();
|
||||
if (m.startsWith('image/')) return 'bi-file-earmark-image';
|
||||
if (m === 'application/pdf' || n.endsWith('.pdf')) return 'bi-file-earmark-pdf';
|
||||
if (m.startsWith('audio/')) return 'bi-file-earmark-music';
|
||||
if (m.startsWith('video/')) return 'bi-file-earmark-play';
|
||||
if (m.startsWith('text/') || /\.(md|txt|csv|json|ya?ml|rs|js|ts|py)$/.test(n)) return 'bi-file-earmark-text';
|
||||
return 'bi-file-earmark';
|
||||
}
|
||||
|
||||
/** Human-readable file size, e.g. "1.2 MB". */
|
||||
function fmtSize(bytes) {
|
||||
if (bytes == null) return '';
|
||||
const u = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0, n = bytes;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${u[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a list of attachment chips. Used both above the composer (pending
|
||||
* uploads — `removable`, with an × button and a spinner while uploading) and
|
||||
* inside a sent user bubble (clickable to open the file). `host` must provide
|
||||
* `_removeAttachment(i)` when `removable` is true.
|
||||
*/
|
||||
export function renderAttachmentChips(host, attachments, { removable = false } = {}) {
|
||||
if (!attachments?.length) return nothing;
|
||||
return html`
|
||||
<div class="attach-chips">
|
||||
${attachments.map((att, i) => html`
|
||||
<div class="attach-chip ${att.uploading ? 'attach-chip--uploading' : ''} ${!removable && att.path ? 'attach-chip--clickable' : ''}"
|
||||
title=${att.name}
|
||||
@click=${() => { if (!removable && att.path) openFile(att.path); }}>
|
||||
${att.uploading
|
||||
? html`<span class="spinner-border spinner-border-sm"></span>`
|
||||
: html`<i class="bi ${attachmentIcon(att)}"></i>`}
|
||||
<span class="attach-chip-name">${att.name}</span>
|
||||
${att.filesize != null ? html`<span class="attach-chip-size">${fmtSize(att.filesize)}</span>` : nothing}
|
||||
${removable ? html`
|
||||
<button class="attach-chip-remove" title="Remove"
|
||||
@click=${(e) => { e.stopPropagation(); host._removeAttachment(i); }}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>` : nothing}
|
||||
</div>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderMsg(host, msg) {
|
||||
try {
|
||||
switch (msg.kind) {
|
||||
case 'user':
|
||||
return html`<div class="copilot-msg user ${msg.failed ? 'copilot-msg--failed' : ''}" style="white-space:pre-wrap">${msg.failed ? failedBadge() : nothing}${msg.content}${renderAttachmentChips(host, msg.attachments)}</div>`;
|
||||
case 'thinking':
|
||||
return html`
|
||||
<div class="copilot-msg assistant copilot-markdown ${msg.failed ? 'copilot-msg--failed' : ''}">
|
||||
${msg.failed ? failedBadge() : nothing}
|
||||
${unsafeHTML(renderMarkdown(msg.content))}
|
||||
${msg.input_tokens != null ? html`<div class="copilot-token-count">↑${msg.input_tokens.toLocaleString()} tok ↓${msg.output_tokens?.toLocaleString()} tok</div>` : nothing}
|
||||
</div>`;
|
||||
case 'assistant':
|
||||
return html`
|
||||
<div class="copilot-msg assistant copilot-markdown ${msg.failed ? 'copilot-msg--failed' : ''}">
|
||||
${msg.failed ? failedBadge() : nothing}
|
||||
${unsafeHTML(renderMarkdown(msg.content))}
|
||||
${msg.input_tokens != null ? html`<div class="copilot-token-count">↑${msg.input_tokens.toLocaleString()} tok ↓${msg.output_tokens?.toLocaleString()} tok</div>` : nothing}
|
||||
</div>`;
|
||||
case 'error':
|
||||
return html`
|
||||
<div class="copilot-msg error">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>${msg.content}
|
||||
</div>`;
|
||||
case 'info':
|
||||
return html`
|
||||
<div class="copilot-msg info">
|
||||
${msg.content}
|
||||
</div>`;
|
||||
case 'pending_write':
|
||||
return renderPendingWrite(host, msg);
|
||||
case 'tool':
|
||||
return renderTool(host, msg);
|
||||
case 'agent':
|
||||
return renderAgent(msg);
|
||||
case 'agent_end':
|
||||
return renderAgentEnd(msg);
|
||||
default:
|
||||
return nothing;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[renderMsg] kind=' + msg.kind, err);
|
||||
return html`<div class="copilot-msg error">Render error [${msg.kind}]: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { ChatSession } from '../lib/chat-session.js';
|
||||
import { renderMsg, renderAttachmentChips } from './copilot-render.js';
|
||||
|
||||
// Built-in (server-handled) slash commands shown at the top of the composer
|
||||
// autocomplete. Custom commands (from `commands/<name>/`) are fetched from
|
||||
// `/api/commands` and appended below.
|
||||
const SYSTEM_COMMAND_ITEMS = [
|
||||
{ name: 'help', description: 'Show available commands' },
|
||||
{ name: 'clear', description: 'Start a new conversation' },
|
||||
{ name: 'new', description: 'Alias for /clear' },
|
||||
{ name: 'models', description: 'List available LLM models' },
|
||||
{ name: 'model', description: 'Select the model for this chat' },
|
||||
{ name: 'context', description: "Last turn's token usage" },
|
||||
{ name: 'cost', description: 'Session spend (USD)' },
|
||||
{ name: 'compact', description: 'Force context compaction' },
|
||||
{ name: 'resettools', description: 'Remove activated tool groups' },
|
||||
{ name: 'sethome', description: 'Set web as notification home' },
|
||||
];
|
||||
|
||||
export class AppCopilot extends ChatSession {
|
||||
static properties = {
|
||||
_collapsed: { state: true },
|
||||
_modelOpen: { state: true },
|
||||
_tabs: { state: true },
|
||||
_activeSource: { state: true },
|
||||
_cmdMenu: { state: true },
|
||||
_cmdSel: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._collapsed = false;
|
||||
this._modelOpen = false;
|
||||
this._resizing = false;
|
||||
// Slash-command autocomplete: `_cmdMenu` is the filtered list currently shown
|
||||
// (null = hidden), `_cmdSel` the highlighted index, `_allCommands` the merged
|
||||
// system + custom list fetched once from `/api/commands`.
|
||||
this._cmdMenu = null;
|
||||
this._cmdSel = 0;
|
||||
this._allCommands = null;
|
||||
// Browser-style tabs: 'General' (the default 'web' source) is always present and
|
||||
// not closable; project chats are added on demand and addressed by their source.
|
||||
this._tabs = [{ source: 'web', label: 'General' }];
|
||||
this._onResizeMove = this._onResizeMove.bind(this);
|
||||
this._onResizeUp = this._onResizeUp.bind(this);
|
||||
this._onKeydown = this._onKeydown.bind(this);
|
||||
this._onKeyup = this._onKeyup.bind(this);
|
||||
this._onProjectChatOpen = this._onProjectChatOpen.bind(this);
|
||||
this._onCopilotOpen = this._onCopilotOpen.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback?.();
|
||||
this._restoreState();
|
||||
this._loadCommands();
|
||||
window.addEventListener('keydown', this._onKeydown);
|
||||
window.addEventListener('keyup', this._onKeyup);
|
||||
window.addEventListener('project-chat-open', this._onProjectChatOpen);
|
||||
window.addEventListener('copilot-open', this._onCopilotOpen);
|
||||
}
|
||||
|
||||
_restoreState() {
|
||||
const w = localStorage.getItem('copilot-width');
|
||||
if (w) document.documentElement.style.setProperty('--copilot-width', w);
|
||||
if (localStorage.getItem('copilot-collapsed') === 'true') {
|
||||
this._setCollapsed(true);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback?.();
|
||||
window.removeEventListener('keydown', this._onKeydown);
|
||||
window.removeEventListener('keyup', this._onKeyup);
|
||||
window.removeEventListener('project-chat-open', this._onProjectChatOpen);
|
||||
window.removeEventListener('copilot-open', this._onCopilotOpen);
|
||||
}
|
||||
|
||||
_onCopilotOpen() {
|
||||
this._setCollapsed(false);
|
||||
}
|
||||
|
||||
_setCollapsed(value) {
|
||||
this._collapsed = value;
|
||||
this.classList.toggle('collapsed', value);
|
||||
localStorage.setItem('copilot-collapsed', value);
|
||||
window.dispatchEvent(new CustomEvent('copilot-collapsed', { detail: { collapsed: value } }));
|
||||
}
|
||||
|
||||
// ── Tabs ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// A project chat was opened elsewhere (e.g. the project board): add its tab if
|
||||
// new, expand the copilot, and switch the live connection to it.
|
||||
_onProjectChatOpen(e) {
|
||||
const { source, label } = e.detail ?? {};
|
||||
if (!source) return;
|
||||
if (!this._tabs.some(t => t.source === source)) {
|
||||
this._tabs = [...this._tabs, { source, label: label || source }];
|
||||
}
|
||||
this._setCollapsed(false);
|
||||
this._selectTab(source);
|
||||
}
|
||||
|
||||
_selectTab(source) {
|
||||
if (source === this._source) return;
|
||||
this._switchSource(source); // base: tear down WS, reload history, reconnect
|
||||
}
|
||||
|
||||
// Close a project tab (UI only — the session persists server-side and can be
|
||||
// reopened from the board). The 'web'/General tab is never closable.
|
||||
_closeTab(source, e) {
|
||||
e?.stopPropagation();
|
||||
if (source === 'web') return;
|
||||
const wasActive = source === this._source;
|
||||
this._tabs = this._tabs.filter(t => t.source !== source);
|
||||
if (wasActive) this._switchSource('web');
|
||||
}
|
||||
|
||||
// ── DOM hooks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_inputEl() {
|
||||
return this.querySelector('.copilot-textarea');
|
||||
}
|
||||
|
||||
_scrollToBottom() {
|
||||
this.updateComplete.then(() => {
|
||||
const el = this.querySelector('.copilot-messages');
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
_onMessagePushed(item) {
|
||||
if (item.kind === 'pending_write') {
|
||||
this.updateComplete.then(() => {
|
||||
const panels = this.querySelectorAll('.copilot-approval');
|
||||
const el = panels[panels.length - 1];
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
});
|
||||
} else {
|
||||
this._scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resize ────────────────────────────────────────────────────────────────────
|
||||
|
||||
_startResize(e) {
|
||||
this._resizing = true;
|
||||
this._resizeStartX = e.clientX;
|
||||
this._resizeStartW = this.offsetWidth;
|
||||
window.addEventListener('mousemove', this._onResizeMove);
|
||||
window.addEventListener('mouseup', this._onResizeUp);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
_onResizeMove(e) {
|
||||
if (!this._resizing) return;
|
||||
const delta = this._resizeStartX - e.clientX;
|
||||
const newWidth = Math.max(260, Math.min(720, this._resizeStartW + delta));
|
||||
document.documentElement.style.setProperty('--copilot-width', `${newWidth}px`);
|
||||
}
|
||||
|
||||
_onResizeUp() {
|
||||
this._resizing = false;
|
||||
window.removeEventListener('mousemove', this._onResizeMove);
|
||||
window.removeEventListener('mouseup', this._onResizeUp);
|
||||
const w = getComputedStyle(document.documentElement).getPropertyValue('--copilot-width').trim();
|
||||
if (w) localStorage.setItem('copilot-width', w);
|
||||
}
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
_handleKeydown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this._send();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slash-command autocomplete ────────────────────────────────────────────────
|
||||
|
||||
/** Fetch custom commands once and merge them below the built-in system ones. */
|
||||
async _loadCommands() {
|
||||
try {
|
||||
const res = await fetch('/api/commands');
|
||||
const custom = res.ok ? await res.json() : [];
|
||||
this._allCommands = [...SYSTEM_COMMAND_ITEMS, ...custom];
|
||||
} catch {
|
||||
this._allCommands = [...SYSTEM_COMMAND_ITEMS];
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute the menu from the current input value. Shown only while typing the
|
||||
* command name (a leading `/` with no whitespace yet); hidden once args start. */
|
||||
_updateCmdMenu(value) {
|
||||
const m = /^\/([a-z0-9_-]*)$/i.exec(value);
|
||||
if (!m) { if (this._cmdMenu) this._cmdMenu = null; return; }
|
||||
const prefix = m[1].toLowerCase();
|
||||
const items = (this._allCommands || SYSTEM_COMMAND_ITEMS)
|
||||
.filter(c => c.name.toLowerCase().startsWith(prefix));
|
||||
this._cmdMenu = items.length ? items : null;
|
||||
this._cmdSel = 0;
|
||||
}
|
||||
|
||||
/** Insert the chosen command (`/name `) and close the menu, ready for arguments. */
|
||||
_applyCmd(name) {
|
||||
const el = this._inputEl();
|
||||
if (el) {
|
||||
el.value = `/${name} `;
|
||||
el.focus();
|
||||
this._autoResize(el);
|
||||
}
|
||||
this._cmdMenu = null;
|
||||
}
|
||||
|
||||
/** Composer keydown: drive the menu when open, else fall back to send-on-Enter. */
|
||||
_composerKeydown(e) {
|
||||
const menu = this._cmdMenu;
|
||||
if (menu && menu.length) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); this._cmdSel = (this._cmdSel + 1) % menu.length; return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); this._cmdSel = (this._cmdSel - 1 + menu.length) % menu.length; return; }
|
||||
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); this._applyCmd(menu[this._cmdSel].name); return; }
|
||||
if (e.key === 'Escape') { e.preventDefault(); this._cmdMenu = null; return; }
|
||||
}
|
||||
this._handleKeydown(e);
|
||||
}
|
||||
|
||||
// ── Ctrl+Space push-to-talk shortcut (desktop only) ──────────────────────────
|
||||
// Voice recording + transcription is owned by the ChatSession base class; the
|
||||
// only desktop-specific bit is the global Ctrl+Space hold-to-record shortcut.
|
||||
|
||||
_onKeydown(e) {
|
||||
if (!this._hasTranscribe) return;
|
||||
if (e.code === 'Space' && e.ctrlKey && !e.repeat) {
|
||||
e.preventDefault();
|
||||
if (!this._recording) this._startRecording(true);
|
||||
}
|
||||
}
|
||||
|
||||
_onKeyup(e) {
|
||||
if (!this._hasTranscribe) return;
|
||||
if (e.code === 'Space' && this._recording && this._shortcutRecording) {
|
||||
e.preventDefault();
|
||||
this._stopRecording();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
_toggleExpand(id) {
|
||||
const next = new Set(this._expanded);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
this._expanded = next;
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
if (this._collapsed) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="copilot-resize-handle" @mousedown=${(e) => this._startResize(e)}></div>
|
||||
|
||||
<div class="copilot-header">
|
||||
<i class="bi bi-stars"></i>
|
||||
<span>Copilot</span>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary ms-auto copilot-collapse-btn"
|
||||
title="Collapse copilot"
|
||||
@click=${() => { this._setCollapsed(true); }}
|
||||
>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._tabs.length > 1 ? html`
|
||||
<div class="copilot-tabs">
|
||||
${this._tabs.map(t => html`
|
||||
<div
|
||||
class="copilot-tab ${t.source === this._source ? 'copilot-tab--active' : ''}"
|
||||
@click=${() => this._selectTab(t.source)}
|
||||
title=${t.label}
|
||||
>
|
||||
<span class="copilot-tab-label">${t.label}</span>
|
||||
${t.source !== 'web' ? html`
|
||||
<button class="copilot-tab-close" title="Close tab"
|
||||
@click=${e => this._closeTab(t.source, e)}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="copilot-messages">
|
||||
${this._messages.length === 0 ? html`
|
||||
<div class="copilot-msg assistant">
|
||||
Hello! How can I help you today?
|
||||
</div>
|
||||
` : this._messages.map(m => renderMsg(this, m))}
|
||||
|
||||
${this._waiting ? html`
|
||||
<div class="copilot-msg assistant copilot-thinking">
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Thinking…
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="copilot-input-area">
|
||||
<div class="copilot-composer"
|
||||
@dragover=${(e) => e.preventDefault()}
|
||||
@drop=${(e) => this._onDrop(e)}>
|
||||
${this._cmdMenu?.length ? html`
|
||||
<div class="copilot-cmd-menu">
|
||||
${this._cmdMenu.map((c, i) => html`
|
||||
<button
|
||||
class="copilot-cmd-item ${i === this._cmdSel ? 'active' : ''}"
|
||||
@mousedown=${(e) => { e.preventDefault(); this._applyCmd(c.name); }}
|
||||
>
|
||||
<span class="copilot-cmd-name">/${c.name}</span>
|
||||
<span class="copilot-cmd-desc">${c.description}</span>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
${renderAttachmentChips(this, this._attachments, { removable: true })}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
class="copilot-file-input"
|
||||
style="display:none"
|
||||
@change=${(e) => { this._addFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<textarea
|
||||
class="copilot-textarea"
|
||||
rows="1"
|
||||
placeholder="Ask the copilot… (Enter to send, Shift+Enter for new line)"
|
||||
@keydown=${this._composerKeydown}
|
||||
@input=${(e) => { this._autoResize(e.target); this._updateCmdMenu(e.target.value); }}
|
||||
@paste=${(e) => this._onPaste(e)}
|
||||
></textarea>
|
||||
<div class="copilot-toolbar">
|
||||
<div class="copilot-toolbar-left">
|
||||
<button
|
||||
class="copilot-toolbar-btn"
|
||||
title="Attach files"
|
||||
@click=${() => this.querySelector('.copilot-file-input')?.click()}
|
||||
><i class="bi bi-paperclip"></i></button>
|
||||
${this._providers.length > 1 ? html`
|
||||
<div class="copilot-model-wrap">
|
||||
${this._modelOpen ? html`
|
||||
<div class="copilot-model-overlay" @click=${() => { this._modelOpen = false; }}></div>
|
||||
<div class="copilot-model-dropdown">
|
||||
${this._providers.map(p => html`
|
||||
<button
|
||||
class="copilot-model-item ${p === this._selectedClient ? 'active' : ''}"
|
||||
@click=${() => { this._selectClient(p); this._modelOpen = false; }}
|
||||
>${p}</button>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
<button class="copilot-model-pill" @click=${() => { this._modelOpen = !this._modelOpen; }}>
|
||||
<i class="bi bi-stars"></i>
|
||||
<span>${this._selectedClient ?? 'auto'}</span>
|
||||
<i class="bi bi-chevron-${this._modelOpen ? 'down' : 'up'}"></i>
|
||||
</button>
|
||||
</div>
|
||||
` : nothing}
|
||||
<button
|
||||
class="copilot-toolbar-btn"
|
||||
title="New session"
|
||||
@click=${() => this._startNewSession()}
|
||||
><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
<div class="copilot-toolbar-right">
|
||||
${this._hasTranscribe ? html`
|
||||
<button
|
||||
class="copilot-send-btn ${this._recording ? 'copilot-send-btn--recording' : ''}"
|
||||
title="${this._recording ? 'Stop recording' : 'Record voice (Ctrl+Space)'}"
|
||||
@click=${() => this._toggleRecording()}
|
||||
>
|
||||
<i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
${this._waiting
|
||||
? html`<button class="copilot-send-btn copilot-send-btn--stop" @click=${() => this._cancel()} title="Stop">
|
||||
<i class="bi bi-stop-fill"></i>
|
||||
</button>`
|
||||
: nothing}
|
||||
<button class="copilot-send-btn" @click=${() => this._send()} title="Send">
|
||||
<i class="bi bi-send-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { FileViewerBase } from './shared/file-viewer-base.js';
|
||||
|
||||
const PAGE_ID = 'file_viewer';
|
||||
|
||||
function pathFromHash() {
|
||||
const h = location.hash;
|
||||
const prefix = `#${PAGE_ID}?path=`;
|
||||
if (!h.startsWith(prefix)) return null;
|
||||
try {
|
||||
return decodeURIComponent(h.slice(prefix.length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop file-viewer page. Self-routes off the hash (`#file_viewer?path=...`):
|
||||
* the sidebar's `llm-page-change` event toggles visibility and `hashchange`
|
||||
* re-loads. All fetch/render/watch logic lives in `FileViewerBase`; this
|
||||
* subclass only adds the desktop chrome and the hash wiring.
|
||||
*/
|
||||
export class FileViewerPage extends FileViewerBase {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._loadFromHash();
|
||||
else this._hide();
|
||||
});
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (this._open) this._loadFromHash();
|
||||
});
|
||||
}
|
||||
|
||||
_loadFromHash() {
|
||||
const path = pathFromHash();
|
||||
if (path) this._show(path);
|
||||
}
|
||||
|
||||
_back() {
|
||||
history.back();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
return html`
|
||||
<div class="llm-page fv-page">
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back" @click=${() => this._back()}>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
<h2 class="llm-page-title fv-title" title=${this._path ?? ''}><bdi>${this._path ?? ''}</bdi></h2>
|
||||
</div>
|
||||
<div class="fv-header-actions">
|
||||
${this._renderModeToggle('btn btn-sm btn-outline-secondary fv-download-btn')}
|
||||
<button class="btn btn-sm btn-outline-secondary fv-download-btn" title="Download" @click=${() => this._download()}>
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fv-body">${this._renderBody()}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { InboxMixin } from '../lib/inbox-mixin.js';
|
||||
|
||||
const GUIDE = [
|
||||
{
|
||||
icon: 'bi-chat-dots-fill',
|
||||
title: 'Copilot',
|
||||
desc: 'The chat panel on the right knows everything — ask it to run agents, enable plugins, write code, or search the web.',
|
||||
color: '#0d6efd',
|
||||
},
|
||||
{
|
||||
icon: 'bi-inbox',
|
||||
title: 'Inbox',
|
||||
desc: 'Pending approvals and agent questions that need your input before background tasks can continue.',
|
||||
color: '#f59e0b',
|
||||
},
|
||||
{
|
||||
icon: 'bi-people',
|
||||
title: 'Agents',
|
||||
desc: 'Specialized sub-agents (engineer, architect, QA…). Each has a focused system prompt, tool set, and model selection.',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
icon: 'bi-clock',
|
||||
title: 'Cron',
|
||||
desc: 'Scheduled tasks that run automatically at set intervals, even when the Copilot is idle.',
|
||||
color: '#f97316',
|
||||
},
|
||||
{
|
||||
icon: 'bi-cpu',
|
||||
title: 'Models',
|
||||
desc: 'Manage LLM, transcription, and image generation models. Drag to reorder priority.',
|
||||
color: '#10b981',
|
||||
},
|
||||
{
|
||||
icon: 'bi-plug',
|
||||
title: 'Providers',
|
||||
desc: 'Add API keys for LLM providers (Anthropic, OpenAI, OpenRouter, Ollama…).',
|
||||
color: '#06b6d4',
|
||||
},
|
||||
{
|
||||
icon: 'bi-shield-check',
|
||||
title: 'Security',
|
||||
desc: 'Define rules to auto-approve or auto-reject tool calls — skip repetitive confirmation prompts.',
|
||||
color: '#ef4444',
|
||||
},
|
||||
];
|
||||
|
||||
export class HomePage extends InboxMixin(LightElement) {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
...super.properties,
|
||||
_open: { state: true },
|
||||
_models: { state: true },
|
||||
_plugins: { state: true },
|
||||
_debugMode: { state: true },
|
||||
_debugLoading: { state: true },
|
||||
_stats: { state: true },
|
||||
_statsRange: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._models = null; // null = loading, [] = no models configured
|
||||
this._plugins = null;
|
||||
this._pollTimer = null;
|
||||
this._debugMode = false;
|
||||
this._debugLoading = true;
|
||||
this._stats = null; // null = loading
|
||||
this._statsRange = 'week';
|
||||
this._chartInstances = {};
|
||||
this._statsTimer = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'home';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) {
|
||||
this._loadAll();
|
||||
this._loadStats();
|
||||
this._startPolling();
|
||||
} else {
|
||||
this._stopPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopPolling();
|
||||
this._destroyCharts();
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
super.updated?.(changed);
|
||||
if (changed.has('_stats') && this._stats !== null) {
|
||||
requestAnimationFrame(() => this._initCharts());
|
||||
}
|
||||
}
|
||||
|
||||
_startPolling() {
|
||||
this._stopPolling();
|
||||
this._pollTimer = setInterval(() => this._loadAll(), 10_000);
|
||||
this._statsTimer = setInterval(() => this._loadStats(), 180_000);
|
||||
}
|
||||
|
||||
_stopPolling() {
|
||||
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
|
||||
if (this._statsTimer) { clearInterval(this._statsTimer); this._statsTimer = null; }
|
||||
}
|
||||
|
||||
async _loadAll() {
|
||||
await Promise.all([
|
||||
this._loadModels(),
|
||||
this._loadPlugins(),
|
||||
this._loadInbox(),
|
||||
this._loadDebugMode(),
|
||||
]);
|
||||
}
|
||||
|
||||
async _loadDebugMode() {
|
||||
try {
|
||||
const res = await fetch('/api/dev/debug_mode');
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
this._debugMode = data.enabled;
|
||||
} catch {
|
||||
// ignore, keep current value
|
||||
} finally {
|
||||
this._debugLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _toggleDebugMode() {
|
||||
const next = !this._debugMode;
|
||||
this._debugMode = next;
|
||||
try {
|
||||
const res = await fetch('/api/dev/debug_mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
window.dispatchEvent(new CustomEvent('debug-mode-change', { detail: { enabled: next } }));
|
||||
} catch {
|
||||
this._debugMode = !next;
|
||||
}
|
||||
}
|
||||
|
||||
async _loadModels() {
|
||||
try {
|
||||
const res = await fetch('/api/llm/models');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._models = await res.json();
|
||||
} catch {
|
||||
this._models = [];
|
||||
}
|
||||
}
|
||||
|
||||
async _loadPlugins() {
|
||||
try {
|
||||
const res = await fetch('/api/plugins');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._plugins = await res.json();
|
||||
} catch {
|
||||
this._plugins = [];
|
||||
}
|
||||
}
|
||||
|
||||
async _loadStats() {
|
||||
try {
|
||||
const res = await fetch(`/api/stats/llm?range=${this._statsRange}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._stats = await res.json();
|
||||
} catch {
|
||||
this._stats = { daily: [], models: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async _setRange(range) {
|
||||
if (range === this._statsRange) return;
|
||||
this._statsRange = range;
|
||||
this._stats = null;
|
||||
await this._loadStats();
|
||||
}
|
||||
|
||||
get _honchoActive() {
|
||||
return this._plugins?.some(p => p.id === 'honcho' && p.enabled && p.running) ?? false;
|
||||
}
|
||||
|
||||
get _statusInfo() {
|
||||
if (this._models === null) return { cls: 'loading', dot: false, icon: null, text: 'Loading…' };
|
||||
if (this._models.length === 0) return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: 'No LLM models' };
|
||||
if (this._models.some(m => m.status === 'healthy')) return { cls: 'online', dot: true, icon: null, text: 'Online & ready' };
|
||||
if (this._models.some(m => m.status === 'degraded')) return { cls: 'warn', dot: true, icon: 'bi-exclamation-triangle-fill', text: 'Degraded' };
|
||||
return { cls: 'error', dot: false, icon: 'bi-exclamation-circle-fill', text: 'All models offline' };
|
||||
}
|
||||
|
||||
_nav(page) {
|
||||
const url = page === 'home' ? location.pathname : '#' + page;
|
||||
history.pushState({ page }, '', url);
|
||||
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } }));
|
||||
}
|
||||
|
||||
// ── Charts ────────────────────────────────────────────────────────────────
|
||||
|
||||
_destroyCharts() {
|
||||
for (const c of Object.values(this._chartInstances)) {
|
||||
c.destroy();
|
||||
}
|
||||
this._chartInstances = {};
|
||||
}
|
||||
|
||||
_shortModelName(name) {
|
||||
return name
|
||||
.replace(/^claude-/, '')
|
||||
.replace(/^gpt-/, '')
|
||||
.replace(/-\d{8}$/, '');
|
||||
}
|
||||
|
||||
get _periodLabel() {
|
||||
return { hour: '/ min', day: '/ hour', week: '/ day', month: '/ day' }[this._statsRange] ?? '/ day';
|
||||
}
|
||||
|
||||
// Generates the full sequence of expected slots for the current range and
|
||||
// merges with backend data, filling missing slots with zeros.
|
||||
_fillGaps(daily) {
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const slots = [];
|
||||
|
||||
if (this._statsRange === 'hour') {
|
||||
for (let i = 59; i >= 0; i--) {
|
||||
const d = new Date(now - i * 60_000);
|
||||
slots.push(`${pad(d.getHours())}:${pad(d.getMinutes())}`);
|
||||
}
|
||||
} else if (this._statsRange === 'day') {
|
||||
for (let i = 23; i >= 0; i--) {
|
||||
const d = new Date(now - i * 3_600_000);
|
||||
slots.push(`${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:00`);
|
||||
}
|
||||
} else {
|
||||
const count = this._statsRange === 'week' ? 7 : 30;
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const d = new Date(now - i * 86_400_000);
|
||||
slots.push(`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`);
|
||||
}
|
||||
}
|
||||
|
||||
const index = new Map(daily.map(d => [d.day, d]));
|
||||
const zero = { requests: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, avg_duration_ms: 0 };
|
||||
return slots.map(s => ({ day: s, ...(index.get(s) ?? zero) }));
|
||||
}
|
||||
|
||||
_initCharts() {
|
||||
if (!window.Chart || !this._stats) return;
|
||||
this._destroyCharts();
|
||||
|
||||
const dark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
|
||||
const gridColor = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.06)';
|
||||
const textColor = dark ? '#adb5bd' : '#6c757d';
|
||||
const isHour = this._statsRange === 'hour';
|
||||
|
||||
const filled = this._fillGaps(this._stats.daily);
|
||||
const days = filled.map(d => d.day);
|
||||
const req = filled.map(d => d.requests);
|
||||
const inp = filled.map(d => d.input_tokens);
|
||||
const out = filled.map(d => d.output_tokens);
|
||||
const cache = filled.map(d => d.cache_read_tokens);
|
||||
// null for empty slots so the latency line doesn't touch zero where there were no requests
|
||||
const lat = filled.map(d => d.requests > 0 ? Math.round(d.avg_duration_ms) : null);
|
||||
const models = this._stats.models;
|
||||
|
||||
const axisDefaults = () => ({
|
||||
ticks: { color: textColor, font: { size: 11 } },
|
||||
grid: { color: gridColor },
|
||||
border: { color: gridColor },
|
||||
});
|
||||
|
||||
const xAxis = () => ({
|
||||
...axisDefaults(),
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { size: 11 },
|
||||
maxTicksLimit: isHour ? 7 : 15,
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const baseOpts = (extraPlugins = {}) => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 300 },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
...extraPlugins,
|
||||
},
|
||||
scales: {
|
||||
x: xAxis(),
|
||||
y: { ...axisDefaults(), beginAtZero: true },
|
||||
},
|
||||
});
|
||||
|
||||
const barDs = (data, color) => ({
|
||||
data, backgroundColor: color, borderRadius: 4, borderSkipped: false,
|
||||
});
|
||||
|
||||
const lineDs = (data, borderColor, bgColor, opts = {}) => ({
|
||||
data, borderColor, backgroundColor: bgColor,
|
||||
fill: true, tension: 0.3, pointRadius: 0, borderWidth: 2,
|
||||
...opts,
|
||||
});
|
||||
|
||||
const type = isHour ? 'line' : 'bar';
|
||||
const get = id => this.querySelector(`#${id}`);
|
||||
|
||||
// Requests
|
||||
const c1 = get('chart-requests');
|
||||
if (c1) this._chartInstances.requests = new Chart(c1, {
|
||||
type,
|
||||
data: {
|
||||
labels: days,
|
||||
datasets: [isHour
|
||||
? lineDs(req, '#3b82f6', 'rgba(59,130,246,0.12)')
|
||||
: barDs(req, '#3b82f6')],
|
||||
},
|
||||
options: baseOpts(),
|
||||
});
|
||||
|
||||
// Tokens
|
||||
const c2 = get('chart-tokens');
|
||||
if (c2) this._chartInstances.tokens = new Chart(c2, {
|
||||
type,
|
||||
data: {
|
||||
labels: days,
|
||||
datasets: isHour ? [
|
||||
lineDs(inp, '#3b82f6', 'rgba(59,130,246,0)', { fill: false, label: 'Input' }),
|
||||
lineDs(out, '#10b981', 'rgba(16,185,129,0)', { fill: false, label: 'Output' }),
|
||||
lineDs(cache, '#f59e0b', 'rgba(245,158,11,0)', { fill: false, label: 'Cached' }),
|
||||
] : (() => {
|
||||
const nonCached = inp.map((v, i) => Math.max(0, v - (cache[i] ?? 0)));
|
||||
return [
|
||||
{ label: 'Cached', data: cache, backgroundColor: '#f59e0b', stack: 'tok', borderSkipped: false },
|
||||
{ label: 'Non-cached', data: nonCached, backgroundColor: '#3b82f6', stack: 'tok', borderSkipped: false },
|
||||
{ label: 'Output', data: out, backgroundColor: '#10b981', stack: 'tok', borderRadius: 4, borderSkipped: false },
|
||||
];
|
||||
})(),
|
||||
},
|
||||
options: baseOpts({
|
||||
legend: {
|
||||
display: true,
|
||||
labels: { color: textColor, boxWidth: 10, font: { size: 11 } },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
footer(items) {
|
||||
const idx = items[0]?.dataIndex;
|
||||
const total = inp[idx] ?? 0;
|
||||
if (!total) return '';
|
||||
const pct = Math.round((cache[idx] ?? 0) / total * 100);
|
||||
return `Cache hit: ${pct}%`;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Latency
|
||||
const c3 = get('chart-latency');
|
||||
if (c3) this._chartInstances.latency = new Chart(c3, {
|
||||
type,
|
||||
data: {
|
||||
labels: days,
|
||||
datasets: [isHour
|
||||
? lineDs(lat, '#8b5cf6', 'rgba(139,92,246,0.12)', { spanGaps: false })
|
||||
: barDs(lat.map(v => v ?? 0), '#8b5cf6')],
|
||||
},
|
||||
options: baseOpts(),
|
||||
});
|
||||
|
||||
// Models — always horizontal bar
|
||||
const c4 = get('chart-models');
|
||||
if (c4) this._chartInstances.models = new Chart(c4, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: models.map(m => this._shortModelName(m.model_name)),
|
||||
datasets: [{
|
||||
data: models.map(m => m.requests),
|
||||
backgroundColor: ['#3b82f6','#10b981','#f59e0b','#8b5cf6','#ef4444','#06b6d4'],
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
...baseOpts(),
|
||||
indexAxis: 'y',
|
||||
scales: {
|
||||
x: { ...axisDefaults(), beginAtZero: true },
|
||||
y: { ...axisDefaults(), ticks: { color: textColor, font: { size: 10 } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderStats() {
|
||||
if (this._stats === null) {
|
||||
return html`<div class="home-stats-loading"><i class="bi bi-hourglass-split"></i> Loading stats…</div>`;
|
||||
}
|
||||
|
||||
const empty = this._stats.daily.length === 0 && this._stats.models.length === 0;
|
||||
if (empty) {
|
||||
return html`
|
||||
<div class="home-stats-empty">
|
||||
<i class="bi bi-bar-chart"></i>
|
||||
<span>No LLM requests in the selected range.</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="home-stats-grid">
|
||||
<div class="home-stat-card">
|
||||
<div class="home-stat-card-title">Requests ${this._periodLabel}</div>
|
||||
<div class="home-stat-canvas-wrap"><canvas id="chart-requests"></canvas></div>
|
||||
</div>
|
||||
<div class="home-stat-card">
|
||||
<div class="home-stat-card-title">Tokens ${this._periodLabel}</div>
|
||||
<div class="home-stat-canvas-wrap"><canvas id="chart-tokens"></canvas></div>
|
||||
</div>
|
||||
<div class="home-stat-card">
|
||||
<div class="home-stat-card-title">Avg latency (ms)</div>
|
||||
<div class="home-stat-canvas-wrap"><canvas id="chart-latency"></canvas></div>
|
||||
</div>
|
||||
<div class="home-stat-card">
|
||||
<div class="home-stat-card-title">Models</div>
|
||||
<div class="home-stat-canvas-wrap"><canvas id="chart-models"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const st = this._statusInfo;
|
||||
const noModels = this._models !== null && this._models.length === 0;
|
||||
const approvals = this._inboxData?.approvals ?? [];
|
||||
const clarifs = this._inboxData?.clarifications ?? [];
|
||||
const elicits = this._inboxData?.elicitations ?? [];
|
||||
const inboxTotal = approvals.length + clarifs.length + elicits.length;
|
||||
|
||||
return html`
|
||||
<div class="home-page">
|
||||
|
||||
<!-- ── Debug toggle ── -->
|
||||
<div class="home-debug-bar">
|
||||
<label class="home-debug-toggle" title="${this._debugMode ? 'Debug mode on' : 'Debug mode off'}">
|
||||
<i class="bi bi-bug-fill"></i>
|
||||
<span>Debug</span>
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
.checked=${this._debugMode}
|
||||
@change=${this._toggleDebugMode}
|
||||
?disabled=${this._debugLoading} />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- ── Hero ── -->
|
||||
<div class="home-hero">
|
||||
<div class="home-hero-image">
|
||||
<img src="/assets/icons/icon-1024.png" alt="Skald" />
|
||||
</div>
|
||||
<div class="home-hero-text">
|
||||
<h1 class="home-hero-title">Skald</h1>
|
||||
<p class="home-hero-desc">Your AI command centre — research, code, plan, and orchestrate. All in one place.</p>
|
||||
<div class="home-hero-status home-hero-status--${st.cls}">
|
||||
${st.dot ? html`<span class="home-hero-dot"></span>` : nothing}
|
||||
${st.icon ? html`<i class="bi ${st.icon}"></i>` : nothing}
|
||||
<span>${st.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── No-models banner ── -->
|
||||
${noModels ? html`
|
||||
<div class="home-banner home-banner--error">
|
||||
<div class="home-banner-icon"><i class="bi bi-cpu-fill"></i></div>
|
||||
<div class="home-banner-body">
|
||||
<strong>No LLM models configured.</strong>
|
||||
Start by adding a provider (Anthropic, OpenAI, OpenRouter…), then add at least one model in the Models section.
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" @click=${() => this._nav('providers')}>
|
||||
Add a provider
|
||||
</button>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<!-- ── LLM Stats ── -->
|
||||
<div class="home-section-title">
|
||||
<i class="bi bi-bar-chart-fill"></i>
|
||||
<span>LLM Stats</span>
|
||||
<div class="home-stats-range ms-auto">
|
||||
${[['hour','1h'],['day','24h'],['week','7d'],['month','30d']].map(([r, label]) => html`
|
||||
<button class="home-stats-range-btn ${this._statsRange === r ? 'active' : ''}"
|
||||
@click=${() => this._setRange(r)}>${label}</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
${this._renderStats()}
|
||||
|
||||
<!-- ── Pending inbox ── -->
|
||||
<div class="home-section-title">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<span>Pending</span>
|
||||
${inboxTotal > 0 ? html`<span class="badge bg-danger">${inboxTotal}</span>` : nothing}
|
||||
<button class="inbox-refresh-btn ms-auto" title="Refresh" @click=${() => this._loadInbox()}>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
${this._renderInboxSection()}
|
||||
|
||||
<!-- ── Honcho tip ── -->
|
||||
${!this._honchoActive ? html`
|
||||
<div class="home-tip">
|
||||
<div class="home-tip-icon"><i class="bi bi-lightbulb-fill"></i></div>
|
||||
<div class="home-tip-body">
|
||||
<strong>Enable Honcho</strong>
|
||||
<span>Persistent long-term memory — the agent learns your preferences over time. Ask the Copilot to enable it.</span>
|
||||
</div>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<!-- ── Quick guide ── -->
|
||||
<div class="home-section-title">
|
||||
<i class="bi bi-map"></i>
|
||||
<span>Quick guide</span>
|
||||
</div>
|
||||
<div class="home-guide">
|
||||
${GUIDE.map(s => html`
|
||||
<div class="home-card" style="--home-card-color: ${s.color}">
|
||||
<div class="home-card-icon">
|
||||
<i class="bi ${s.icon}"></i>
|
||||
</div>
|
||||
<div class="home-card-body">
|
||||
<h6 class="home-card-title">${s.title}</h6>
|
||||
<p class="home-card-desc">${s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
function emptyForm(firstTypeId = '') {
|
||||
return { name: '', type: firstTypeId, api_key: '', base_url: '', description: '' };
|
||||
}
|
||||
|
||||
export class LlmProvidersPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_providers: { state: true },
|
||||
_providerTypes: { state: true },
|
||||
_modelCounts: { state: true },
|
||||
_modal: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
_form: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._providers = [];
|
||||
this._providerTypes = [];
|
||||
this._modelCounts = {};
|
||||
this._modal = null;
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
this._form = emptyForm();
|
||||
}
|
||||
|
||||
_typeMeta(typeId) {
|
||||
return this._providerTypes.find(t => t.type_id === typeId) ?? { display_name: typeId, color: '#888', icon: 'bi-box', fields: [] };
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === 'providers';
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._load();
|
||||
});
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [typesRes, provRes, modelsRes] = await Promise.all([
|
||||
fetch('/api/llm/providers/types'),
|
||||
fetch('/api/llm/providers'),
|
||||
fetch('/api/llm/models'),
|
||||
]);
|
||||
if (!typesRes.ok) throw new Error(`Provider types: HTTP ${typesRes.status}`);
|
||||
if (!provRes.ok) throw new Error(`Providers: HTTP ${provRes.status}`);
|
||||
if (!modelsRes.ok) throw new Error(`Models: HTTP ${modelsRes.status}`);
|
||||
|
||||
const providerTypes = await typesRes.json();
|
||||
const providers = await provRes.json();
|
||||
const models = await modelsRes.json();
|
||||
|
||||
const counts = {};
|
||||
for (const m of models) {
|
||||
const pid = String(m.provider_id);
|
||||
counts[pid] = (counts[pid] || 0) + 1;
|
||||
}
|
||||
|
||||
this._providerTypes = providerTypes;
|
||||
this._providers = providers;
|
||||
this._modelCounts = counts;
|
||||
|
||||
// Set default form type to first available provider type
|
||||
if (!this._form.type && providerTypes.length > 0) {
|
||||
this._form = { ...this._form, type: providerTypes[0].type_id };
|
||||
}
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_openAdd() {
|
||||
this._error = null;
|
||||
this._form = emptyForm(this._providerTypes[0]?.type_id ?? '');
|
||||
this._modal = { mode: 'add' };
|
||||
}
|
||||
|
||||
async _openEdit(provider) {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/llm/providers/${provider.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const record = await res.json();
|
||||
this._form = {
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
api_key: record.api_key ?? '',
|
||||
base_url: record.base_url ?? '',
|
||||
description: record.description ?? '',
|
||||
};
|
||||
this._modal = { mode: 'edit', id: record.id };
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _delete(provider) {
|
||||
if (!confirm(`Delete provider "${provider.name}"? All associated models will be deleted too.`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/llm/providers/${provider.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _onSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
|
||||
const f = this._form;
|
||||
const meta = this._typeMeta(f.type);
|
||||
const needsBaseUrl = meta.fields.some(field => field.key === 'base_url');
|
||||
const payload = {
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
api_key: f.api_key || null,
|
||||
base_url: needsBaseUrl ? (f.base_url || null) : null,
|
||||
description: f.description || null,
|
||||
};
|
||||
|
||||
const isEdit = this._modal?.mode === 'edit';
|
||||
const url = isEdit ? `/api/llm/providers/${this._modal.id}` : '/api/llm/providers';
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: isEdit ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
_setField(field, value) {
|
||||
this._form = { ...this._form, [field]: value };
|
||||
}
|
||||
|
||||
_closeModal() { this._modal = null; this._error = null; }
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────
|
||||
|
||||
_renderCard(p) {
|
||||
const meta = this._typeMeta(p.type);
|
||||
const color = meta.color;
|
||||
const icon = meta.icon;
|
||||
const label = meta.display_name;
|
||||
const count = this._modelCounts[String(p.id)];
|
||||
const hasKey = Boolean(p.api_key);
|
||||
const needsUrl = meta.fields.some(f => f.key === 'base_url');
|
||||
|
||||
return html`
|
||||
<div class="pv-card" style="--pv-color: ${color}">
|
||||
<div class="pv-card-row1">
|
||||
<div class="pv-card-icon" style="background: color-mix(in srgb, ${color} 14%, transparent); color: ${color}">
|
||||
<i class="bi ${icon}"></i>
|
||||
</div>
|
||||
<span class="pv-card-name">${p.name}</span>
|
||||
<span class="pv-card-type-badge">${label}</span>
|
||||
${count != null ? html`
|
||||
<span class="pv-card-count" title="Models using this provider">
|
||||
<i class="bi bi-cpu me-1"></i>${count}
|
||||
</span>
|
||||
` : ''}
|
||||
<div class="pv-card-actions">
|
||||
<button class="pv-btn-icon pv-btn-edit" title="Edit" @click=${() => this._openEdit(p)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="pv-btn-icon pv-btn-delete" title="Delete" @click=${() => this._delete(p)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${p.description ? html`
|
||||
<div class="pv-card-row2">
|
||||
<span class="pv-card-desc">${p.description}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="pv-card-row3">
|
||||
<span class="pv-card-tag ${hasKey ? 'pv-tag-ok' : 'pv-tag-missing'}">
|
||||
<i class="bi ${hasKey ? 'bi-lock-fill' : 'bi-unlock'}"></i>
|
||||
API key ${hasKey ? 'configured' : 'missing'}
|
||||
</span>
|
||||
${needsUrl && p.base_url ? html`
|
||||
<span class="pv-card-tag pv-tag-url" title="Base URL">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
<span class="pv-card-url-text">${p.base_url}</span>
|
||||
</span>
|
||||
` : ''}
|
||||
${p.created_at ? html`
|
||||
<span class="pv-card-tag pv-tag-date">
|
||||
<i class="bi bi-calendar3"></i>
|
||||
${new Date(p.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderModal() {
|
||||
const isEdit = this._modal?.mode === 'edit';
|
||||
const f = this._form;
|
||||
const meta = this._typeMeta(f.type);
|
||||
const needsKey = meta.fields.some(field => field.key === 'api_key');
|
||||
const needsUrl = meta.fields.some(field => field.key === 'base_url');
|
||||
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog pv-modal">
|
||||
<div class="pv-modal-header">
|
||||
<i class="bi bi-plug"></i>
|
||||
<span>${isEdit ? 'Edit Provider' : 'Add Provider'}</span>
|
||||
<button type="button" class="pv-modal-close" @click=${() => this._closeModal()}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
|
||||
<form @submit=${(e) => this._onSubmit(e)}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.name} required
|
||||
placeholder="e.g. My Anthropic" @input=${(e) => this._setField('name', e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Type</label>
|
||||
<select class="form-select form-select-sm" .value=${f.type}
|
||||
@change=${(e) => this._setField('type', e.target.value)}>
|
||||
${this._providerTypes.map(t => html`<option value=${t.type_id}>${t.display_name}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
${needsKey ? html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">API Key</label>
|
||||
<input type="password" class="form-control form-control-sm" .value=${f.api_key}
|
||||
autocomplete="new-password"
|
||||
placeholder=${isEdit ? 'Leave blank to keep existing key' : ''}
|
||||
@input=${(e) => this._setField('api_key', e.target.value)} />
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${needsUrl ? html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Base URL</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.base_url}
|
||||
placeholder=${f.type === 'ollama' ? 'http://localhost:11434' : 'http://localhost:1234/v1'}
|
||||
@input=${(e) => this._setField('base_url', e.target.value)} />
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Description <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.description}
|
||||
@input=${(e) => this._setField('description', e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div class="pv-modal-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving
|
||||
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
|
||||
: html`<i class="bi bi-check-lg me-1"></i>${isEdit ? 'Save changes' : 'Add provider'}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Main render ───────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="pv-page">
|
||||
<div class="pv-header">
|
||||
<h2 class="pv-title">
|
||||
<i class="bi bi-plug me-2"></i>Providers
|
||||
</h2>
|
||||
<div class="pv-header-right">
|
||||
<span class="pv-header-count">${this._providers.length}</span>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error && !this._modal ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : ''}
|
||||
|
||||
<div class="pv-card-list">
|
||||
${this._providers.length === 0 ? html`
|
||||
<div class="pv-empty">
|
||||
<i class="bi bi-plug"></i>
|
||||
<p>No providers configured yet.</p>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add your first provider
|
||||
</button>
|
||||
</div>
|
||||
` : this._providers.map(p => this._renderCard(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._modal ? this._renderModal() : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement, renderMarkdown } from '../lib/base.js';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function fmtTokens(n) {
|
||||
if (n == null) return '—';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function cacheHitPct(item) {
|
||||
if (item.cache_read_tokens == null || !item.input_tokens) return '—';
|
||||
return (item.cache_read_tokens / item.input_tokens * 100).toFixed(0) + '%';
|
||||
}
|
||||
|
||||
function cacheTooltip(item) {
|
||||
const parts = [];
|
||||
if (item.cache_read_tokens != null) parts.push(`read: ${item.cache_read_tokens.toLocaleString()} tk`);
|
||||
if (item.cache_creation_tokens != null) parts.push(`write: ${item.cache_creation_tokens.toLocaleString()} tk`);
|
||||
return parts.length ? parts.join(' | ') : '';
|
||||
}
|
||||
|
||||
function parseJson(str) {
|
||||
if (!str) return null;
|
||||
try { return JSON.parse(str); } catch { return null; }
|
||||
}
|
||||
|
||||
function extractSystem(req) {
|
||||
if (!req) return null;
|
||||
// Anthropic: top-level system field
|
||||
if (req.system != null) {
|
||||
if (typeof req.system === 'string') return req.system;
|
||||
if (Array.isArray(req.system)) {
|
||||
return req.system.map(b => (typeof b === 'string' ? b : b.text ?? '')).join('\n\n');
|
||||
}
|
||||
}
|
||||
// OpenAI: only the very first message if it is role=system
|
||||
if (Array.isArray(req.messages) && req.messages[0]?.role === 'system') {
|
||||
return typeof req.messages[0].content === 'string' ? req.messages[0].content : '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractMessages(req) {
|
||||
if (!req || !Array.isArray(req.messages)) return [];
|
||||
let msgs = req.messages;
|
||||
// For OpenAI format: skip the first message if it was already shown as the System Prompt
|
||||
if (!req.system && msgs[0]?.role === 'system') msgs = msgs.slice(1);
|
||||
// All remaining messages are kept, including mid-conversation role=system inserts
|
||||
return msgs;
|
||||
}
|
||||
|
||||
function extractParams(req) {
|
||||
if (!req) return [];
|
||||
const skip = new Set(['model', 'messages', 'system', 'tools', 'tool_choice', 'stream']);
|
||||
return Object.entries(req)
|
||||
.filter(([k]) => !skip.has(k))
|
||||
.map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : String(v)]);
|
||||
}
|
||||
|
||||
function extractTools(req) {
|
||||
return req?.tools ?? [];
|
||||
}
|
||||
|
||||
function extractRespBlocks(resp) {
|
||||
if (!resp) return [];
|
||||
if (Array.isArray(resp.content)) return resp.content; // Anthropic
|
||||
const msg = resp?.choices?.[0]?.message;
|
||||
if (msg) return contentBlocks(msg); // OpenAI — reuse helper (handles reasoning_content, tool_calls)
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractRespMeta(resp) {
|
||||
if (!resp) return [];
|
||||
const pairs = [];
|
||||
const skip = new Set(['content', 'choices', 'type', 'role', 'object', 'usage']);
|
||||
for (const [k, v] of Object.entries(resp)) {
|
||||
if (skip.has(k)) continue;
|
||||
pairs.push([k, typeof v === 'object' ? JSON.stringify(v) : String(v)]);
|
||||
}
|
||||
// OpenAI: finish_reason lives inside choices
|
||||
const choice = resp?.choices?.[0];
|
||||
if (choice?.finish_reason && !resp.stop_reason) {
|
||||
pairs.push(['finish_reason', choice.finish_reason]);
|
||||
}
|
||||
// usage — flatten sub-keys
|
||||
if (resp.usage && typeof resp.usage === 'object') {
|
||||
for (const [k, v] of Object.entries(resp.usage)) {
|
||||
if (v != null) pairs.push([`usage.${k}`, typeof v === 'object' ? JSON.stringify(v) : String(v)]);
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function paramsPreview(input) {
|
||||
if (!input || Object.keys(input).length === 0) return '';
|
||||
const str = JSON.stringify(input);
|
||||
return str.length > 80 ? str.slice(0, 77) + '…' : str;
|
||||
}
|
||||
|
||||
function normalizeToolResultContent(block) {
|
||||
if (Array.isArray(block.content))
|
||||
return block.content.map(b => b.text ?? JSON.stringify(b)).join('\n');
|
||||
if (typeof block.content === 'string') return block.content;
|
||||
return JSON.stringify(block.content ?? '');
|
||||
}
|
||||
|
||||
function buildToolResultMap(msgs) {
|
||||
const map = new Map(); // tool_use_id → { content, is_error }
|
||||
for (const msg of msgs) {
|
||||
// Anthropic format: tool_result blocks inside user message content
|
||||
for (const block of contentBlocks(msg)) {
|
||||
if (block.type === 'tool_result') {
|
||||
map.set(block.tool_use_id, {
|
||||
content: normalizeToolResultContent(block),
|
||||
is_error: !!block.is_error,
|
||||
});
|
||||
}
|
||||
}
|
||||
// OpenAI format: role='tool' messages carry the result directly
|
||||
if (msg.role === 'tool' && msg.tool_call_id) {
|
||||
const content = typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: (Array.isArray(msg.content) ? msg.content.map(b => b.text ?? JSON.stringify(b)).join('\n') : JSON.stringify(msg.content ?? ''));
|
||||
map.set(msg.tool_call_id, { content, is_error: false });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function contentBlocks(msg) {
|
||||
const blocks = [];
|
||||
if (typeof msg.reasoning_content === 'string' && msg.reasoning_content) {
|
||||
blocks.push({ type: 'reasoning', text: msg.reasoning_content });
|
||||
}
|
||||
if (msg.content) {
|
||||
if (typeof msg.content === 'string') blocks.push({ type: 'text', text: msg.content });
|
||||
else if (Array.isArray(msg.content)) blocks.push(...msg.content);
|
||||
}
|
||||
// OpenAI format: tool calls live in tool_calls[], not in content
|
||||
if (Array.isArray(msg.tool_calls)) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
let input = {};
|
||||
try { input = JSON.parse(tc.function?.arguments ?? '{}'); } catch { /* ignore */ }
|
||||
blocks.push({ type: 'tool_use', id: tc.id, name: tc.function?.name ?? '?', input });
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export class LlmRequestDetail extends LightElement {
|
||||
static properties = {
|
||||
detailId: { type: Number },
|
||||
_detail: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
_openSections: { state: true },
|
||||
_expandedTools: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.detailId = null;
|
||||
this._detail = null;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._openSections = new Set(['response']);
|
||||
this._expandedTools = new Set();
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (changed.has('detailId') && this.detailId != null) {
|
||||
this._detail = null;
|
||||
this._error = null;
|
||||
this._openSections = new Set(['response']);
|
||||
this._expandedTools = new Set();
|
||||
this._fetch(this.detailId);
|
||||
}
|
||||
}
|
||||
|
||||
async _fetch(id) {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/dev/llm-requests/${id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._detail = await res.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_back() {
|
||||
this.dispatchEvent(new CustomEvent('detail-back', { bubbles: true }));
|
||||
}
|
||||
|
||||
_toggleSection(name) {
|
||||
const next = new Set(this._openSections);
|
||||
next.has(name) ? next.delete(name) : next.add(name);
|
||||
this._openSections = next;
|
||||
}
|
||||
|
||||
_toggleToolExpand(key) {
|
||||
const next = new Set(this._expandedTools);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
this._expandedTools = next;
|
||||
}
|
||||
|
||||
// ── Section wrapper ─────────────────────────────────────────────────────────
|
||||
|
||||
_renderSection(id, title, content, badge = null) {
|
||||
const open = this._openSections.has(id);
|
||||
return html`
|
||||
<div class="llmr-section ${open ? 'llmr-section--open' : ''}">
|
||||
<div class="llmr-section-header" @click=${() => this._toggleSection(id)}>
|
||||
<span class="llmr-section-chevron">
|
||||
<i class="bi bi-chevron-${open ? 'down' : 'right'}"></i>
|
||||
</span>
|
||||
<span class="llmr-section-title">${title}</span>
|
||||
${badge != null ? html`<span class="llmr-section-badge">${badge}</span>` : nothing}
|
||||
</div>
|
||||
<div class="llmr-section-body">
|
||||
${content}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Key-value table ──────────────────────────────────────────────────────────
|
||||
|
||||
_renderKvTable(pairs) {
|
||||
if (!pairs || pairs.length === 0) return html`<p class="text-secondary small mb-0">—</p>`;
|
||||
return html`
|
||||
<table class="llmr-kv-table">
|
||||
<tbody>
|
||||
${pairs.map(([k, v]) => html`
|
||||
<tr>
|
||||
<td class="llmr-kv-key">${k}</td>
|
||||
<td class="llmr-kv-val">${v}</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Stat bar ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderStatBar(d) {
|
||||
return html`
|
||||
<div class="llmr-detail-statbar">
|
||||
<span class="llmr-badge-agent">${d.agent_id ?? 'no agent'}</span>
|
||||
<span class="llmr-badge-source">${d.source ?? '—'}</span>
|
||||
<span class="llmr-detail-model">${d.model_name}</span>
|
||||
${d.stack_id != null ? html`<span class="llmr-detail-pill llmr-detail-pill--stack">stack #${d.stack_id}</span>` : nothing}
|
||||
<span class="llmr-detail-sep"></span>
|
||||
<span class="llmr-detail-stat" title="Input tokens">
|
||||
<i class="bi bi-arrow-up-circle"></i> ${fmtTokens(d.input_tokens)}
|
||||
</span>
|
||||
<span class="llmr-detail-stat" title="Output tokens">
|
||||
<i class="bi bi-arrow-down-circle"></i> ${fmtTokens(d.output_tokens)}
|
||||
</span>
|
||||
${d.cache_read_tokens > 0 ? html`
|
||||
<span class="llmr-detail-stat llmr-detail-stat--cache" title=${cacheTooltip(d)}>
|
||||
<i class="bi bi-lightning-charge"></i> cache ${cacheHitPct(d)}
|
||||
</span>
|
||||
` : nothing}
|
||||
<span class="llmr-detail-stat">
|
||||
<i class="bi bi-clock"></i> ${d.duration_ms} ms
|
||||
</span>
|
||||
<span class="llmr-detail-date">${formatDate(d.created_at)}</span>
|
||||
${d.error_text ? html`
|
||||
<span class="llmr-detail-error-badge" title=${d.error_text}>
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> error
|
||||
</span>
|
||||
` : nothing}
|
||||
</div>
|
||||
${d.error_text ? html`
|
||||
<div class="llmr-detail-error-box">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> ${d.error_text}
|
||||
</div>
|
||||
` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Content blocks ───────────────────────────────────────────────────────────
|
||||
|
||||
// toolResultMap: Map<tool_use_id, {content, is_error}> — null when not available
|
||||
_renderContentBlock(block, keyPrefix, toolResultMap = null) {
|
||||
if (!block) return nothing;
|
||||
const type = block.type;
|
||||
|
||||
if (type === 'reasoning') {
|
||||
const key = `${keyPrefix}-reasoning`;
|
||||
const open = this._expandedTools.has(key);
|
||||
const text = block.text ?? '';
|
||||
return html`
|
||||
<div class="llmr-reasoning-block">
|
||||
<div class="llmr-reasoning-header" @click=${() => this._toggleToolExpand(key)}>
|
||||
<i class="bi bi-lightbulb"></i>
|
||||
<span>reasoning</span>
|
||||
<span class="llmr-tool-toggle ms-auto">
|
||||
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
|
||||
</span>
|
||||
</div>
|
||||
${open ? html`<pre class="llmr-text-block llmr-reasoning-body">${text}</pre>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === 'text') {
|
||||
const text = block.text ?? '';
|
||||
if (!text) return nothing;
|
||||
return html`<pre class="llmr-text-block">${text}</pre>`;
|
||||
}
|
||||
|
||||
if (type === 'tool_use') {
|
||||
const key = `${keyPrefix}-use-${block.id ?? block.name}`;
|
||||
const open = this._expandedTools.has(key);
|
||||
const args = block.input != null ? JSON.stringify(block.input, null, 2) : '{}';
|
||||
const preview = paramsPreview(block.input);
|
||||
const result = toolResultMap?.get(block.id);
|
||||
return html`
|
||||
<div class="llmr-tool-block llmr-tool-block--use ${result?.is_error ? 'llmr-tool-block--error' : ''}">
|
||||
<div class="llmr-tool-block-header" @click=${() => this._toggleToolExpand(key)}>
|
||||
<i class="bi bi-wrench"></i>
|
||||
<span class="llmr-tool-name">${block.name}</span>
|
||||
${preview ? html`<span class="llmr-tool-preview">${preview}</span>` : nothing}
|
||||
<span class="llmr-tool-toggle ms-auto">
|
||||
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
|
||||
</span>
|
||||
</div>
|
||||
${open ? html`
|
||||
<div class="llmr-tool-expanded">
|
||||
<div class="llmr-tool-section-label">Parameters</div>
|
||||
<pre class="llmr-tool-pre">${args}</pre>
|
||||
${result != null ? html`
|
||||
<div class="llmr-tool-section-label llmr-tool-section-label--result">
|
||||
Result ${result.is_error ? html`<span class="badge bg-danger ms-1">error</span>` : nothing}
|
||||
</div>
|
||||
<pre class="llmr-tool-pre">${result.content}</pre>
|
||||
` : nothing}
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// tool_result: only rendered when there is no toolResultMap (i.e. not in conversation context)
|
||||
if (type === 'tool_result') {
|
||||
if (toolResultMap != null) return nothing; // shown inline inside the tool_use block
|
||||
const key = `${keyPrefix}-result-${block.tool_use_id}`;
|
||||
const open = this._expandedTools.has(key);
|
||||
const content = normalizeToolResultContent(block);
|
||||
return html`
|
||||
<div class="llmr-tool-block llmr-tool-block--result ${block.is_error ? 'llmr-tool-block--error' : ''}">
|
||||
<div class="llmr-tool-block-header" @click=${() => this._toggleToolExpand(key)}>
|
||||
<i class="bi bi-arrow-return-left"></i>
|
||||
<span class="llmr-tool-name">result</span>
|
||||
<span class="llmr-tool-id">${block.tool_use_id ?? ''}</span>
|
||||
${block.is_error ? html`<span class="badge bg-danger ms-1">error</span>` : nothing}
|
||||
<span class="llmr-tool-toggle ms-auto">
|
||||
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
|
||||
</span>
|
||||
</div>
|
||||
${open ? html`<pre class="llmr-tool-pre">${content}</pre>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`<pre class="llmr-tool-pre">${JSON.stringify(block, null, 2)}</pre>`;
|
||||
}
|
||||
|
||||
_renderMessage(msg, idx, toolResultMap) {
|
||||
const role = msg.role ?? 'unknown';
|
||||
const blocks = contentBlocks(msg);
|
||||
|
||||
// skip messages that are entirely tool results (shown inline inside the tool_use block)
|
||||
// Anthropic: role=user messages whose content is all tool_result blocks
|
||||
// OpenAI: role=tool messages
|
||||
if (role === 'tool') return nothing;
|
||||
if (role === 'user' && blocks.length > 0 && blocks.every(b => b.type === 'tool_result')) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
// mid-conversation system prompt: render with markdown and a distinct style
|
||||
if (role === 'system') {
|
||||
const text = typeof msg.content === 'string' ? msg.content : '';
|
||||
if (!text) return nothing;
|
||||
return html`
|
||||
<div class="llmr-msg llmr-msg--system">
|
||||
<div class="llmr-msg-role"><i class="bi bi-shield-lock-fill"></i> system</div>
|
||||
<div class="llmr-msg-body">
|
||||
<div class="llmr-system-md copilot-markdown">${unsafeHTML(renderMarkdown(text))}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="llmr-msg llmr-msg--${role}">
|
||||
<div class="llmr-msg-role">${role}</div>
|
||||
<div class="llmr-msg-body">
|
||||
${blocks.map((b, bi) => this._renderContentBlock(b, `msg-${idx}-${bi}`, toolResultMap))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderResponseBlock(block, idx) {
|
||||
if (!block) return nothing;
|
||||
// Delegate to _renderContentBlock for shared block types (reasoning, text, tool_use, tool_result)
|
||||
return this._renderContentBlock(block, `resp-${idx}`);
|
||||
}
|
||||
|
||||
// ── Main render ──────────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
if (this._loading) return html`
|
||||
<div class="llmr-page">
|
||||
<div class="llmr-detail-back">
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}>
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</button>
|
||||
</div>
|
||||
<div class="llmr-state">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
<span>Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this._error) return html`
|
||||
<div class="llmr-page">
|
||||
<div class="llmr-detail-back">
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}>
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</button>
|
||||
</div>
|
||||
<div class="llmr-state llmr-state--error">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
<span>${this._error}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!this._detail) return nothing;
|
||||
|
||||
const d = this._detail;
|
||||
const req = parseJson(d.request_json);
|
||||
const resp = parseJson(d.response_json);
|
||||
const hdrs = parseJson(d.request_headers);
|
||||
const respHdrs = parseJson(d.response_headers);
|
||||
const system = extractSystem(req);
|
||||
const msgs = extractMessages(req);
|
||||
const params = extractParams(req);
|
||||
const tools = extractTools(req);
|
||||
|
||||
const payloadMissing = !req && !resp;
|
||||
const respBlocks = extractRespBlocks(resp);
|
||||
const respMeta = extractRespMeta(resp);
|
||||
const toolResultMap = buildToolResultMap(msgs);
|
||||
|
||||
return html`
|
||||
<div class="llmr-page">
|
||||
<div class="llmr-detail-back">
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}>
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</button>
|
||||
<span class="llmr-detail-title">
|
||||
<i class="bi bi-journal-code"></i> Request <span class="llmr-detail-id">#${d.id}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${this._renderStatBar(d)}
|
||||
|
||||
${payloadMissing ? html`
|
||||
<div class="llmr-purged-banner">
|
||||
<i class="bi bi-hourglass-split"></i>
|
||||
Payload not available — this request has been purged by the retention policy.
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
${hdrs ? this._renderSection('req-headers', 'Request Headers',
|
||||
this._renderKvTable(Object.entries(hdrs))
|
||||
) : nothing}
|
||||
|
||||
${respHdrs ? this._renderSection('resp-headers', 'Response Headers',
|
||||
this._renderKvTable(Object.entries(respHdrs))
|
||||
) : nothing}
|
||||
|
||||
${params.length ? this._renderSection('params', 'Parameters',
|
||||
this._renderKvTable(params)
|
||||
) : nothing}
|
||||
|
||||
${system ? this._renderSection('system', 'System Prompt',
|
||||
html`<div class="llmr-system-md copilot-markdown">${unsafeHTML(renderMarkdown(system))}</div>`
|
||||
) : nothing}
|
||||
|
||||
${msgs.length ? this._renderSection('conversation', 'Conversation',
|
||||
html`<div class="llmr-msg-list">
|
||||
${msgs.map((m, i) => this._renderMessage(m, i, toolResultMap))}
|
||||
</div>`,
|
||||
msgs.length
|
||||
) : nothing}
|
||||
|
||||
${tools.length ? this._renderSection('tools', 'Tools Defined',
|
||||
html`<div class="llmr-tool-def-list">
|
||||
${tools.map((t, i) => {
|
||||
// Anthropic: { name, description, input_schema }
|
||||
// OpenAI: { type: 'function', function: { name, description, parameters } }
|
||||
const name = t.name ?? t.function?.name ?? '(unknown)';
|
||||
const desc = t.description ?? t.function?.description ?? '';
|
||||
const schema = t.input_schema ?? t.function?.parameters ?? null;
|
||||
const key = `tooldef-${i}-${name}`;
|
||||
const open = this._expandedTools.has(key);
|
||||
return html`
|
||||
<div class="llmr-tool-def ${open ? 'llmr-tool-def--open' : ''}">
|
||||
<div class="llmr-tool-def-header" @click=${() => this._toggleToolExpand(key)}>
|
||||
<i class="bi bi-wrench" style="color:#0891b2;font-size:.75rem"></i>
|
||||
<span class="llmr-tool-name">${name}</span>
|
||||
<span class="llmr-tool-def-desc">${desc}</span>
|
||||
${schema ? html`
|
||||
<span class="llmr-tool-toggle ms-auto">
|
||||
<i class="bi bi-${open ? 'dash' : 'plus'}-circle"></i>
|
||||
</span>
|
||||
` : nothing}
|
||||
</div>
|
||||
${open && schema ? html`
|
||||
<pre class="llmr-tool-pre">${JSON.stringify(schema, null, 2)}</pre>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>`,
|
||||
tools.length
|
||||
) : nothing}
|
||||
|
||||
${resp ? this._renderSection('response', 'Response',
|
||||
html`
|
||||
${respMeta.length ? this._renderKvTable(respMeta) : nothing}
|
||||
<div class="llmr-msg-list llmr-msg-list--resp">
|
||||
${respBlocks.map((b, i) => this._renderResponseBlock(b, i))}
|
||||
</div>
|
||||
`
|
||||
) : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
const PAGE_ID = 'llm-requests';
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function fmtTokens(n) {
|
||||
if (n == null) return '—';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function cacheHitPct(item) {
|
||||
if (item.cache_read_tokens == null || !item.input_tokens) return '—';
|
||||
return (item.cache_read_tokens / item.input_tokens * 100).toFixed(0) + '%';
|
||||
}
|
||||
|
||||
function cacheTooltip(item) {
|
||||
const parts = [];
|
||||
if (item.cache_read_tokens != null) parts.push(`read: ${item.cache_read_tokens.toLocaleString()} tk`);
|
||||
if (item.cache_creation_tokens != null) parts.push(`write: ${item.cache_creation_tokens.toLocaleString()} tk`);
|
||||
return parts.length ? parts.join(' | ') : '';
|
||||
}
|
||||
|
||||
export class LlmRequestsPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_items: { state: true },
|
||||
_total: { state: true },
|
||||
_page: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
_agentId: { state: true },
|
||||
_source: { state: true },
|
||||
_from: { state: true },
|
||||
_to: { state: true },
|
||||
_applied: { state: true },
|
||||
_detailId: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._items = [];
|
||||
this._total = 0;
|
||||
this._page = 1;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._agentId = '';
|
||||
this._source = '';
|
||||
this._from = '';
|
||||
this._to = '';
|
||||
this._applied = {};
|
||||
this._detailId = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) {
|
||||
const id = this._idFromHash();
|
||||
this._detailId = id;
|
||||
if (id == null && this._items.length === 0) this._fetch(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_idFromHash() {
|
||||
const parts = location.hash.replace('#', '').split('/');
|
||||
if (parts[0] === PAGE_ID && parts[1]) {
|
||||
const n = Number(parts[1]);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_openDetail(id) {
|
||||
this._detailId = id;
|
||||
history.pushState({}, '', `#${PAGE_ID}/${id}`);
|
||||
}
|
||||
|
||||
_back() {
|
||||
this._detailId = null;
|
||||
history.pushState({}, '', `#${PAGE_ID}`);
|
||||
if (this._items.length === 0) this._fetch(1);
|
||||
}
|
||||
|
||||
// ── List fetching ────────────────────────────────────────────────────────────
|
||||
|
||||
async _fetch(page) {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
const params = new URLSearchParams({ page });
|
||||
if (this._agentId) params.set('agent_id', this._agentId);
|
||||
if (this._source) params.set('source', this._source);
|
||||
if (this._from) params.set('from', this._from);
|
||||
if (this._to) params.set('to', this._to);
|
||||
this._applied = { agentId: this._agentId, source: this._source, from: this._from, to: this._to };
|
||||
try {
|
||||
const res = await fetch(`/api/dev/llm-requests?${params}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
this._items = data.items;
|
||||
this._total = data.total;
|
||||
this._page = data.page;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_apply() { this._fetch(1); }
|
||||
|
||||
_reset() {
|
||||
this._agentId = '';
|
||||
this._source = '';
|
||||
this._from = '';
|
||||
this._to = '';
|
||||
this._fetch(1);
|
||||
}
|
||||
|
||||
get _totalPages() { return Math.max(1, Math.ceil(this._total / PAGE_SIZE)); }
|
||||
|
||||
// ── Renders ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderFilters() {
|
||||
return html`
|
||||
<div class="llmr-filters">
|
||||
<div class="llmr-filter-group">
|
||||
<label class="llmr-filter-label">Agent ID</label>
|
||||
<input class="form-control form-control-sm" type="text"
|
||||
placeholder="e.g. main"
|
||||
.value=${this._agentId}
|
||||
@input=${e => this._agentId = e.target.value}
|
||||
@keydown=${e => e.key === 'Enter' && this._apply()} />
|
||||
</div>
|
||||
<div class="llmr-filter-group">
|
||||
<label class="llmr-filter-label">Source</label>
|
||||
<input class="form-control form-control-sm" type="text"
|
||||
placeholder="e.g. web, tic, cron"
|
||||
.value=${this._source}
|
||||
@input=${e => this._source = e.target.value}
|
||||
@keydown=${e => e.key === 'Enter' && this._apply()} />
|
||||
</div>
|
||||
<div class="llmr-filter-group">
|
||||
<label class="llmr-filter-label">From</label>
|
||||
<input class="form-control form-control-sm" type="date"
|
||||
.value=${this._from}
|
||||
@change=${e => this._from = e.target.value} />
|
||||
</div>
|
||||
<div class="llmr-filter-group">
|
||||
<label class="llmr-filter-label">To</label>
|
||||
<input class="form-control form-control-sm" type="date"
|
||||
.value=${this._to}
|
||||
@change=${e => this._to = e.target.value} />
|
||||
</div>
|
||||
<div class="llmr-filter-actions">
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._apply()}
|
||||
?disabled=${this._loading}>
|
||||
Apply
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._reset()}
|
||||
?disabled=${this._loading}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderTable() {
|
||||
if (this._loading) return html`
|
||||
<div class="llmr-state">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
<span>Loading…</span>
|
||||
</div>
|
||||
`;
|
||||
if (this._error) return html`
|
||||
<div class="llmr-state llmr-state--error">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
<span>${this._error}</span>
|
||||
</div>
|
||||
`;
|
||||
if (this._items.length === 0) return html`
|
||||
<div class="llmr-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<span>No requests found.</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="llmr-table-wrap">
|
||||
<table class="table table-sm llmr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Source</th>
|
||||
<th>Model</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end">In tokens</th>
|
||||
<th class="text-end">Out tokens</th>
|
||||
<th class="text-end">Cache hit</th>
|
||||
<th class="text-end">ms</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this._items.map(r => html`
|
||||
<tr class="${r.error_text ? 'llmr-row--error' : ''} llmr-row--clickable"
|
||||
@click=${() => this._openDetail(r.id)}>
|
||||
<td><span class="llmr-badge-agent">${r.agent_id ?? '—'}</span></td>
|
||||
<td><span class="llmr-badge-source">${r.source ?? '—'}</span></td>
|
||||
<td class="llmr-model">${r.model_name}</td>
|
||||
<td class="llmr-date">${formatDate(r.created_at)}</td>
|
||||
<td class="text-end llmr-num">${fmtTokens(r.input_tokens)}</td>
|
||||
<td class="text-end llmr-num">${fmtTokens(r.output_tokens)}</td>
|
||||
<td class="text-end llmr-num ${r.cache_read_tokens > 0 ? 'llmr-cache-hit' : ''}"
|
||||
title=${cacheTooltip(r)}>
|
||||
${cacheHitPct(r)}
|
||||
</td>
|
||||
<td class="text-end llmr-num">${r.duration_ms}</td>
|
||||
</tr>
|
||||
${r.error_text ? html`
|
||||
<tr class="llmr-row--error-detail">
|
||||
<td colspan="8">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> ${r.error_text}
|
||||
</td>
|
||||
</tr>
|
||||
` : nothing}
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderPagination() {
|
||||
if (this._totalPages <= 1) return nothing;
|
||||
const pages = this._totalPages;
|
||||
const cur = this._page;
|
||||
return html`
|
||||
<div class="llmr-pagination">
|
||||
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur <= 1}
|
||||
@click=${() => this._fetch(cur - 1)}>
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<span class="llmr-page-info">Page ${cur} of ${pages} — ${this._total} results</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages}
|
||||
@click=${() => this._fetch(cur + 1)}>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._detailId != null) {
|
||||
return html`
|
||||
<llm-request-detail
|
||||
.detailId=${this._detailId}
|
||||
@detail-back=${() => this._back()}>
|
||||
</llm-request-detail>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="llmr-page">
|
||||
<div class="llmr-header">
|
||||
<h2 class="llmr-title"><i class="bi bi-journal-code"></i> LLM Requests</h2>
|
||||
<span class="llmr-total-badge">${this._total} rows</span>
|
||||
</div>
|
||||
${this._renderFilters()}
|
||||
${this._renderTable()}
|
||||
${this._renderPagination()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
import './shared/inbox-page.js';
|
||||
import './shared/chat-page.js';
|
||||
import './shared/projects-page.js';
|
||||
import './shared/file-viewer-mobile.js';
|
||||
|
||||
// Sections addressable via the URL hash — same routing style as the desktop
|
||||
// sidebar (web/components/sidebar.js). The native iOS shell and mobile browsers
|
||||
// share this router so the URL always reflects the active section: native menu
|
||||
// sync, deep links, and back/refresh restoration all flow from one place.
|
||||
// `file_viewer` is not a tab — it's opened from content (a clickable tool path
|
||||
// via openFile() → `#file_viewer?path=...`) and so has no bottom-nav entry.
|
||||
const VALID_SECTIONS = ['inbox', 'projects', 'chat', 'notifications', 'settings', 'file_viewer'];
|
||||
|
||||
class MobileApp extends LitElement {
|
||||
// No shadow DOM — lets external CSS and Bootstrap Icons apply directly.
|
||||
createRenderRoot() { return this; }
|
||||
|
||||
static properties = {
|
||||
_section: { state: true },
|
||||
// Source the chat is bound to: 'mobile' (main) or 'project-{id}'.
|
||||
_chatSource: { state: true },
|
||||
// Label shown in the chat header when inside a project.
|
||||
_chatLabel: { state: true },
|
||||
// File shown by the file_viewer section (from `#file_viewer?path=...`).
|
||||
_filePath: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._section = 'chat';
|
||||
this._chatSource = 'mobile';
|
||||
this._chatLabel = '';
|
||||
this._filePath = null;
|
||||
// id → name cache, so a cold deep-link (#chat/project-<id> opened by the
|
||||
// native shell) can resolve its header label without the project list open.
|
||||
this._projectLabels = {};
|
||||
// Native shell mode (?native=true): the HTML bottom nav is hidden — a native
|
||||
// tab bar drives navigation via location.hash. Mark the host so CSS can drop
|
||||
// the safe-area insets the native chrome already provides.
|
||||
this._native = new URLSearchParams(location.search).get('native') === 'true';
|
||||
if (this._native) this.setAttribute('data-native', '');
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._onHashChange = () => this._applyHash();
|
||||
window.addEventListener('hashchange', this._onHashChange);
|
||||
window.addEventListener('popstate', this._onHashChange);
|
||||
// Default route when no hash is present (replaceState: no history entry).
|
||||
if (!location.hash) history.replaceState(null, '', '#chat');
|
||||
this._applyHash();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
window.removeEventListener('popstate', this._onHashChange);
|
||||
}
|
||||
|
||||
// ── Hash routing ───────────────────────────────────────────────────────────
|
||||
|
||||
// { section, projectId, filePath } parsed from location.hash. Forms:
|
||||
// #projects → section 'projects'
|
||||
// #chat → section 'chat' (main mobile session)
|
||||
// #chat/project-<id> → section 'chat' bound to a project's session
|
||||
// #file_viewer?path=<enc> → section 'file_viewer' showing a file
|
||||
_readHash() {
|
||||
const raw = location.hash.slice(1);
|
||||
if (!raw) return { section: 'chat', projectId: null, filePath: null };
|
||||
// Segment ends at the first `/` (project sub-route) or `?` (query, e.g. the
|
||||
// file viewer's `?path=`).
|
||||
const cut = raw.search(/[/?]/);
|
||||
const seg = cut === -1 ? raw : raw.slice(0, cut);
|
||||
const section = VALID_SECTIONS.includes(seg) ? seg : 'chat';
|
||||
if (section === 'file_viewer') {
|
||||
let filePath = null;
|
||||
const m = raw.match(/[?&]path=([^&]*)/);
|
||||
if (m) { try { filePath = decodeURIComponent(m[1]); } catch { /* keep null */ } }
|
||||
return { section, projectId: null, filePath };
|
||||
}
|
||||
const slash = raw.indexOf('/');
|
||||
const sub = slash === -1 ? '' : raw.slice(slash + 1);
|
||||
let projectId = null;
|
||||
if (section === 'chat' && sub.startsWith('project-')) {
|
||||
projectId = sub.slice('project-'.length) || null;
|
||||
}
|
||||
return { section, projectId, filePath: null };
|
||||
}
|
||||
|
||||
_applyHash() {
|
||||
const { section, projectId, filePath } = this._readHash();
|
||||
this._section = section;
|
||||
this._filePath = filePath;
|
||||
if (projectId) {
|
||||
const source = 'project-' + projectId;
|
||||
if (this._chatSource !== source) this._chatSource = source;
|
||||
this._resolveLabel(projectId);
|
||||
} else if (section === 'chat') {
|
||||
if (this._chatSource !== 'mobile') this._chatSource = 'mobile';
|
||||
this._chatLabel = '';
|
||||
}
|
||||
// Tell the native shell which section is active. For the file viewer we also
|
||||
// pass the document path, so the iOS "Doc" tab can highlight itself and
|
||||
// remember the last opened file (re-opened when the user taps Doc again).
|
||||
this._notifyNative(
|
||||
section,
|
||||
projectId ? 'project-' + projectId : null,
|
||||
section === 'file_viewer' ? filePath : null,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the display label for a project id (shown in the chat header). Cached
|
||||
// in _projectLabels; fetched once from /api/projects when first needed (e.g. a
|
||||
// cold native deep-link), then served from cache on every subsequent switch.
|
||||
async _resolveLabel(projectId) {
|
||||
if (this._projectLabels[projectId] != null) {
|
||||
this._chatLabel = this._projectLabels[projectId];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
for (const p of await res.json()) this._projectLabels[p.id] = p.name;
|
||||
} catch { /* keep whatever label we have */ }
|
||||
this._chatLabel = this._projectLabels[projectId] ?? projectId;
|
||||
}
|
||||
|
||||
_nav(section) {
|
||||
const target = '#' + section;
|
||||
// Setting the hash to the current value fires no event; only change when it
|
||||
// differs (also covers exiting a project sub-route via the chat tab).
|
||||
if (location.hash !== target) location.hash = target;
|
||||
}
|
||||
|
||||
// A project was tapped in the projects list: re-point the chat to its source
|
||||
// and push the full project route so back/refresh keep the user in the project.
|
||||
_onProjectOpen(e) {
|
||||
const { source, label } = e.detail ?? {};
|
||||
if (!source || !source.startsWith('project-')) return;
|
||||
const id = source.slice('project-'.length);
|
||||
if (label) this._projectLabels[id] = label;
|
||||
location.hash = '#chat/' + source;
|
||||
}
|
||||
|
||||
// Back-out from a project chat: re-point to the main mobile session.
|
||||
_onProjectExit() {
|
||||
location.hash = '#chat';
|
||||
}
|
||||
|
||||
// ── Native bridge ──────────────────────────────────────────────────────────
|
||||
|
||||
// Web → Native: tell the iOS shell which section/project is active so it can
|
||||
// highlight the matching native tab. `path` is only set for the file viewer
|
||||
// (the document being shown). No-op outside WKWebView — the `skaldNav` message
|
||||
// handler only exists when the shell registered it.
|
||||
_notifyNative(section, project, path = null) {
|
||||
try {
|
||||
window.webkit?.messageHandlers?.skaldNav?.postMessage({ section, project, path });
|
||||
} catch { /* not in native shell */ }
|
||||
}
|
||||
|
||||
render() {
|
||||
const s = this._section;
|
||||
const item = (id, icon, label, extraClass = '') => html`
|
||||
<div class="mobile-nav-item ${extraClass} ${s === id ? 'active' : ''}"
|
||||
@click=${() => this._nav(id)}>
|
||||
${id === 'chat'
|
||||
? html`<div class="chat-fab"><i class="bi bi-chat-dots-fill"></i></div>`
|
||||
: html`<i class="bi ${icon}"></i>`}
|
||||
<span>${label}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div id="mobile-root">
|
||||
<div class="mobile-content">
|
||||
<inbox-page
|
||||
.visible=${s === 'inbox'}
|
||||
style=${s === 'inbox' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
|
||||
></inbox-page>
|
||||
<chat-page
|
||||
.visible=${s === 'chat'}
|
||||
.source=${this._chatSource}
|
||||
.label=${this._chatLabel}
|
||||
@project-exit=${() => this._onProjectExit()}
|
||||
style=${s === 'chat' ? 'flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column' : 'display:none'}
|
||||
></chat-page>
|
||||
<projects-page
|
||||
.visible=${s === 'projects'}
|
||||
@project-open=${(e) => this._onProjectOpen(e)}
|
||||
style=${s === 'projects' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
|
||||
></projects-page>
|
||||
<mobile-file-viewer-page
|
||||
.visible=${s === 'file_viewer'}
|
||||
.path=${this._filePath}
|
||||
style=${s === 'file_viewer' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
|
||||
></mobile-file-viewer-page>
|
||||
${['notifications', 'settings'].includes(s) ? html`
|
||||
<div class="mobile-coming-soon">
|
||||
<i class="bi bi-tools"></i>
|
||||
<p>Coming soon</p>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${this._native ? nothing : html`
|
||||
<nav class="mobile-nav">
|
||||
${item('inbox', 'bi-inbox', 'Inbox')}
|
||||
${item('projects', 'bi-folder2-open', 'Projects')}
|
||||
${item('chat', '', 'Chat', 'chat-btn')}
|
||||
${item('notifications', 'bi-bell', 'Alerts')}
|
||||
${item('settings', 'bi-sliders', 'Settings')}
|
||||
</nav>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('mobile-app', MobileApp);
|
||||
@@ -0,0 +1,142 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
id: 'llm',
|
||||
icon: 'bi-cpu',
|
||||
title: 'LLM',
|
||||
desc: 'Chat & completion models for agents and tools',
|
||||
},
|
||||
{
|
||||
id: 'transcribe',
|
||||
icon: 'bi-mic',
|
||||
title: 'Transcription',
|
||||
desc: 'Speech-to-text models via cloud or local plugin',
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
icon: 'bi-image',
|
||||
title: 'Image Generation',
|
||||
desc: 'Text-to-image models via cloud API',
|
||||
},
|
||||
{
|
||||
id: 'tts',
|
||||
icon: 'bi-volume-up',
|
||||
title: 'Text-to-Speech',
|
||||
desc: 'Speech synthesis models via cloud or local plugin',
|
||||
},
|
||||
];
|
||||
|
||||
export class ModelsHubPage extends LightElement {
|
||||
static properties = {
|
||||
_section: { state: true },
|
||||
_counts: { state: true },
|
||||
_loading: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._section = null;
|
||||
this._counts = { llm: 0, transcribe: 0, image: 0, tts: 0 };
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
const open = e.detail.page === 'models';
|
||||
this.style.display = open ? 'flex' : 'none';
|
||||
if (open) {
|
||||
this._section = this._sectionFromHash();
|
||||
if (!this._section) this._loadCounts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_sectionFromHash() {
|
||||
const parts = location.hash.slice(1).split('/');
|
||||
if (parts[0] === 'models' && parts[1]) {
|
||||
return ['llm', 'transcribe', 'image', 'tts'].includes(parts[1]) ? parts[1] : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async _loadCounts() {
|
||||
this._loading = true;
|
||||
try {
|
||||
const [llmRes, tRes, igRes, ttsRes] = await Promise.all([
|
||||
fetch('/api/llm/models'),
|
||||
fetch('/api/transcribe/models'),
|
||||
fetch('/api/image-generate/models'),
|
||||
fetch('/api/tts/models'),
|
||||
]);
|
||||
const [llm, transcribe, image, tts] = await Promise.all([
|
||||
llmRes.ok ? llmRes.json() : [],
|
||||
tRes.ok ? tRes.json() : [],
|
||||
igRes.ok ? igRes.json() : [],
|
||||
ttsRes.ok ? ttsRes.json() : [],
|
||||
]);
|
||||
this._counts = {
|
||||
llm: llm.length,
|
||||
transcribe: transcribe.length,
|
||||
image: image.length,
|
||||
tts: tts.length,
|
||||
};
|
||||
} catch {
|
||||
// counts stay at 0 on error — non-critical
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_openSection(id) {
|
||||
this._section = id;
|
||||
history.pushState({ page: 'models', section: id }, '', `#models/${id}`);
|
||||
}
|
||||
|
||||
_goBack() {
|
||||
this._section = null;
|
||||
this._loadCounts();
|
||||
history.replaceState({ page: 'models' }, '', '#models');
|
||||
}
|
||||
|
||||
_countLabel(id) {
|
||||
const n = this._counts[id] ?? 0;
|
||||
return n === 0 ? 'No models' : n === 1 ? '1 model' : `${n} models`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._section) {
|
||||
return html`
|
||||
${this._section === 'llm' ? html`<models-llm-section .onback=${() => this._goBack()}></models-llm-section>` : ''}
|
||||
${this._section === 'transcribe' ? html`<models-transcribe-section .onback=${() => this._goBack()}></models-transcribe-section>` : ''}
|
||||
${this._section === 'image' ? html`<models-image-section .onback=${() => this._goBack()}></models-image-section>` : ''}
|
||||
${this._section === 'tts' ? html`<models-tts-section .onback=${() => this._goBack()}></models-tts-section>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="models-hub">
|
||||
<h2 class="llm-page-title">Models</h2>
|
||||
<p class="text-muted" style="font-size:0.88rem;margin-top:0.25rem">
|
||||
Configure LLM, transcription, and image generation providers.
|
||||
</p>
|
||||
<div class="models-hub-grid">
|
||||
${CARDS.map(card => html`
|
||||
<button class="models-type-card" @click=${() => this._openSection(card.id)}>
|
||||
<div class="models-type-card-icon">
|
||||
<i class="bi ${card.icon}"></i>
|
||||
</div>
|
||||
<div class="models-type-card-title">${card.title}</div>
|
||||
<div class="models-type-card-desc">${card.desc}</div>
|
||||
<div class="models-type-card-count ${this._counts[card.id] > 0 ? 'has-models' : ''}">
|
||||
${this._loading ? '…' : this._countLabel(card.id)}
|
||||
</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
function emptyIgForm() {
|
||||
return { provider_id: '', model_id: '', name: '', priority: 100 };
|
||||
}
|
||||
|
||||
export class ModelsImageSection extends LightElement {
|
||||
static properties = {
|
||||
onback: { attribute: false },
|
||||
_models: { state: true },
|
||||
_providers: { state: true },
|
||||
_modal: { state: true },
|
||||
_form: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
_provider: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.onback = null;
|
||||
this._models = [];
|
||||
this._providers = [];
|
||||
this._modal = null;
|
||||
this._form = emptyIgForm();
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
this._provider = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._load();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [modelsRes, providersRes] = await Promise.all([
|
||||
fetch('/api/image-generate/models'),
|
||||
fetch('/api/llm/providers'),
|
||||
]);
|
||||
if (!modelsRes.ok) throw new Error(`models: HTTP ${modelsRes.status}`);
|
||||
if (!providersRes.ok) throw new Error(`providers: HTTP ${providersRes.status}`);
|
||||
this._models = await modelsRes.json();
|
||||
this._providers = await providersRes.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_openAdd() {
|
||||
this._error = null;
|
||||
this._provider = null;
|
||||
this._form = emptyIgForm();
|
||||
this._modal = 'pick-provider';
|
||||
}
|
||||
|
||||
_pickProvider(provider) {
|
||||
this._provider = provider;
|
||||
this._form = { ...emptyIgForm(), provider_id: provider.id };
|
||||
this._modal = 'add';
|
||||
}
|
||||
|
||||
// ── Edit flow ────────────────────────────────────────────────────────────────
|
||||
|
||||
async _openEdit(m) {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/image-generate/models/${m.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const r = await res.json();
|
||||
this._provider = this._providers.find(p => p.id === r.provider_id) ?? null;
|
||||
this._form = {
|
||||
provider_id: r.provider_id,
|
||||
model_id: r.model_id,
|
||||
name: r.name,
|
||||
priority: r.priority,
|
||||
};
|
||||
this._modal = { mode: 'edit', id: r.id, name: r.name };
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async _delete(m) {
|
||||
if (!confirm(`Delete image model "${m.name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/image-generate/models/${m.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit add ───────────────────────────────────────────────────────────────
|
||||
|
||||
async _submitAdd(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
const f = this._form;
|
||||
try {
|
||||
const res = await fetch('/api/image-generate/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: Number(f.provider_id),
|
||||
model_id: f.model_id,
|
||||
name: f.name || f.model_id,
|
||||
priority: Number(f.priority) || 100,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit edit ──────────────────────────────────────────────────────────────
|
||||
|
||||
async _submitEdit(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
const f = this._form;
|
||||
const id = this._modal.id;
|
||||
try {
|
||||
const res = await fetch(`/api/image-generate/models/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: Number(f.provider_id),
|
||||
model_id: f.model_id,
|
||||
name: f.name || f.model_id,
|
||||
priority: Number(f.priority) || 100,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
_closeModal() { this._modal = null; this._error = null; }
|
||||
|
||||
// ── Render card ──────────────────────────────────────────────────────────────
|
||||
|
||||
_renderCard(m) {
|
||||
const isPlugin = m.from_plugin;
|
||||
return html`
|
||||
<div class="llm-card">
|
||||
<div class="llm-card-row1">
|
||||
${isPlugin
|
||||
? html`<span class="ig-source-badge ig-source-plugin">Plugin</span>`
|
||||
: html`<span class="ig-source-badge ig-source-cloud">Cloud</span>`}
|
||||
<span class="llm-card-name">${m.name}</span>
|
||||
<div class="llm-card-actions">
|
||||
${isPlugin ? html`
|
||||
<span class="llm-btn-icon" title="Managed by plugin" style="cursor:default;opacity:0.4">
|
||||
<i class="bi bi-lock"></i>
|
||||
</span>
|
||||
` : html`
|
||||
<button class="llm-btn-icon llm-btn-edit" title="Edit" @click=${() => this._openEdit(m)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="llm-btn-icon llm-btn-delete" title="Delete" @click=${() => this._delete(m)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-card-row2">
|
||||
${!isPlugin ? html`<span class="llm-provider-name">${m.provider_name}</span>` : ''}
|
||||
<span class="llm-model-id">${isPlugin ? m.model_id || m.id : m.model_id}</span>
|
||||
<span class="ig-priority-tag" title="Priority">#${m.priority}</span>
|
||||
</div>
|
||||
|
||||
${m.description ? html`
|
||||
<div class="ig-card-desc">${m.description}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: pick provider ──────────────────────────────────────────────────────
|
||||
|
||||
_renderPickProvider() {
|
||||
const igProviders = this._providers.filter(p =>
|
||||
Array.isArray(p.supported_types) && p.supported_types.includes('image_generate')
|
||||
);
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">Add Image Model — Choose Provider</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<div class="llm-provider-grid">
|
||||
${igProviders.map(p => html`
|
||||
<button class="llm-provider-card" @click=${() => this._pickProvider(p)}>
|
||||
<div class="llm-provider-card-name">${p.name}</div>
|
||||
<div class="llm-provider-card-type text-muted" style="font-size:0.75rem">${p.type}</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="agent-dialog-actions mt-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: add / edit form ────────────────────────────────────────────────────
|
||||
|
||||
_renderForm(isEdit = false) {
|
||||
const f = this._form;
|
||||
const p = this._provider;
|
||||
const title = isEdit
|
||||
? html`Edit <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.name}</span>`
|
||||
: html`Add Image Model <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>`;
|
||||
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">${title}</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<form @submit=${(e) => isEdit ? this._submitEdit(e) : this._submitAdd(e)}>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Model ID <span class="text-muted fw-normal">(sent to API)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required
|
||||
placeholder="e.g. x-ai/grok-2-vision"
|
||||
?disabled=${isEdit}
|
||||
@input=${(e) => this._form = { ...this._form, model_id: e.target.value }} />
|
||||
${isEdit ? html`<div class="form-text">Model ID cannot be changed after creation.</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Name / Alias <span class="text-muted fw-normal">(used as provider_id in the LLM tool)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.name}
|
||||
placeholder=${f.model_id || 'same as model ID'}
|
||||
@input=${(e) => this._form = { ...this._form, name: e.target.value }} />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label>
|
||||
<input type="number" class="form-control form-control-sm" .value=${String(f.priority)} min="1"
|
||||
@input=${(e) => this._form = { ...this._form, priority: e.target.value }} />
|
||||
<div class="form-text">Lower number = tried first. Default: 100.</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-dialog-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving ? 'Saving…' : isEdit ? 'Save changes' : 'Add model'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const igProviders = this._providers.filter(p =>
|
||||
Array.isArray(p.supported_types) && p.supported_types.includes('image_generate')
|
||||
);
|
||||
const canAdd = igProviders.length > 0;
|
||||
|
||||
return html`
|
||||
<div class="llm-page">
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
${this.onback ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back to models" @click=${this.onback}>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<div>
|
||||
<h2 class="llm-page-title">Image Generation Models</h2>
|
||||
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} ?disabled=${!canAdd}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${!canAdd ? html`
|
||||
<div class="agent-info-banner">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-0">No provider supports image generation yet. Add an <strong>OpenRouter</strong> provider first.</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._models.some(m => m.from_plugin) ? html`
|
||||
<div class="agent-info-banner">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-0">Models with the <strong>Plugin</strong> badge are read-only — managed automatically by the plugin that registered them.
|
||||
To add, modify, or remove them, ask the agent directly: it has all the documentation it needs.</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._error && !this._modal ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : ''}
|
||||
|
||||
<div class="llm-card-list">
|
||||
${this._models.length === 0 ? html`
|
||||
<div class="llm-empty-state">
|
||||
<i class="bi bi-image"></i>
|
||||
<p>No image generation models configured.</p>
|
||||
${canAdd ? html`
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add your first model
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
` : this._models.map(m => this._renderCard(m))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._modal === 'pick-provider' ? this._renderPickProvider() : ''}
|
||||
${this._modal === 'add' ? this._renderForm(false) : ''}
|
||||
${this._modal?.mode === 'edit' ? this._renderForm(true) : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
const STRENGTH_COLORS = {
|
||||
very_high: '#ef4444',
|
||||
high: '#f97316',
|
||||
average: '#eab308',
|
||||
low: '#84cc16',
|
||||
very_low: '#22c55e',
|
||||
};
|
||||
|
||||
const STRENGTH_LABELS = {
|
||||
very_high: 'Very High',
|
||||
high: 'High',
|
||||
average: 'Average',
|
||||
low: 'Low',
|
||||
very_low: 'Very Low',
|
||||
};
|
||||
|
||||
const STRENGTH_OPTIONS = ['very_low', 'low', 'average', 'high', 'very_high'];
|
||||
const SCOPE_OPTIONS = ['coding', 'writing', 'reasoning', 'math', 'basic', 'search'];
|
||||
|
||||
function emptyMeta() {
|
||||
// `reasoning` is the selected reasoning value: a string for a ValueSet mode,
|
||||
// a number for a Range mode, or null (off). Interpreted per provider.
|
||||
return { strength: '', scope: [], priority: 100, is_default: false, reasoning: null };
|
||||
}
|
||||
|
||||
function emptyOrForm() {
|
||||
return {
|
||||
model_id: '',
|
||||
name: '',
|
||||
max_tokens: '',
|
||||
...emptyMeta(),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyDefaultForm() {
|
||||
return { model_id: '', name: '', extra_params: '', ...emptyMeta() };
|
||||
}
|
||||
|
||||
export class ModelsLlmSection extends LightElement {
|
||||
static properties = {
|
||||
onback: { attribute: false },
|
||||
_models: { state: true },
|
||||
_providers: { state: true },
|
||||
_modal: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
_orModels: { state: true },
|
||||
_orLoading: { state: true },
|
||||
_orSearch: { state: true },
|
||||
_orForm: { state: true },
|
||||
_form: { state: true },
|
||||
_pickedProvider: { state: true },
|
||||
_reasoningMode: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.onback = null;
|
||||
this._models = [];
|
||||
this._providers = [];
|
||||
this._modal = null;
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
this._orModels = [];
|
||||
this._orLoading = false;
|
||||
this._orSearch = '';
|
||||
this._orForm = emptyOrForm();
|
||||
this._form = emptyDefaultForm();
|
||||
this._pickedProvider = null;
|
||||
this._reasoningMode = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._load();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [modelsRes, providersRes] = await Promise.all([
|
||||
fetch('/api/llm/models'),
|
||||
fetch('/api/llm/providers'),
|
||||
]);
|
||||
if (!modelsRes.ok) throw new Error(`models: HTTP ${modelsRes.status}`);
|
||||
if (!providersRes.ok) throw new Error(`providers: HTTP ${providersRes.status}`);
|
||||
this._models = await modelsRes.json();
|
||||
this._providers = await providersRes.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Move up/down ─────────────────────────────────────────────────────────────
|
||||
|
||||
async _moveUp(i) {
|
||||
if (i <= 0) return;
|
||||
const models = [...this._models];
|
||||
[models[i - 1], models[i]] = [models[i], models[i - 1]];
|
||||
this._models = models;
|
||||
await this._savePriorities();
|
||||
}
|
||||
|
||||
async _moveDown(i) {
|
||||
if (i >= this._models.length - 1) return;
|
||||
const models = [...this._models];
|
||||
[models[i], models[i + 1]] = [models[i + 1], models[i]];
|
||||
this._models = models;
|
||||
await this._savePriorities();
|
||||
}
|
||||
|
||||
async _savePriorities() {
|
||||
try {
|
||||
await Promise.all(this._models.map((m, i) =>
|
||||
fetch(`/api/llm/models/${m.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// A PUT overwrites every column, so carry through all preserved
|
||||
// fields (catalog metadata + reasoning) — omitting them would wipe
|
||||
// them on reorder.
|
||||
body: JSON.stringify({
|
||||
provider_id: m.provider_id,
|
||||
model_id: m.model_id,
|
||||
name: m.name,
|
||||
strength: m.strength ?? null,
|
||||
scope: m.scope,
|
||||
is_default: m.is_default,
|
||||
priority: (i + 1) * 10,
|
||||
extra_params: m.extra_params ?? null,
|
||||
context_length: m.context_length ?? null,
|
||||
max_output_tokens: m.max_output_tokens ?? null,
|
||||
knowledge_cutoff: m.knowledge_cutoff ?? null,
|
||||
capabilities: m.capabilities ?? null,
|
||||
reasoning: m.reasoning ?? null,
|
||||
}),
|
||||
})
|
||||
));
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = `Failed to save order: ${e.message}`;
|
||||
await this._load();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_openAdd() {
|
||||
this._error = null;
|
||||
this._pickedProvider = null;
|
||||
this._modal = 'provider-pick';
|
||||
}
|
||||
|
||||
async _pickProvider(provider) {
|
||||
this._error = null;
|
||||
this._pickedProvider = provider;
|
||||
this._reasoningMode = null;
|
||||
|
||||
const hasModelPicker = ['openrouter', 'ollama', 'lm_studio', 'deepseek', 'zai'].includes(provider.type);
|
||||
|
||||
if (hasModelPicker) {
|
||||
this._orForm = emptyOrForm();
|
||||
this._orSearch = '';
|
||||
this._orModels = [];
|
||||
this._modal = 'add-openrouter';
|
||||
await this._loadOrModels(provider.id);
|
||||
} else {
|
||||
this._form = { ...emptyDefaultForm(), provider_id: provider.id };
|
||||
this._modal = 'add-default';
|
||||
}
|
||||
}
|
||||
|
||||
async _loadOrModels(providerId) {
|
||||
this._orLoading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/llm/providers/${providerId}/models`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
this._orModels = await res.json();
|
||||
} catch (e) {
|
||||
this._error = `Failed to load models: ${e.message}`;
|
||||
} finally {
|
||||
this._orLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit flow ────────────────────────────────────────────────────────────────
|
||||
|
||||
async _openEdit(model) {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/llm/models/${model.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const record = await res.json();
|
||||
this._form = {
|
||||
strength: record.strength ?? '',
|
||||
scope: record.scope ?? [],
|
||||
priority: record.priority,
|
||||
is_default: record.is_default,
|
||||
provider_id: record.provider_id,
|
||||
model_id: record.model_id,
|
||||
name: record.name,
|
||||
extra_params: record.extra_params ? JSON.stringify(record.extra_params) : '',
|
||||
reasoning: record.reasoning ?? null,
|
||||
// Preserved so the PUT doesn't wipe catalog metadata (see _submitEdit).
|
||||
context_length: record.context_length ?? null,
|
||||
max_output_tokens: record.max_output_tokens ?? null,
|
||||
knowledge_cutoff: record.knowledge_cutoff ?? null,
|
||||
capabilities: record.capabilities ?? null,
|
||||
};
|
||||
// The reasoning control descriptor lives on the list item (LlmModelInfo).
|
||||
this._modal = {
|
||||
mode: 'edit', id: record.id, name: record.name, model_id: record.model_id,
|
||||
reasoning_mode: model.reasoning_mode ?? null,
|
||||
};
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async _delete(model) {
|
||||
if (!confirm(`Delete model "${model.name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/llm/models/${model.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit: add default ──────────────────────────────────────────────────────
|
||||
|
||||
async _submitDefault(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
|
||||
const f = this._form;
|
||||
let extra_params = null;
|
||||
if (f.extra_params && f.extra_params.trim()) {
|
||||
try { extra_params = JSON.parse(f.extra_params); }
|
||||
catch { this._error = 'Extra params: invalid JSON'; this._saving = false; return; }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/llm/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: Number(this._pickedProvider.id),
|
||||
model_id: f.model_id,
|
||||
name: f.name || f.model_id,
|
||||
strength: f.strength || null,
|
||||
scope: f.scope,
|
||||
is_default: f.is_default,
|
||||
priority: Number(f.priority),
|
||||
extra_params,
|
||||
reasoning: f.reasoning ?? null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit: add openrouter ───────────────────────────────────────────────────
|
||||
|
||||
async _submitCatalog(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
if (!this._orForm.model_id) { this._error = 'Select a model'; return; }
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
|
||||
const f = this._orForm;
|
||||
const extra_params = {};
|
||||
if (f.max_tokens) extra_params.max_tokens = Number(f.max_tokens);
|
||||
|
||||
const selected = this._orModels.find(m => m.id === f.model_id);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/llm/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: Number(this._pickedProvider.id),
|
||||
model_id: f.model_id,
|
||||
name: f.name || f.model_id,
|
||||
strength: f.strength || null,
|
||||
scope: f.scope,
|
||||
is_default: f.is_default,
|
||||
priority: Number(f.priority),
|
||||
extra_params: Object.keys(extra_params).length ? extra_params : null,
|
||||
context_length: selected?.context_length ?? null,
|
||||
max_output_tokens: selected?.max_completion_tokens ?? null,
|
||||
knowledge_cutoff: selected?.knowledge_cutoff ?? null,
|
||||
capabilities: selected?.capabilities?.length ? selected.capabilities : null,
|
||||
reasoning: f.reasoning ?? null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit: edit ─────────────────────────────────────────────────────────────
|
||||
|
||||
async _submitEdit(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
|
||||
const f = this._form;
|
||||
let extra_params = null;
|
||||
if (f.extra_params && f.extra_params.trim()) {
|
||||
try { extra_params = JSON.parse(f.extra_params); }
|
||||
catch { this._error = 'Extra params: invalid JSON'; this._saving = false; return; }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/llm/models/${this._modal.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: f.provider_id,
|
||||
model_id: f.model_id,
|
||||
name: f.name,
|
||||
strength: f.strength || null,
|
||||
scope: f.scope,
|
||||
is_default: f.is_default,
|
||||
priority: Number(f.priority),
|
||||
extra_params,
|
||||
reasoning: f.reasoning ?? null,
|
||||
context_length: f.context_length ?? null,
|
||||
max_output_tokens: f.max_output_tokens ?? null,
|
||||
knowledge_cutoff: f.knowledge_cutoff ?? null,
|
||||
capabilities: f.capabilities ?? null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Field helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
_setField(field, value) {
|
||||
this._form = { ...this._form, [field]: value };
|
||||
}
|
||||
|
||||
// Manual "add model" flow: the reasoning control descriptor depends on the
|
||||
// model_id, so fetch it (per provider) when the field settles.
|
||||
async _fetchDefaultReasoning(modelId) {
|
||||
this._reasoningMode = null;
|
||||
this._setField('reasoning', null);
|
||||
const pid = this._pickedProvider?.id;
|
||||
if (!pid || !modelId.trim()) return;
|
||||
try {
|
||||
const res = await fetch(`/api/llm/providers/${pid}/reasoning-mode?model_id=${encodeURIComponent(modelId)}`);
|
||||
if (res.ok) this._reasoningMode = await res.json();
|
||||
} catch { /* descriptor is optional; ignore */ }
|
||||
}
|
||||
|
||||
_setOrField(field, value) {
|
||||
this._orForm = { ...this._orForm, [field]: value };
|
||||
}
|
||||
|
||||
_toggleScope(scope, isOr = false) {
|
||||
if (isOr) {
|
||||
const s = this._orForm.scope;
|
||||
this._orForm = { ...this._orForm, scope: s.includes(scope) ? s.filter(x => x !== scope) : [...s, scope] };
|
||||
} else {
|
||||
const s = this._form.scope;
|
||||
this._form = { ...this._form, scope: s.includes(scope) ? s.filter(x => x !== scope) : [...s, scope] };
|
||||
}
|
||||
}
|
||||
|
||||
_closeModal() { this._modal = null; this._error = null; }
|
||||
|
||||
// ── Render helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
_fmtP(p) {
|
||||
if (p == null) return null;
|
||||
if (p === 0) return '$0';
|
||||
return p < 0.01 ? `$${p.toFixed(4)}` : `$${p.toFixed(3)}`;
|
||||
}
|
||||
|
||||
_renderPriceCell(model) {
|
||||
const inp = this._fmtP(model.price_input_per_million);
|
||||
const out = this._fmtP(model.price_output_per_million);
|
||||
if (!inp && !out) return html`<span style="opacity:0.3">—</span>`;
|
||||
return html`
|
||||
<span class="llm-price-tag" title="Input/Output per 1M tokens">
|
||||
${inp ?? '?'} <span style="opacity:0.45">→</span> ${out ?? '?'}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderStrengthDot(strength) {
|
||||
if (!strength) return html`<span style="opacity:0.3">—</span>`;
|
||||
return html`
|
||||
<span class="llm-strength-dot"
|
||||
style="background:${STRENGTH_COLORS[strength] ?? '#888'}"
|
||||
title=${STRENGTH_LABELS[strength] ?? strength}></span>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderStatus(status) {
|
||||
const cfg = {
|
||||
healthy: { color: '#22c55e', title: 'Healthy' },
|
||||
degraded: { color: '#eab308', title: 'Degraded' },
|
||||
down: { color: '#ef4444', title: 'Down' },
|
||||
}[status] ?? { color: '#888', title: status };
|
||||
return html`<span class="llm-strength-dot" style="background:${cfg.color}" title=${cfg.title}></span>`;
|
||||
}
|
||||
|
||||
_renderCard(m, i) {
|
||||
const first = i === 0;
|
||||
const last = i === this._models.length - 1;
|
||||
return html`
|
||||
<div class="llm-card">
|
||||
<div class="llm-card-row1">
|
||||
<div class="llm-move-btns">
|
||||
<button class="llm-move-btn" title="Move up"
|
||||
?disabled=${first}
|
||||
@click=${() => this._moveUp(i)}>
|
||||
<i class="bi bi-chevron-up"></i>
|
||||
</button>
|
||||
<button class="llm-move-btn" title="Move down"
|
||||
?disabled=${last}
|
||||
@click=${() => this._moveDown(i)}>
|
||||
<i class="bi bi-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
${this._renderStrengthDot(m.strength)}
|
||||
${this._renderStatus(m.status)}
|
||||
<span class="llm-card-name">${m.name}</span>
|
||||
${m.is_default ? html`<span class="llm-card-badge">default</span>` : ''}
|
||||
<div class="llm-card-actions">
|
||||
<button class="llm-btn-icon llm-btn-edit" title="Edit" @click=${() => this._openEdit(m)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="llm-btn-icon llm-btn-delete" title="Delete" @click=${() => this._delete(m)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-card-row2">
|
||||
<span class="llm-provider-name">${m.provider_name}</span>
|
||||
<span class="llm-model-id">${m.model_id}</span>
|
||||
${this._renderPriceCell(m)}
|
||||
</div>
|
||||
|
||||
${(m.scope ?? []).length > 0 || m.extra_params ? html`
|
||||
<div class="llm-card-row3">
|
||||
${(m.scope ?? []).map(s => html`<span class="llm-scope-pill">${s}</span>`)}
|
||||
${m.extra_params ? html`<span class="llm-scope-pill llm-params-pill" title=${JSON.stringify(m.extra_params)}>+params</span>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Data-driven reasoning control. `mode` is the provider-supplied descriptor
|
||||
// (ReasoningMode): a `value_set` (discrete choices → dropdown) or a `range`
|
||||
// (numeric budget → checkbox + number input). `form.reasoning` holds the
|
||||
// current value (string for value_set, number for range, or null = off).
|
||||
_renderReasoning(form, setField, mode) {
|
||||
if (!mode) return '';
|
||||
if (mode.type === 'value_set') {
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Reasoning</label>
|
||||
<select class="form-select form-select-sm"
|
||||
@change=${(e) => setField('reasoning', e.target.value || null)}>
|
||||
<option value="" ?selected=${form.reasoning == null}>— off —</option>
|
||||
${mode.values.map(v => html`
|
||||
<option value=${v} ?selected=${form.reasoning === v}>${v}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
// range
|
||||
const enabled = form.reasoning != null;
|
||||
return html`
|
||||
<div class="mb-3">
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="m-reasoning-on"
|
||||
.checked=${enabled}
|
||||
@change=${(e) => setField('reasoning', e.target.checked ? (mode.default ?? mode.min) : null)} />
|
||||
<label class="form-check-label fw-semibold" for="m-reasoning-on" style="font-size:0.82rem">
|
||||
Reasoning (thinking)
|
||||
</label>
|
||||
</div>
|
||||
${enabled ? html`
|
||||
<div class="d-flex align-items-center gap-2 ms-3">
|
||||
<input type="number" class="form-control form-control-sm" style="max-width:140px"
|
||||
min=${mode.min} max=${mode.max} step=${mode.step ?? 1}
|
||||
.value=${String(form.reasoning)}
|
||||
@input=${(e) => setField('reasoning', e.target.value === '' ? null : Number(e.target.value))} />
|
||||
<span class="text-muted" style="font-size:0.78rem">${mode.unit ?? ''}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderMetaFields(form, setField, toggleScope, reasoningMode) {
|
||||
return html`
|
||||
${this._renderReasoning(form, setField, reasoningMode)}
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Strength</label>
|
||||
<select class="form-select form-select-sm"
|
||||
@change=${(e) => setField('strength', e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
${STRENGTH_OPTIONS.map(s => html`
|
||||
<option value=${s} ?selected=${form.strength === s}>${STRENGTH_LABELS[s]}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label>
|
||||
<input type="number" class="form-control form-control-sm" .value=${String(form.priority)} min="1"
|
||||
@input=${(e) => setField('priority', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Scope</label>
|
||||
<div class="llm-scope-grid">
|
||||
${SCOPE_OPTIONS.map(s => html`
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="scope-${s}"
|
||||
.checked=${form.scope.includes(s)} @change=${() => toggleScope(s)} />
|
||||
<label class="form-check-label" for="scope-${s}" style="font-size:0.82rem">${s}</label>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="m-is-default"
|
||||
.checked=${form.is_default} @change=${(e) => setField('is_default', e.target.checked)} />
|
||||
<label class="form-check-label" for="m-is-default" style="font-size:0.82rem">Default model</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: provider picker ───────────────────────────────────────────────────
|
||||
|
||||
_renderProviderPick() {
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">Add Model — Choose Provider</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<div class="llm-provider-grid">
|
||||
${this._providers.filter(p => (p.supported_types ?? []).includes('llm')).map(p => html`
|
||||
<button class="llm-provider-card" @click=${() => this._pickProvider(p)}>
|
||||
<div class="llm-provider-card-name">${p.name}</div>
|
||||
<div class="llm-provider-card-type text-muted" style="font-size:0.75rem">${p.type}</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="agent-dialog-actions mt-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: add default ───────────────────────────────────────────────────────
|
||||
|
||||
_renderAddDefault() {
|
||||
const f = this._form;
|
||||
const p = this._pickedProvider;
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">
|
||||
Add Model
|
||||
<span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>
|
||||
</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<form @submit=${(e) => this._submitDefault(e)}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Model ID <span class="text-muted fw-normal">(sent to API)</span></label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required
|
||||
placeholder="e.g. gpt-4o"
|
||||
@input=${(e) => this._setField('model_id', e.target.value)}
|
||||
@change=${(e) => this._fetchDefaultReasoning(e.target.value)} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.name}
|
||||
placeholder=${f.model_id || 'same as model ID'}
|
||||
@input=${(e) => this._setField('name', e.target.value)} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Extra params <span class="text-muted fw-normal">(JSON, optional)</span>
|
||||
</label>
|
||||
<textarea class="form-control form-control-sm font-monospace" rows="3"
|
||||
.value=${f.extra_params}
|
||||
@input=${(e) => this._setField('extra_params', e.target.value)}
|
||||
style="font-size:0.78rem;resize:vertical"></textarea>
|
||||
</div>
|
||||
${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._reasoningMode)}
|
||||
<div class="agent-dialog-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving ? 'Saving…' : 'Add model'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: add model from catalog ────────────────────────────────────────────
|
||||
|
||||
_renderAddCatalog() {
|
||||
const f = this._orForm;
|
||||
const p = this._pickedProvider;
|
||||
const search = this._orSearch.toLowerCase();
|
||||
const filtered = this._orModels.filter(m =>
|
||||
!search || m.id.toLowerCase().includes(search) || m.name.toLowerCase().includes(search)
|
||||
);
|
||||
|
||||
const selected = this._orModels.find(m => m.id === f.model_id);
|
||||
const supportsMaxTokens = selected?.capabilities?.includes('max_tokens') ?? true;
|
||||
|
||||
const formatPrice = (m) => {
|
||||
const i = this._fmtP(m.price_input_per_million);
|
||||
const o = this._fmtP(m.price_output_per_million);
|
||||
if (!i && !o) return null;
|
||||
return `${i ?? '?'} → ${o ?? '?'}/M`;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal llm-modal-wide">
|
||||
<div class="llm-modal-title">
|
||||
Add Model
|
||||
<span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>
|
||||
</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
|
||||
<form @submit=${(e) => this._submitCatalog(e)}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Model</label>
|
||||
<input type="text" class="form-control form-control-sm mb-1"
|
||||
placeholder="Search models…"
|
||||
.value=${this._orSearch}
|
||||
@input=${(e) => { this._orSearch = e.target.value; }} />
|
||||
|
||||
${this._orLoading
|
||||
? html`<div class="text-muted py-2" style="font-size:0.82rem">Loading models…</div>`
|
||||
: html`
|
||||
<div class="llm-or-model-list">
|
||||
${filtered.length === 0
|
||||
? html`<div class="text-muted px-2 py-1" style="font-size:0.82rem">No models found</div>`
|
||||
: filtered.map(m => html`
|
||||
<div class="llm-or-model-row ${f.model_id === m.id ? 'selected' : ''}"
|
||||
@click=${() => this._setOrField('model_id', m.id)}>
|
||||
<div class="llm-or-model-name">${m.name}</div>
|
||||
<div class="llm-or-model-meta">
|
||||
<span class="text-muted" style="font-size:0.72rem">${m.id}</span>
|
||||
${(m.price_input_per_million != null || m.price_output_per_million != null) ? html`
|
||||
<span class="llm-or-price">${formatPrice(m)}</span>
|
||||
` : ''}
|
||||
${m.context_length ? html`
|
||||
<span class="text-muted" style="font-size:0.72rem">${(m.context_length / 1000).toFixed(0)}k ctx</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.name}
|
||||
placeholder=${selected?.name || f.model_id || 'same as model ID'}
|
||||
@input=${(e) => this._setOrField('name', e.target.value)} />
|
||||
</div>
|
||||
|
||||
${supportsMaxTokens ? html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Max output tokens <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input type="number" class="form-control form-control-sm" .value=${f.max_tokens}
|
||||
placeholder=${selected?.max_completion_tokens ? `up to ${selected.max_completion_tokens.toLocaleString()}` : ''}
|
||||
min="1"
|
||||
@input=${(e) => this._setOrField('max_tokens', e.target.value)} />
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._renderMetaFields(f, (k, v) => this._setOrField(k, v), (s) => this._toggleScope(s, true), selected?.reasoning)}
|
||||
|
||||
<div class="agent-dialog-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving || !f.model_id}>
|
||||
${this._saving ? 'Saving…' : 'Add model'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: edit ──────────────────────────────────────────────────────────────
|
||||
|
||||
_renderEdit() {
|
||||
const m = this._modal;
|
||||
const f = this._form;
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">
|
||||
Edit
|
||||
<span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${m.name}</span>
|
||||
</div>
|
||||
<p class="text-muted mb-3" style="font-size:0.8rem">
|
||||
Model ID and provider cannot be changed. To use a different model, add a new entry.
|
||||
</p>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<form @submit=${(e) => this._submitEdit(e)}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.name}
|
||||
placeholder=${m.model_id || 'model name'}
|
||||
@input=${(e) => this._setField('name', e.target.value)} />
|
||||
<div class="form-text" style="font-size:0.75rem">Used to reference this model (e.g. in an agent's <code>client</code>). Must be unique.</div>
|
||||
</div>
|
||||
${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._modal.reasoning_mode)}
|
||||
<div class="agent-dialog-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="llm-page">
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
${this.onback ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back to models" @click=${this.onback}>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<div>
|
||||
<h2 class="llm-page-title">LLM Models</h2>
|
||||
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}
|
||||
?disabled=${this._providers.length === 0}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._providers.length === 0 ? html`
|
||||
<div class="agent-info-banner">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-0">Add a <strong>Provider</strong> first, then come back here to add models.</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._error && !this._modal ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : ''}
|
||||
|
||||
<div class="llm-card-list">
|
||||
${this._models.length === 0 && this._providers.length > 0 ? html`
|
||||
<div class="llm-empty-state">
|
||||
<i class="bi bi-cpu"></i>
|
||||
<p>No models configured yet.</p>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add your first model
|
||||
</button>
|
||||
</div>
|
||||
` : this._models.map((m, i) => this._renderCard(m, i))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._modal === 'provider-pick' ? this._renderProviderPick() : ''}
|
||||
${this._modal === 'add-openrouter' ? this._renderAddCatalog() : ''}
|
||||
${this._modal === 'add-default' ? this._renderAddDefault() : ''}
|
||||
${this._modal?.mode === 'edit' ? this._renderEdit() : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
function emptyTForm() {
|
||||
return { provider_id: '', model_id: '', name: '', language: '', priority: 100 };
|
||||
}
|
||||
|
||||
export class ModelsTranscribeSection extends LightElement {
|
||||
static properties = {
|
||||
onback: { attribute: false },
|
||||
_models: { state: true },
|
||||
_providers: { state: true },
|
||||
_modal: { state: true },
|
||||
_form: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
_provider: { state: true },
|
||||
_remoteModels: { state: true },
|
||||
_loadingModels: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.onback = null;
|
||||
this._models = [];
|
||||
this._providers = [];
|
||||
this._modal = null;
|
||||
this._form = emptyTForm();
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
this._provider = null;
|
||||
this._remoteModels = null;
|
||||
this._loadingModels = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._load();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [modelsRes, providersRes] = await Promise.all([
|
||||
fetch('/api/transcribe/models'),
|
||||
fetch('/api/llm/providers'),
|
||||
]);
|
||||
if (!modelsRes.ok) throw new Error(`models: HTTP ${modelsRes.status}`);
|
||||
if (!providersRes.ok) throw new Error(`providers: HTTP ${providersRes.status}`);
|
||||
this._models = await modelsRes.json();
|
||||
this._providers = await providersRes.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_openAdd() {
|
||||
this._error = null;
|
||||
this._provider = null;
|
||||
this._form = emptyTForm();
|
||||
this._modal = 'pick-provider';
|
||||
}
|
||||
|
||||
async _pickProvider(provider) {
|
||||
this._provider = provider;
|
||||
this._remoteModels = null;
|
||||
this._form = { ...emptyTForm(), provider_id: provider.id };
|
||||
this._loadingModels = true;
|
||||
this._modal = 'pick-model';
|
||||
try {
|
||||
const res = await fetch(`/api/transcribe/providers/${provider.id}/models`);
|
||||
this._remoteModels = res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
this._remoteModels = null;
|
||||
} finally {
|
||||
this._loadingModels = false;
|
||||
if (!this._remoteModels || this._remoteModels.length === 0) {
|
||||
this._modal = 'add';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_pickRemoteModel(remote) {
|
||||
this._form = {
|
||||
...this._form,
|
||||
model_id: remote.id,
|
||||
name: remote.name,
|
||||
};
|
||||
this._modal = 'add';
|
||||
}
|
||||
|
||||
// ── Edit flow ────────────────────────────────────────────────────────────────
|
||||
|
||||
async _openEdit(m) {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/transcribe/models/${m.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const r = await res.json();
|
||||
this._provider = this._providers.find(p => p.id === r.provider_id) ?? null;
|
||||
this._form = {
|
||||
provider_id: r.provider_id,
|
||||
model_id: r.model_id,
|
||||
name: r.name,
|
||||
language: r.language ?? '',
|
||||
priority: r.priority,
|
||||
};
|
||||
this._modal = { mode: 'edit', id: r.id, name: r.name };
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async _delete(m) {
|
||||
if (!confirm(`Delete transcription model "${m.name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/transcribe/models/${m.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit add ───────────────────────────────────────────────────────────────
|
||||
|
||||
async _submitAdd(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
const f = this._form;
|
||||
try {
|
||||
const res = await fetch('/api/transcribe/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: Number(f.provider_id),
|
||||
model_id: f.model_id,
|
||||
name: f.name || f.model_id,
|
||||
language: f.language || null,
|
||||
priority: Number(f.priority) || 100,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit edit ──────────────────────────────────────────────────────────────
|
||||
|
||||
async _submitEdit(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
const f = this._form;
|
||||
const id = this._modal.id;
|
||||
try {
|
||||
const res = await fetch(`/api/transcribe/models/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider_id: Number(f.provider_id),
|
||||
model_id: f.model_id,
|
||||
name: f.name || f.model_id,
|
||||
language: f.language || null,
|
||||
priority: Number(f.priority) || 100,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
_closeModal() { this._modal = null; this._error = null; }
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderRow(m) {
|
||||
const isPlugin = m.from_plugin;
|
||||
return html`
|
||||
<tr class="llm-row">
|
||||
<td>
|
||||
${isPlugin
|
||||
? html`<span class="badge" style="background:#7c3aed;font-size:0.65rem;font-weight:500">Plugin</span>`
|
||||
: html`<span class="badge bg-secondary" style="font-size:0.65rem;font-weight:500">Cloud</span>`}
|
||||
</td>
|
||||
<td><span class="fw-semibold">${m.name}</span></td>
|
||||
<td class="text-muted" style="font-size:0.8rem">${isPlugin ? '—' : m.provider_name}</td>
|
||||
<td class="llm-model" title=${m.model_id}>${m.model_id}</td>
|
||||
<td style="font-size:0.8rem">${m.language ?? html`<span style="opacity:0.35">auto</span>`}</td>
|
||||
<td class="llm-actions">
|
||||
${isPlugin ? html`
|
||||
<span class="text-muted" style="font-size:0.75rem" title="Managed by plugin">
|
||||
<i class="bi bi-lock"></i>
|
||||
</span>
|
||||
` : html`
|
||||
<button class="btn btn-sm btn-link" title="Edit" @click=${() => this._openEdit(m)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-link text-danger" title="Delete" @click=${() => this._delete(m)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
`}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderPickProvider() {
|
||||
const tProviders = this._providers.filter(p =>
|
||||
Array.isArray(p.supported_types) && p.supported_types.includes('transcribe')
|
||||
);
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">Add Transcription Model — Choose Provider</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<div class="llm-provider-grid">
|
||||
${tProviders.map(p => html`
|
||||
<button class="llm-provider-card" @click=${() => this._pickProvider(p)}>
|
||||
<div class="llm-provider-card-name">${p.name}</div>
|
||||
<div class="llm-provider-card-type text-muted" style="font-size:0.75rem">${p.type}</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="agent-dialog-actions mt-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderPickModel() {
|
||||
const p = this._provider;
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">
|
||||
Add Transcription Model
|
||||
<span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>
|
||||
</div>
|
||||
${this._loadingModels ? html`
|
||||
<div class="text-center py-4 text-muted" style="font-size:0.85rem">
|
||||
<div class="spinner-border spinner-border-sm me-2"></div>Loading models…
|
||||
</div>
|
||||
` : html`
|
||||
<div class="tts-model-pick-list">
|
||||
${(this._remoteModels ?? []).map(m => html`
|
||||
<button class="tts-model-pick-item" @click=${() => this._pickRemoteModel(m)}>
|
||||
<div class="tts-model-pick-name">${m.name}</div>
|
||||
${m.description ? html`<div class="tts-model-pick-desc">${m.description}</div>` : ''}
|
||||
${m.languages?.length ? html`
|
||||
<div class="tts-model-pick-langs">${m.languages.slice(0, 6).join(', ')}${m.languages.length > 6 ? ` +${m.languages.length - 6}` : ''}</div>
|
||||
` : ''}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="agent-dialog-actions mt-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => { this._modal = 'add'; }}>
|
||||
Enter model ID manually
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderForm(isEdit = false) {
|
||||
const f = this._form;
|
||||
const p = this._provider;
|
||||
const title = isEdit
|
||||
? html`Edit <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.name}</span>`
|
||||
: html`Add Transcription Model <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>`;
|
||||
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">${title}</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<form @submit=${(e) => isEdit ? this._submitEdit(e) : this._submitAdd(e)}>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Model ID <span class="text-muted fw-normal">(sent to API)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required
|
||||
placeholder="e.g. openai/whisper-1"
|
||||
?disabled=${isEdit}
|
||||
@input=${(e) => this._form = { ...this._form, model_id: e.target.value }} />
|
||||
${isEdit ? html`<div class="form-text">Model ID cannot be changed after creation.</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Name / Alias <span class="text-muted fw-normal">(optional)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.name}
|
||||
placeholder=${f.model_id || 'same as model ID'}
|
||||
@input=${(e) => this._form = { ...this._form, name: e.target.value }} />
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Language <span class="text-muted fw-normal">(BCP-47, optional)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.language}
|
||||
placeholder="e.g. it, en — leave blank for auto-detect"
|
||||
@input=${(e) => this._form = { ...this._form, language: e.target.value }} />
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label>
|
||||
<input type="number" class="form-control form-control-sm" .value=${String(f.priority)} min="1"
|
||||
@input=${(e) => this._form = { ...this._form, priority: e.target.value }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-dialog-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving ? 'Saving…' : isEdit ? 'Save changes' : 'Add model'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const tProviders = this._providers.filter(p =>
|
||||
Array.isArray(p.supported_types) && p.supported_types.includes('transcribe')
|
||||
);
|
||||
const canAdd = tProviders.length > 0;
|
||||
|
||||
return html`
|
||||
<div class="llm-page">
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
${this.onback ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back to models" @click=${this.onback}>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<h2 class="llm-page-title">Transcription Models</h2>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} ?disabled=${!canAdd}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${!canAdd ? html`
|
||||
<div class="agent-info-banner">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-0">No provider supports transcription yet. Add an <strong>OpenAI</strong> or <strong>OpenRouter</strong> provider first.</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._error && !this._modal ? html`
|
||||
<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>
|
||||
` : ''}
|
||||
|
||||
${this._models.length === 0 ? html`
|
||||
<p class="text-muted" style="font-size:0.9rem">
|
||||
No transcription models configured.
|
||||
${canAdd ? html`Click <strong>Add</strong> to add a cloud model.` : ''}
|
||||
Activate the <strong>Whisper Local</strong> plugin for on-device transcription.
|
||||
</p>
|
||||
` : html`
|
||||
<div class="table-responsive">
|
||||
<table class="table llm-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:5rem">Source</th>
|
||||
<th>Name</th>
|
||||
<th>Provider</th>
|
||||
<th>Model ID</th>
|
||||
<th>Language</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this._models.map(m => this._renderRow(m))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
${this._modal === 'pick-provider' ? this._renderPickProvider() : ''}
|
||||
${this._modal === 'pick-model' ? this._renderPickModel() : ''}
|
||||
${this._modal === 'add' ? this._renderForm(false) : ''}
|
||||
${this._modal?.mode === 'edit' ? this._renderForm(true) : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
// Audio formats accepted by the OpenAI-compatible `/audio/speech` endpoint.
|
||||
const TTS_RESPONSE_FORMATS = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'];
|
||||
|
||||
function emptyTtsForm() {
|
||||
return { provider_id: '', model_id: '', voice_id: '', name: '', description: '', instructions: '', response_format: '', priority: 100 };
|
||||
}
|
||||
|
||||
export class ModelsTtsSection extends LightElement {
|
||||
static properties = {
|
||||
onback: { attribute: false },
|
||||
_models: { state: true },
|
||||
_providers: { state: true },
|
||||
_modal: { state: true },
|
||||
_form: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
_provider: { state: true },
|
||||
_remoteModels: { state: true },
|
||||
_loadingModels: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.onback = null;
|
||||
this._models = [];
|
||||
this._providers = [];
|
||||
this._modal = null;
|
||||
this._form = emptyTtsForm();
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
this._provider = null;
|
||||
this._remoteModels = null;
|
||||
this._loadingModels = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._load();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const [modelsRes, providersRes] = await Promise.all([
|
||||
fetch('/api/tts/models'),
|
||||
fetch('/api/llm/providers'),
|
||||
]);
|
||||
if (!modelsRes.ok) throw new Error(`models: HTTP ${modelsRes.status}`);
|
||||
if (!providersRes.ok) throw new Error(`providers: HTTP ${providersRes.status}`);
|
||||
this._models = await modelsRes.json();
|
||||
this._providers = await providersRes.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add flow ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_openAdd() {
|
||||
this._error = null;
|
||||
this._provider = null;
|
||||
this._form = emptyTtsForm();
|
||||
this._modal = 'pick-provider';
|
||||
}
|
||||
|
||||
async _pickProvider(provider) {
|
||||
this._provider = provider;
|
||||
this._remoteModels = null;
|
||||
this._form = { ...emptyTtsForm(), provider_id: provider.id };
|
||||
this._loadingModels = true;
|
||||
this._modal = 'pick-model';
|
||||
try {
|
||||
const res = await fetch(`/api/tts/providers/${provider.id}/models`);
|
||||
this._remoteModels = res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
this._remoteModels = null;
|
||||
} finally {
|
||||
this._loadingModels = false;
|
||||
if (!this._remoteModels || this._remoteModels.length === 0) {
|
||||
this._modal = 'add';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_pickRemoteModel(remote) {
|
||||
this._form = {
|
||||
...this._form,
|
||||
model_id: remote.id,
|
||||
name: remote.name,
|
||||
description: remote.description ?? '',
|
||||
instructions: remote.instructions ?? '',
|
||||
};
|
||||
this._modal = 'add';
|
||||
}
|
||||
|
||||
// ── Edit flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async _openEdit(m) {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/tts/models/${m.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const r = await res.json();
|
||||
this._provider = this._providers.find(p => p.id === r.provider_id) ?? null;
|
||||
this._form = {
|
||||
provider_id: r.provider_id,
|
||||
model_id: r.model_id,
|
||||
voice_id: r.voice_id ?? '',
|
||||
name: r.name,
|
||||
description: r.description ?? '',
|
||||
instructions: r.instructions ?? '',
|
||||
response_format: r.response_format ?? '',
|
||||
priority: r.priority,
|
||||
};
|
||||
this._modal = { mode: 'edit', id: r.id, name: r.name };
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async _delete(m) {
|
||||
if (!confirm(`Delete TTS model "${m.name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/tts/models/${m.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────────
|
||||
|
||||
_payload() {
|
||||
const f = this._form;
|
||||
return {
|
||||
provider_id: Number(f.provider_id),
|
||||
model_id: f.model_id,
|
||||
voice_id: f.voice_id || null,
|
||||
name: f.name || f.model_id,
|
||||
description: f.description || null,
|
||||
instructions: f.instructions || null,
|
||||
response_format: f.response_format || null,
|
||||
priority: Number(f.priority) || 100,
|
||||
};
|
||||
}
|
||||
|
||||
async _submitAdd(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/tts/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this._payload()),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _submitEdit(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
const id = this._modal.id;
|
||||
try {
|
||||
const res = await fetch(`/api/tts/models/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this._payload()),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
_closeModal() { this._modal = null; this._error = null; }
|
||||
|
||||
// ── Render card ───────────────────────────────────────────────────────────────
|
||||
|
||||
_renderCard(m) {
|
||||
const isPlugin = m.from_plugin;
|
||||
return html`
|
||||
<div class="llm-card">
|
||||
<div class="llm-card-row1">
|
||||
${isPlugin
|
||||
? html`<span class="ig-source-badge ig-source-plugin">Plugin</span>`
|
||||
: html`<span class="ig-source-badge ig-source-cloud">Cloud</span>`}
|
||||
<span class="llm-card-name">${m.name}</span>
|
||||
<div class="llm-card-actions">
|
||||
${isPlugin ? html`
|
||||
<span class="llm-btn-icon" title="Managed by plugin" style="cursor:default;opacity:0.4">
|
||||
<i class="bi bi-lock"></i>
|
||||
</span>
|
||||
` : html`
|
||||
<button class="llm-btn-icon llm-btn-edit" title="Edit" @click=${() => this._openEdit(m)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="llm-btn-icon llm-btn-delete" title="Delete" @click=${() => this._delete(m)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-card-row2">
|
||||
${!isPlugin ? html`<span class="llm-provider-name">${m.provider_name}</span>` : ''}
|
||||
<span class="llm-model-id">${isPlugin ? m.model_id || m.id : m.model_id}</span>
|
||||
${m.voice_id ? html`<span class="llm-model-id" style="opacity:0.6" title="Voice ID">${m.voice_id}</span>` : ''}
|
||||
${m.response_format ? html`<span class="llm-model-id" style="opacity:0.6" title="Response format">${m.response_format}</span>` : ''}
|
||||
${!isPlugin ? html`<span class="ig-priority-tag" title="Priority">#${m.priority}</span>` : ''}
|
||||
</div>
|
||||
|
||||
${m.description ? html`
|
||||
<div class="ig-card-desc">${m.description}</div>
|
||||
` : ''}
|
||||
|
||||
${m.instructions ? html`
|
||||
<div class="ig-card-desc" style="font-style:italic;color:var(--bs-secondary)">${m.instructions}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: pick provider ──────────────────────────────────────────────────────
|
||||
|
||||
_renderPickProvider() {
|
||||
const ttsProviders = this._providers.filter(p =>
|
||||
Array.isArray(p.supported_types) && p.supported_types.includes('tts')
|
||||
);
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">Add TTS Model — Choose Provider</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<div class="llm-provider-grid">
|
||||
${ttsProviders.map(p => html`
|
||||
<button class="llm-provider-card" @click=${() => this._pickProvider(p)}>
|
||||
<div class="llm-provider-card-name">${p.name}</div>
|
||||
<div class="llm-provider-card-type text-muted" style="font-size:0.75rem">${p.type}</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="agent-dialog-actions mt-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: pick remote model ──────────────────────────────────────────────────
|
||||
|
||||
_renderPickModel() {
|
||||
const p = this._provider;
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">
|
||||
Add TTS Model
|
||||
<span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>
|
||||
</div>
|
||||
${this._loadingModels ? html`
|
||||
<div class="text-center py-4 text-muted" style="font-size:0.85rem">
|
||||
<div class="spinner-border spinner-border-sm me-2"></div>Loading models…
|
||||
</div>
|
||||
` : html`
|
||||
<div class="tts-model-pick-list">
|
||||
${(this._remoteModels ?? []).map(m => html`
|
||||
<button class="tts-model-pick-item" @click=${() => this._pickRemoteModel(m)}>
|
||||
<div class="tts-model-pick-row1">
|
||||
<span class="tts-model-pick-name">${m.name}</span>
|
||||
${m.cost_factor != null ? html`
|
||||
<span class="tts-model-pick-cost" title="Cost multiplier relative to base rate">×${m.cost_factor.toFixed(1)}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
${m.description ? html`<div class="tts-model-pick-desc">${m.description}</div>` : ''}
|
||||
${m.languages?.length ? html`
|
||||
<div class="tts-model-pick-langs">${m.languages.slice(0, 6).join(', ')}${m.languages.length > 6 ? ` +${m.languages.length - 6}` : ''}</div>
|
||||
` : ''}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="agent-dialog-actions mt-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @click=${() => { this._modal = 'add'; }}>
|
||||
Enter model ID manually
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Modal: add / edit form ────────────────────────────────────────────────────
|
||||
|
||||
_renderForm(isEdit = false) {
|
||||
const f = this._form;
|
||||
const p = this._provider;
|
||||
const title = isEdit
|
||||
? html`Edit <span class="text-muted fw-normal ms-1" style="font-size:0.9rem">${this._modal.name}</span>`
|
||||
: html`Add TTS Model <span class="badge bg-secondary ms-2" style="font-size:0.7rem;font-weight:400">${p?.name}</span>`;
|
||||
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop" @click=${(e) => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog llm-modal">
|
||||
<div class="llm-modal-title">${title}</div>
|
||||
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>` : ''}
|
||||
<form @submit=${(e) => isEdit ? this._submitEdit(e) : this._submitAdd(e)}>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Model ID <span class="text-muted fw-normal">(sent to API)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.model_id} required
|
||||
placeholder="e.g. tts-1-hd"
|
||||
?disabled=${isEdit}
|
||||
@input=${(e) => this._form = { ...this._form, model_id: e.target.value }} />
|
||||
${isEdit ? html`<div class="form-text">Model ID cannot be changed after creation.</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Voice ID <span class="text-muted fw-normal">(optional — required for ElevenLabs)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.voice_id}
|
||||
placeholder="e.g. alloy, Kore, 21m00Tcm4TlvDq8ikWAM"
|
||||
@input=${(e) => this._form = { ...this._form, voice_id: e.target.value }} />
|
||||
<div class="form-text">
|
||||
Speaker voice. OpenAI: <code>alloy</code>/<code>echo</code>/<code>nova</code>… (default <code>alloy</code> if empty);
|
||||
Gemini: <code>Kore</code>/<code>Puck</code>/<code>Zephyr</code>…; ElevenLabs: the voice ID.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Name / Alias</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.name}
|
||||
placeholder=${f.model_id || 'same as model ID'}
|
||||
@input=${(e) => this._form = { ...this._form, name: e.target.value }} />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Description <span class="text-muted fw-normal">(optional)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" .value=${f.description}
|
||||
placeholder="e.g. High quality, slow — best for long responses"
|
||||
@input=${(e) => this._form = { ...this._form, description: e.target.value }} />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Instructions <span class="text-muted fw-normal">(optional — shown to LLM)</span>
|
||||
</label>
|
||||
<textarea class="form-control form-control-sm" rows="3" .value=${f.instructions}
|
||||
placeholder="e.g. Speak in a calm, neutral tone. Pause slightly between sentences."
|
||||
@input=${(e) => this._form = { ...this._form, instructions: e.target.value }}></textarea>
|
||||
<div class="form-text">Voice/tone guidance injected into the LLM system prompt when this model is active.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">
|
||||
Response format <span class="text-muted fw-normal">(optional)</span>
|
||||
</label>
|
||||
<select class="form-select form-select-sm" .value=${f.response_format}
|
||||
@change=${(e) => this._form = { ...this._form, response_format: e.target.value }}>
|
||||
<option value="">Provider default (mp3)</option>
|
||||
${TTS_RESPONSE_FORMATS.map(fmt => html`
|
||||
<option value=${fmt}>${fmt}</option>
|
||||
`)}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Audio format requested from the provider. Leave empty unless the model requires
|
||||
a specific one — e.g. Gemini TTS only accepts <code>pcm</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Priority</label>
|
||||
<input type="number" class="form-control form-control-sm" .value=${String(f.priority)} min="1"
|
||||
@input=${(e) => this._form = { ...this._form, priority: e.target.value }} />
|
||||
<div class="form-text">Lower number = used first. Default: 100.</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-dialog-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving ? 'Saving…' : isEdit ? 'Save changes' : 'Add model'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const ttsProviders = this._providers.filter(p =>
|
||||
Array.isArray(p.supported_types) && p.supported_types.includes('tts')
|
||||
);
|
||||
const canAdd = ttsProviders.length > 0;
|
||||
|
||||
return html`
|
||||
<div class="llm-page">
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
${this.onback ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary back-btn" title="Back to models" @click=${this.onback}>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<div>
|
||||
<h2 class="llm-page-title">Text-to-Speech Models</h2>
|
||||
<span class="llm-page-count">${this._models.length} model${this._models.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()} ?disabled=${!canAdd}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${!canAdd ? html`
|
||||
<div class="agent-info-banner">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-0">No provider supports TTS yet. Add an <strong>OpenAI</strong> provider first.</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._models.some(m => m.from_plugin) ? html`
|
||||
<div class="agent-info-banner">
|
||||
<div class="agent-info-banner-icon"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="agent-info-banner-body">
|
||||
<p class="mb-0">Models with the <strong>Plugin</strong> badge are read-only — managed automatically by the plugin that registered them.</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this._error && !this._modal ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : ''}
|
||||
|
||||
<div class="llm-card-list">
|
||||
${this._models.length === 0 ? html`
|
||||
<div class="llm-empty-state">
|
||||
<i class="bi bi-volume-up"></i>
|
||||
<p>No TTS models configured.</p>
|
||||
${canAdd ? html`
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>Add your first model
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
` : this._models.map(m => this._renderCard(m))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._modal === 'pick-provider' ? this._renderPickProvider() : ''}
|
||||
${this._modal === 'pick-model' ? this._renderPickModel() : ''}
|
||||
${this._modal === 'add' ? this._renderForm(false) : ''}
|
||||
${this._modal?.mode === 'edit' ? this._renderForm(true) : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { ProjectListSection } from './project-list.js';
|
||||
import { ProjectBoardSection } from './project-board.js';
|
||||
|
||||
export class ProjectsPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_view: { state: true },
|
||||
_projectId: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._view = 'list';
|
||||
this._projectId = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
const open = e.detail.page === 'projects';
|
||||
this._open = open;
|
||||
this.style.display = open ? 'flex' : 'none';
|
||||
if (open) {
|
||||
const { view, id } = this._parseHash();
|
||||
this._view = view;
|
||||
this._projectId = id;
|
||||
this._loadCurrent();
|
||||
}
|
||||
});
|
||||
window.addEventListener('sidebar-open-project', (e) => {
|
||||
this._open = true;
|
||||
this.style.display = 'flex';
|
||||
this._navigateToBoard(e.detail.id);
|
||||
});
|
||||
}
|
||||
|
||||
_parseHash() {
|
||||
const parts = location.hash.slice(1).split('/');
|
||||
if (parts[0] === 'projects' && parts[1] && /^\d+$/.test(parts[1])) {
|
||||
return { view: 'board', id: parseInt(parts[1], 10) };
|
||||
}
|
||||
return { view: 'list', id: null };
|
||||
}
|
||||
|
||||
_loadCurrent() {
|
||||
this.updateComplete.then(() => {
|
||||
if (this._view === 'list') {
|
||||
this.querySelector('project-list-section')?.load();
|
||||
} else {
|
||||
this.querySelector('project-board-section')?.load(this._projectId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_navigateToBoard(id) {
|
||||
this._view = 'board';
|
||||
this._projectId = id;
|
||||
history.pushState({ page: 'projects', id }, '', `#projects/${id}`);
|
||||
this.updateComplete.then(() => {
|
||||
this.querySelector('project-board-section')?.load(id);
|
||||
});
|
||||
}
|
||||
|
||||
_navigateToList() {
|
||||
this._view = 'list';
|
||||
this._projectId = null;
|
||||
history.pushState({ page: 'projects' }, '', '#projects');
|
||||
this.updateComplete.then(() => {
|
||||
this.querySelector('project-list-section')?.load();
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
return html`
|
||||
${this._view === 'list' ? html`
|
||||
<project-list-section
|
||||
@project-navigate=${e => this._navigateToBoard(e.detail.id)}
|
||||
></project-list-section>
|
||||
` : html`
|
||||
<project-board-section
|
||||
@project-back=${() => this._navigateToList()}
|
||||
></project-board-section>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('project-list-section', ProjectListSection);
|
||||
customElements.define('project-board-section', ProjectBoardSection);
|
||||
@@ -0,0 +1,472 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement, renderMarkdown } from '../../lib/base.js';
|
||||
import { formatDate } from '../tasks/utils.js';
|
||||
|
||||
export class ProjectBoardSection extends LightElement {
|
||||
static properties = {
|
||||
_project: { state: true },
|
||||
_tickets: { state: true },
|
||||
_modal: { state: true },
|
||||
_form: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
_expanded: { state: true },
|
||||
_expandedDesc: { state: true },
|
||||
_agents: { state: true },
|
||||
_groups: { state: true },
|
||||
_activeTab: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._project = null;
|
||||
this._tickets = [];
|
||||
this._modal = null;
|
||||
this._form = this._emptyForm();
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
this._expanded = null;
|
||||
this._expandedDesc = {};
|
||||
this._pollTimer = null;
|
||||
this._projectId = null;
|
||||
this._agents = [];
|
||||
this._groups = [];
|
||||
this._activeTab = 'tickets';
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopPolling();
|
||||
}
|
||||
|
||||
_emptyForm() {
|
||||
// No default agent — a ticket runs a `task` agent, picked once the list loads.
|
||||
return { title: '', description: '', agent_id: '', security_group: '' };
|
||||
}
|
||||
|
||||
async load(projectId) {
|
||||
this._projectId = projectId;
|
||||
this._project = null;
|
||||
this._error = null;
|
||||
try {
|
||||
const [projRes, tickRes] = await Promise.all([
|
||||
fetch(`/api/projects/${projectId}`),
|
||||
fetch(`/api/projects/${projectId}/tickets`),
|
||||
]);
|
||||
if (!projRes.ok) throw new Error(`HTTP ${projRes.status}`);
|
||||
if (!tickRes.ok) throw new Error(`HTTP ${tickRes.status}`);
|
||||
this._project = await projRes.json();
|
||||
this._tickets = await tickRes.json();
|
||||
this._updatePolling();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _loadTickets() {
|
||||
if (!this._projectId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${this._projectId}/tickets`);
|
||||
if (res.ok) {
|
||||
this._tickets = await res.json();
|
||||
this._updatePolling();
|
||||
}
|
||||
} catch { /* ignore transient errors during poll */ }
|
||||
}
|
||||
|
||||
_hasActiveTickets() {
|
||||
return this._tickets.some(t => t.status === 'pending' || t.status === 'in_progress');
|
||||
}
|
||||
|
||||
_updatePolling() {
|
||||
if (this._hasActiveTickets()) {
|
||||
this._startPolling();
|
||||
} else {
|
||||
this._stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
_startPolling() {
|
||||
if (this._pollTimer) return;
|
||||
this._pollTimer = setInterval(() => this._loadTickets(), 5000);
|
||||
}
|
||||
|
||||
_stopPolling() {
|
||||
if (this._pollTimer) {
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_groupTickets() {
|
||||
const running = [];
|
||||
const todo = [];
|
||||
const completed = [];
|
||||
|
||||
for (const t of this._tickets) {
|
||||
if (t.status === 'pending' || t.status === 'in_progress') {
|
||||
running.push(t);
|
||||
} else if (t.status === 'todo') {
|
||||
todo.push(t);
|
||||
} else {
|
||||
completed.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
todo.sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''));
|
||||
completed.sort((a, b) => (b.completed_at ?? '').localeCompare(a.completed_at ?? ''));
|
||||
|
||||
return { running, todo, completed };
|
||||
}
|
||||
|
||||
async _loadModalData() {
|
||||
try {
|
||||
const [agentsRes, groupsRes] = await Promise.all([
|
||||
fetch('/api/agents'),
|
||||
fetch('/api/tool-permission-groups'),
|
||||
]);
|
||||
if (agentsRes.ok) this._agents = await agentsRes.json();
|
||||
if (groupsRes.ok) this._groups = await groupsRes.json();
|
||||
// Tickets run task agents only; pre-select the first one so a valid value is sent.
|
||||
if (!this._form.agent_id) {
|
||||
const first = this._agents.find(a => a.type === 'task');
|
||||
if (first) this._form = { ...this._form, agent_id: first.id };
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async _startTicket(ticket) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${ticket.project_id}/tickets/${ticket.id}/start`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._loadTickets();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _resetTicket(ticket) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${ticket.project_id}/tickets/${ticket.id}/reset`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (this._expanded === ticket.id) this._expanded = null;
|
||||
await this._loadTickets();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _deleteTicket(ticket) {
|
||||
if (!confirm(`Delete ticket "${ticket.title}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${ticket.project_id}/tickets/${ticket.id}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._loadTickets();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _createTicket(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const payload = { ...this._form };
|
||||
if (!payload.security_group) delete payload.security_group;
|
||||
const res = await fetch(`/api/projects/${this._projectId}/tickets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this._loadTickets();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
_back() {
|
||||
this._stopPolling();
|
||||
this.dispatchEvent(new CustomEvent('project-back', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
async _openChat() {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${this._projectId}/session`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const { source } = await res.json();
|
||||
window.dispatchEvent(new CustomEvent('project-chat-open', {
|
||||
detail: { source, label: this._project?.name ?? `Project ${this._projectId}` },
|
||||
}));
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
_toggleExpand(id) {
|
||||
this._expanded = this._expanded === id ? null : id;
|
||||
}
|
||||
|
||||
_toggleDesc(id) {
|
||||
this._expandedDesc = { ...this._expandedDesc, [id]: !this._expandedDesc[id] };
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderTicketCard(ticket) {
|
||||
const isRunning = ticket.status === 'pending' || ticket.status === 'in_progress';
|
||||
const isDone = ticket.status === 'done';
|
||||
const isFailed = ticket.status === 'failed';
|
||||
const isCompleted = isDone || isFailed;
|
||||
const isExpanded = this._expanded === ticket.id;
|
||||
|
||||
const cardClass = isRunning ? 'ticket-card ticket-card--running'
|
||||
: isDone ? 'ticket-card ticket-card--done'
|
||||
: isFailed ? 'ticket-card ticket-card--failed'
|
||||
: 'ticket-card';
|
||||
|
||||
return html`
|
||||
<div class="${cardClass}">
|
||||
<div class="ticket-card-header">
|
||||
<span class="ticket-card-title">${ticket.title}</span>
|
||||
${isRunning ? html`
|
||||
<span class="spinner-border spinner-border-sm text-primary"
|
||||
style="width:0.7rem;height:0.7rem;flex-shrink:0"></span>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
${ticket.description
|
||||
? html`<div class="ticket-card-desc ${this._expandedDesc[ticket.id] ? 'ticket-card-desc--expanded' : ''}"
|
||||
@click=${() => { if (!window.getSelection().toString()) this._toggleDesc(ticket.id); }}>${ticket.description}</div>`
|
||||
: nothing}
|
||||
<div class="ticket-card-meta">
|
||||
<span><i class="bi bi-person me-1"></i>${ticket.agent_id}</span>
|
||||
${ticket.started_at ? html`
|
||||
<span><i class="bi bi-clock me-1"></i>${formatDate(ticket.started_at)}</span>
|
||||
` : html`
|
||||
<span><i class="bi bi-calendar me-1"></i>${formatDate(ticket.created_at)}</span>
|
||||
`}
|
||||
${isCompleted && ticket.completed_at ? html`
|
||||
<span><i class="bi bi-check2 me-1"></i>${formatDate(ticket.completed_at)}</span>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="ticket-card-actions">
|
||||
${ticket.status === 'todo' ? html`
|
||||
<button class="btn btn-sm btn-outline-primary ticket-card-btn"
|
||||
@click=${() => this._startTicket(ticket)}>
|
||||
<i class="bi bi-play-fill me-1"></i>Start
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger ticket-card-btn"
|
||||
@click=${() => this._deleteTicket(ticket)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
|
||||
${isRunning ? html`
|
||||
<span class="ticket-card-running-label">Running…</span>
|
||||
${ticket.session_id != null ? html`
|
||||
<a href="#session/${ticket.session_id}" class="ticket-card-session-link">
|
||||
<i class="bi bi-chat-text me-1"></i>#${ticket.session_id}
|
||||
</a>
|
||||
` : nothing}
|
||||
` : nothing}
|
||||
|
||||
${isCompleted ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary ticket-card-btn"
|
||||
@click=${() => this._resetTicket(ticket)}>
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Reset
|
||||
</button>
|
||||
<button class="btn btn-sm ticket-card-btn ${isDone ? 'btn-outline-success' : 'btn-outline-danger'}"
|
||||
@click=${() => this._toggleExpand(ticket.id)}>
|
||||
<i class="bi bi-${isExpanded ? 'chevron-up' : 'chevron-down'} me-1"></i>
|
||||
${isDone ? 'Result' : 'Error'}
|
||||
</button>
|
||||
${ticket.session_id != null ? html`
|
||||
<a href="#session/${ticket.session_id}"
|
||||
class="btn btn-sm btn-outline-secondary ticket-card-btn ticket-card-session-btn">
|
||||
<i class="bi bi-chat-text me-1"></i>#${ticket.session_id}
|
||||
</a>
|
||||
` : nothing}
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
${isCompleted && isExpanded ? html`
|
||||
<div class="ticket-card-result ticket-card-result--${isDone ? 'success' : 'error'}">
|
||||
${isDone
|
||||
? html`<div class="ticket-result-markdown copilot-markdown">
|
||||
${unsafeHTML(renderMarkdown(ticket.result ?? '(no output)'))}
|
||||
</div>`
|
||||
: html`<pre class="ticket-result-error">${ticket.error ?? '(no error message)'}</pre>`}
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderSection(label, icon, colorClass, tickets, emptyLabel) {
|
||||
return html`
|
||||
<div class="ticket-section">
|
||||
<div class="ticket-section-header ${colorClass}">
|
||||
<span><i class="bi bi-${icon} me-1"></i>${label}</span>
|
||||
<span class="badge bg-secondary ms-2">${tickets.length}</span>
|
||||
</div>
|
||||
${tickets.length === 0
|
||||
? html`<div class="ticket-section-empty">${emptyLabel}</div>`
|
||||
: tickets.map(t => this._renderTicketCard(t))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderTabBar() {
|
||||
return html`
|
||||
<div class="project-tab-bar">
|
||||
<button
|
||||
class="project-tab ${this._activeTab === 'tickets' ? 'project-tab--active' : ''}"
|
||||
@click=${() => { this._activeTab = 'tickets'; }}>
|
||||
<i class="bi bi-card-list me-1"></i>Tickets
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderTicketsTab() {
|
||||
const { running, todo, completed } = this._groupTickets();
|
||||
return html`
|
||||
<div class="ticket-list">
|
||||
${this._renderSection('Running', 'activity', 'ticket-section-header--running', running, 'No tickets running')}
|
||||
${this._renderSection('Todo', 'circle', '', todo, 'No tickets to do')}
|
||||
${this._renderSection('Completed', 'check-circle', 'ticket-section-header--completed', completed, 'No completed tickets')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderModal() {
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop">
|
||||
<div class="agent-dialog agent-dialog--ticket">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
|
||||
<i class="bi bi-card-text"></i>
|
||||
<span style="font-weight:600">New Ticket</span>
|
||||
<button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem"
|
||||
@click=${() => this._modal = null}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
<form @submit=${e => this._createTicket(e)}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Title</label>
|
||||
<input type="text" class="form-control form-control-sm" required
|
||||
placeholder="What needs to be done"
|
||||
.value=${this._form.title}
|
||||
@input=${e => this._form = { ...this._form, title: e.target.value }} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Description / Prompt</label>
|
||||
<textarea class="form-control form-control-sm" rows="4"
|
||||
placeholder="Detailed instructions for the agent…"
|
||||
.value=${this._form.description}
|
||||
@input=${e => this._form = { ...this._form, description: e.target.value }}></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Agent</label>
|
||||
<select class="form-select form-select-sm"
|
||||
.value=${this._form.agent_id}
|
||||
@change=${e => this._form = { ...this._form, agent_id: e.target.value }}>
|
||||
${this._agents.filter(a => a.type === 'task').map(a => html`
|
||||
<option value=${a.id} ?selected=${this._form.agent_id === a.id}>${a.name || a.id}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Security Group</label>
|
||||
<select class="form-select form-select-sm"
|
||||
.value=${this._form.security_group}
|
||||
@change=${e => this._form = { ...this._form, security_group: e.target.value }}>
|
||||
<option value="">— inherit from project —</option>
|
||||
${this._groups.map(g => html`
|
||||
<option value=${g.id} ?selected=${this._form.security_group === g.id}>${g.name}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:0.5rem">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
@click=${() => this._modal = null}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving
|
||||
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
|
||||
: html`<i class="bi bi-check-lg me-1"></i>Create`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._project) {
|
||||
return html`
|
||||
<div style="display:flex;align-items:center;justify-content:center;flex:1">
|
||||
<span class="spinner-border text-primary"></span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="project-page">
|
||||
<div class="project-page-header">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._back()}>
|
||||
<i class="bi bi-arrow-left me-1"></i>Projects
|
||||
</button>
|
||||
<h2 class="project-page-title">
|
||||
<i class="bi bi-folder2"></i>${this._project.name}
|
||||
</h2>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem">
|
||||
<button class="btn btn-sm btn-outline-primary" @click=${() => this._openChat()}>
|
||||
<i class="bi bi-chat-dots me-1"></i>Open Chat
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary"
|
||||
@click=${() => { this._form = this._emptyForm(); this._error = null; this._modal = { mode: 'add' }; this._loadModalData(); }}>
|
||||
<i class="bi bi-plus-lg me-1"></i>New Ticket
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._renderTabBar()}
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mt-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${this._activeTab === 'tickets' ? this._renderTicketsTab() : nothing}
|
||||
|
||||
${this._modal ? this._renderModal() : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { formatDate } from '../tasks/utils.js';
|
||||
|
||||
export class ProjectListSection extends LightElement {
|
||||
static properties = {
|
||||
_projects: { state: true },
|
||||
_modal: { state: true },
|
||||
_form: { state: true },
|
||||
_saving: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._projects = [];
|
||||
this._modal = null;
|
||||
this._form = this._emptyForm();
|
||||
this._saving = false;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
_emptyForm() {
|
||||
return { name: '', path: '', description: '' };
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._projects = await res.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
_openAdd() {
|
||||
this._form = this._emptyForm();
|
||||
this._error = null;
|
||||
this._modal = { mode: 'add' };
|
||||
}
|
||||
|
||||
_openEdit(project) {
|
||||
this._form = { name: project.name, path: project.path, description: project.description ?? '' };
|
||||
this._error = null;
|
||||
this._modal = { mode: 'edit', project };
|
||||
}
|
||||
|
||||
_closeModal() {
|
||||
this._modal = null;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
async _submit(e) {
|
||||
e.preventDefault();
|
||||
if (this._saving) return;
|
||||
this._saving = true;
|
||||
this._error = null;
|
||||
const isEdit = this._modal?.mode === 'edit';
|
||||
const url = isEdit ? `/api/projects/${this._modal.project.id}` : '/api/projects';
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: isEdit ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this._form),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._modal = null;
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _delete(project) {
|
||||
if (!confirm(`Delete project "${project.name}"?\nAll tickets will also be deleted.`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${project.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
_navigate(project) {
|
||||
this.dispatchEvent(new CustomEvent('project-navigate', {
|
||||
bubbles: true, composed: true, detail: { id: project.id },
|
||||
}));
|
||||
}
|
||||
|
||||
_setField(f, v) {
|
||||
this._form = { ...this._form, [f]: v };
|
||||
}
|
||||
|
||||
_renderModal() {
|
||||
const isEdit = this._modal?.mode === 'edit';
|
||||
return html`
|
||||
<div class="agent-dialog-backdrop"
|
||||
@click=${e => { if (e.target === e.currentTarget) this._closeModal(); }}>
|
||||
<div class="agent-dialog">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
|
||||
<i class="bi bi-kanban"></i>
|
||||
<span style="font-weight:600">${isEdit ? 'Edit Project' : 'New Project'}</span>
|
||||
<button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem"
|
||||
@click=${() => this._closeModal()}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mb-3" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
<form @submit=${e => this._submit(e)}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" required
|
||||
placeholder="My Project"
|
||||
.value=${this._form.name}
|
||||
@input=${e => this._setField('name', e.target.value)} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Path</label>
|
||||
<input type="text" class="form-control form-control-sm" required
|
||||
placeholder="/path/to/project"
|
||||
.value=${this._form.path}
|
||||
@input=${e => this._setField('path', e.target.value)} />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">Description</label>
|
||||
<textarea class="form-control form-control-sm" rows="2"
|
||||
placeholder="What this project is about"
|
||||
.value=${this._form.description}
|
||||
@input=${e => this._setField('description', e.target.value)}></textarea>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:0.5rem">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
@click=${() => this._closeModal()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._saving}>
|
||||
${this._saving
|
||||
? html`<span class="spinner-border spinner-border-sm me-1"></span>Saving…`
|
||||
: html`<i class="bi bi-check-lg me-1"></i>${isEdit ? 'Save' : 'Create'}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderCard(project) {
|
||||
return html`
|
||||
<div class="project-card" @click=${() => this._navigate(project)}>
|
||||
<div class="project-card-header">
|
||||
<div class="project-card-title">${project.name}</div>
|
||||
<div class="project-card-actions" @click=${e => e.stopPropagation()}>
|
||||
<button class="project-card-icon-btn" title="Edit"
|
||||
@click=${() => this._openEdit(project)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="project-card-icon-btn project-card-icon-btn--danger" title="Delete"
|
||||
@click=${() => this._delete(project)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-card-path"><i class="bi bi-folder2 me-1"></i>${project.path}</div>
|
||||
${project.description
|
||||
? html`<div class="project-card-desc">${project.description}</div>`
|
||||
: nothing}
|
||||
<div class="project-card-meta">Updated ${formatDate(project.updated_at)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="project-page">
|
||||
<div class="project-page-header">
|
||||
<h2 class="project-page-title"><i class="bi bi-kanban"></i> Projects</h2>
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._openAdd()}>
|
||||
<i class="bi bi-plus-lg me-1"></i>New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mt-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${this._projects.length === 0 ? html`
|
||||
<div class="task-empty">
|
||||
<i class="bi bi-kanban"></i>
|
||||
<p>No projects yet. Create one to get started.</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="project-grid">
|
||||
${this._projects.map(p => this._renderCard(p))}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${this._modal ? this._renderModal() : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
const PAGE_ID = 'session';
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return null;
|
||||
return new Date(iso).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function sourceBadgeClass(source) {
|
||||
const map = { tic: 'bg-warning text-dark', cron: 'bg-info text-dark', web: 'bg-primary', telegram: 'bg-success', mobile: 'bg-secondary' };
|
||||
return map[source] ?? 'bg-secondary';
|
||||
}
|
||||
|
||||
function jsonPretty(val) {
|
||||
if (val == null) return '—';
|
||||
if (typeof val === 'string') return val;
|
||||
return JSON.stringify(val, null, 2);
|
||||
}
|
||||
|
||||
export class SessionDetailPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_sessionId: { state: true },
|
||||
_data: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
_live: { state: true },
|
||||
_expandedTools: { state: true },
|
||||
_expandedReasons: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._sessionId = null;
|
||||
this._data = null;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._live = false;
|
||||
this._expandedTools = new Set();
|
||||
this._expandedReasons = new Set();
|
||||
this._ws = null;
|
||||
this._wsReconnectTimer = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._loadFromHash();
|
||||
else this._closeWs();
|
||||
});
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (this._open) this._loadFromHash();
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._closeWs();
|
||||
}
|
||||
|
||||
_idFromHash() {
|
||||
const parts = location.hash.replace('#', '').split('/');
|
||||
if (parts[0] === PAGE_ID && parts[1]) return parseInt(parts[1], 10);
|
||||
return null;
|
||||
}
|
||||
|
||||
_loadFromHash() {
|
||||
const id = this._idFromHash();
|
||||
if (id != null && id !== this._sessionId) {
|
||||
this._sessionId = id;
|
||||
this._closeWs();
|
||||
this._fetch(id);
|
||||
}
|
||||
}
|
||||
|
||||
async _fetch(id) {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
this._data = null;
|
||||
this._expandedTools = new Set();
|
||||
this._expandedReasons = new Set();
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._data = await res.json();
|
||||
this._connectWs(id);
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Live WebSocket ─────────────────────────────────────────────────────────
|
||||
|
||||
_connectWs(id) {
|
||||
this._closeWs();
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${location.host}/api/ws/session/${id}`);
|
||||
this._ws = ws;
|
||||
|
||||
ws.onopen = () => { this._live = true; };
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try { this._handleEvent(JSON.parse(e.data)); } catch {}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this._live = false;
|
||||
this._ws = null;
|
||||
// Reconnect after 3 s if the page is still open and showing this session.
|
||||
if (this._open && this._sessionId === id) {
|
||||
this._wsReconnectTimer = setTimeout(() => this._connectWs(id), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
|
||||
_closeWs() {
|
||||
clearTimeout(this._wsReconnectTimer);
|
||||
if (this._ws) { this._ws.onclose = null; this._ws.close(); this._ws = null; }
|
||||
this._live = false;
|
||||
}
|
||||
|
||||
_handleEvent(ev) {
|
||||
if (!this._data) return;
|
||||
const msgs = [...this._data.messages];
|
||||
const now = new Date().toISOString();
|
||||
|
||||
switch (ev.type) {
|
||||
case 'tool_start':
|
||||
msgs.push({
|
||||
kind: 'tool',
|
||||
tool_call_id: ev.tool_call_id,
|
||||
message_id: ev.message_id,
|
||||
name: ev.name,
|
||||
label_short: ev.label_short,
|
||||
label_full: ev.label_full,
|
||||
arguments: ev.arguments,
|
||||
result: null,
|
||||
error: null,
|
||||
status: 'pending',
|
||||
created_at: now,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'tool_done': {
|
||||
const i = msgs.findIndex(m => m.kind === 'tool' && m.tool_call_id === ev.tool_call_id);
|
||||
if (i >= 0) msgs[i] = { ...msgs[i], result: ev.result, status: 'done' };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_error': {
|
||||
const i = msgs.findIndex(m => m.kind === 'tool' && m.tool_call_id === ev.tool_call_id);
|
||||
if (i >= 0) msgs[i] = { ...msgs[i], error: ev.error, status: 'error' };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'thinking':
|
||||
msgs.push({
|
||||
kind: 'thinking',
|
||||
message_id: ev.message_id,
|
||||
content: ev.content,
|
||||
reasoning: '',
|
||||
input_tokens: ev.input_tokens ?? null,
|
||||
output_tokens: ev.output_tokens ?? null,
|
||||
created_at: now,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
msgs.push({
|
||||
kind: 'assistant',
|
||||
message_id: ev.message_id,
|
||||
content: ev.content,
|
||||
reasoning: '',
|
||||
input_tokens: ev.input_tokens ?? null,
|
||||
output_tokens: ev.output_tokens ?? null,
|
||||
created_at: now,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'user_message':
|
||||
msgs.push({
|
||||
kind: 'user',
|
||||
content: ev.content,
|
||||
is_synthetic: false,
|
||||
created_at: now,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'agent_start':
|
||||
msgs.push({
|
||||
kind: 'agent',
|
||||
agent_id: ev.agent_id,
|
||||
depth: ev.depth,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'agent_done':
|
||||
msgs.push({
|
||||
kind: 'agent_end',
|
||||
agent_id: ev.agent_id,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
return; // ignore unknown events
|
||||
}
|
||||
|
||||
this._data = { ...this._data, messages: msgs };
|
||||
}
|
||||
|
||||
_toggleTool(id) {
|
||||
const next = new Set(this._expandedTools);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
this._expandedTools = next;
|
||||
}
|
||||
|
||||
_toggleReason(id) {
|
||||
const next = new Set(this._expandedReasons);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
this._expandedReasons = next;
|
||||
}
|
||||
|
||||
// ── Renderers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_back() {
|
||||
history.back();
|
||||
}
|
||||
|
||||
_renderSessionHeader(session) {
|
||||
return html`
|
||||
<div class="sd-session-header">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<button class="btn btn-sm btn-outline-secondary sd-back-btn" @click=${() => this._back()}>
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</button>
|
||||
<span class="badge ${sourceBadgeClass(session.source)}">${session.source}</span>
|
||||
<span class="fw-semibold font-monospace">agent: ${session.agent_id}</span>
|
||||
<span class="text-secondary small">id: ${session.id}</span>
|
||||
${session.is_ephemeral ? html`<span class="badge bg-light text-dark border">ephemeral</span>` : nothing}
|
||||
${!session.is_interactive ? html`<span class="badge bg-light text-dark border">automated</span>` : nothing}
|
||||
${this._live
|
||||
? html`<span class="sd-live-badge"><span class="sd-live-dot"></span>live</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="text-secondary small mt-1">${formatDate(session.created_at)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderUserMsg(item, idx) {
|
||||
const time = formatTime(item.created_at);
|
||||
return html`
|
||||
<div class="sd-msg sd-msg--user ${item.is_synthetic ? 'sd-msg--synthetic' : ''}">
|
||||
<div class="sd-msg-role">
|
||||
${item.is_synthetic
|
||||
? html`<span class="badge bg-warning text-dark me-1" style="font-size:0.65rem">synthetic</span>`
|
||||
: nothing}
|
||||
<span>User</span>
|
||||
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
|
||||
</div>
|
||||
<div class="sd-msg-content">${item.content}</div>
|
||||
${item.failed ? html`<div class="sd-msg-failed">failed</div>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderAssistantMsg(item, idx) {
|
||||
const key = `ast-${idx}`;
|
||||
const hasReasoning = item.reasoning && item.reasoning.trim().length > 0;
|
||||
const expanded = this._expandedReasons.has(key);
|
||||
const time = formatTime(item.created_at);
|
||||
return html`
|
||||
<div class="sd-msg sd-msg--assistant ${item.failed ? 'sd-msg--failed' : ''}">
|
||||
<div class="sd-msg-role">
|
||||
Assistant
|
||||
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
|
||||
${item.input_tokens != null ? html`<span class="sd-tokens">${item.input_tokens}↑ ${item.output_tokens}↓</span>` : nothing}
|
||||
</div>
|
||||
${hasReasoning ? html`
|
||||
<div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}>
|
||||
<i class="bi bi-brain me-1"></i>
|
||||
Reasoning
|
||||
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
|
||||
</div>
|
||||
${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing}
|
||||
` : nothing}
|
||||
${item.content ? html`<div class="sd-msg-content">${item.content}</div>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderThinkingMsg(item, idx) {
|
||||
const key = `think-${item.message_id ?? idx}`;
|
||||
const hasReasoning = item.reasoning && item.reasoning.trim().length > 0;
|
||||
const expanded = this._expandedReasons.has(key);
|
||||
const time = formatTime(item.created_at);
|
||||
return html`
|
||||
<div class="sd-msg sd-msg--thinking ${item.failed ? 'sd-msg--failed' : ''}">
|
||||
<div class="sd-msg-role">
|
||||
<i class="bi bi-lightning-charge me-1"></i>Thinking
|
||||
${time ? html`<span class="sd-msg-time">${time}</span>` : nothing}
|
||||
${item.input_tokens != null ? html`<span class="sd-tokens">${item.input_tokens}↑ ${item.output_tokens}↓</span>` : nothing}
|
||||
</div>
|
||||
${hasReasoning ? html`
|
||||
<div class="sd-reasoning-toggle" @click=${() => this._toggleReason(key)}>
|
||||
<i class="bi bi-brain me-1"></i>
|
||||
Reasoning
|
||||
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-1"></i>
|
||||
</div>
|
||||
${expanded ? html`<pre class="sd-reasoning-block">${item.reasoning}</pre>` : nothing}
|
||||
` : nothing}
|
||||
${item.content ? html`<div class="sd-msg-content">${item.content}</div>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderToolMsg(item, idx) {
|
||||
const key = item.tool_call_id ?? idx;
|
||||
const expanded = this._expandedTools.has(key);
|
||||
const statusClass = { done: 'sd-tool--done', error: 'sd-tool--error', pending: 'sd-tool--pending' }[item.status] ?? '';
|
||||
return html`
|
||||
<div class="sd-tool ${statusClass}">
|
||||
<div class="sd-tool-header" @click=${() => this._toggleTool(key)}>
|
||||
<span class="sd-tool-icon">
|
||||
<i class="bi bi-${item.status === 'done' ? 'check-circle' : item.status === 'error' ? 'x-circle' : 'hourglass-split'}"></i>
|
||||
</span>
|
||||
<span class="sd-tool-name">${item.label_short ?? item.name}</span>
|
||||
<i class="bi bi-chevron-${expanded ? 'up' : 'down'} ms-auto"></i>
|
||||
</div>
|
||||
${expanded ? html`
|
||||
<div class="sd-tool-body">
|
||||
${item.label_full && item.label_full !== item.label_short
|
||||
? html`<div class="sd-tool-label-full text-secondary small mb-2">${item.label_full}</div>`
|
||||
: nothing}
|
||||
<div class="sd-tool-section-label">Arguments</div>
|
||||
<pre class="sd-code-block">${jsonPretty(item.arguments)}</pre>
|
||||
<div class="sd-tool-section-label mt-2">
|
||||
${item.status === 'error' ? 'Error' : 'Result'}
|
||||
</div>
|
||||
<pre class="sd-code-block ${item.status === 'error' ? 'sd-code-block--error' : ''}">${
|
||||
item.result ?? item.error ?? '—'
|
||||
}</pre>
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderAgentFrame(item) {
|
||||
return html`
|
||||
<div class="sd-agent-frame-start">
|
||||
<i class="bi bi-robot me-1"></i>
|
||||
<span>Sub-agent: <strong>${item.agent_id}</strong></span>
|
||||
<span class="text-secondary small ms-2">depth ${item.depth}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderAgentFrameEnd(item) {
|
||||
return html`<div class="sd-agent-frame-end">end of ${item.agent_id}</div>`;
|
||||
}
|
||||
|
||||
_renderMessage(item, idx) {
|
||||
switch (item.kind) {
|
||||
case 'user': return this._renderUserMsg(item, idx);
|
||||
case 'assistant': return this._renderAssistantMsg(item, idx);
|
||||
case 'thinking': return this._renderThinkingMsg(item, idx);
|
||||
case 'tool': return this._renderToolMsg(item, idx);
|
||||
case 'agent': return this._renderAgentFrame(item);
|
||||
case 'agent_end': return this._renderAgentFrameEnd(item);
|
||||
default: return nothing;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<style>
|
||||
.sd-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
gap: 0;
|
||||
}
|
||||
.sd-back-btn {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
.sd-session-header {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.sd-msg {
|
||||
border-left: 3px solid transparent;
|
||||
padding: 0.625rem 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
.sd-msg--user { border-left-color: var(--bs-primary); background: var(--bs-tertiary-bg); }
|
||||
.sd-msg--synthetic { opacity: 0.75; border-left-color: var(--bs-warning); }
|
||||
.sd-msg--assistant { border-left-color: var(--bs-success); }
|
||||
.sd-msg--thinking { border-left-color: var(--bs-info); background: var(--bs-tertiary-bg); }
|
||||
.sd-msg--failed { border-left-color: var(--bs-danger) !important; opacity: 0.7; }
|
||||
.sd-msg-role {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.sd-msg-time {
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
.sd-tokens {
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-left: auto;
|
||||
}
|
||||
.sd-msg-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.sd-msg-failed { font-size: 0.7rem; color: var(--bs-danger); margin-top: 0.25rem; }
|
||||
.sd-reasoning-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
color: var(--bs-secondary-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 999px;
|
||||
padding: 0.125rem 0.5rem;
|
||||
margin-bottom: 0.375rem;
|
||||
user-select: none;
|
||||
}
|
||||
.sd-reasoning-toggle:hover { background: var(--bs-tertiary-bg); }
|
||||
.sd-reasoning-block {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-bottom: 0.5rem;
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.sd-tool {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
.sd-tool--done { border-left: 3px solid var(--bs-success); }
|
||||
.sd-tool--error { border-left: 3px solid var(--bs-danger); }
|
||||
.sd-tool--pending { border-left: 3px solid var(--bs-warning); }
|
||||
.sd-tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sd-tool-header:hover { background: var(--bs-tertiary-bg); }
|
||||
.sd-tool-icon { color: var(--bs-secondary-color); }
|
||||
.sd-tool-name { font-family: var(--bs-font-monospace); font-size: 0.8rem; }
|
||||
.sd-tool-body {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
.sd-tool-section-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.sd-tool-label-full { font-style: italic; }
|
||||
.sd-code-block {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
.sd-code-block--error { border-color: var(--bs-danger); color: var(--bs-danger); }
|
||||
.sd-agent-frame-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--bs-secondary-color);
|
||||
border-top: 1px dashed var(--bs-border-color);
|
||||
padding: 0.375rem 0;
|
||||
margin: 0.25rem 0 0.25rem 1rem;
|
||||
}
|
||||
.sd-agent-frame-end {
|
||||
font-size: 0.72rem;
|
||||
color: var(--bs-secondary-color);
|
||||
border-bottom: 1px dashed var(--bs-border-color);
|
||||
padding: 0.25rem 0;
|
||||
margin: 0 0 0.375rem 1rem;
|
||||
}
|
||||
.sd-live-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-success);
|
||||
border: 1px solid var(--bs-success);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
.sd-live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--bs-success);
|
||||
animation: sd-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes sd-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.25; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="sd-container">
|
||||
${this._loading ? html`
|
||||
<div class="text-center text-secondary py-5">
|
||||
<div class="spinner-border spinner-border-sm me-2"></div>Loading session…
|
||||
</div>
|
||||
` : this._error ? html`
|
||||
<div class="alert alert-danger">${this._error}</div>
|
||||
` : !this._data ? html`
|
||||
<div class="text-secondary text-center py-5">No session loaded.<br>
|
||||
<span class="small">Navigate to <code>#session/{id}</code> to view a session.</span>
|
||||
</div>
|
||||
` : html`
|
||||
${this._renderSessionHeader(this._data.session)}
|
||||
${this._data.messages.length === 0
|
||||
? html`<div class="text-secondary text-center py-4">No messages in this session.</div>`
|
||||
: this._data.messages.map((m, i) => this._renderMessage(m, i))
|
||||
}
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { ChatSession } from '../../lib/chat-session.js';
|
||||
import { renderMsg, renderAttachmentChips } from '../copilot-render.js';
|
||||
|
||||
export class ChatPage extends ChatSession {
|
||||
static properties = {
|
||||
visible: { type: Boolean },
|
||||
// Target source. Defaults to the main mobile session; set to `project-{id}`
|
||||
// to bind this chat to a project's coordinator session.
|
||||
source: { type: String },
|
||||
// Human-readable label for the active source (e.g. the project name), shown
|
||||
// in the header when inside a project.
|
||||
label: { type: String },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.visible = false;
|
||||
this.source = 'mobile';
|
||||
this.label = '';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Honour the initial `source` prop on the first connect so a cold deep-link
|
||||
// (e.g. the native shell opening #chat/project-<id>) connects straight to it,
|
||||
// instead of connecting to the 'mobile' default and switching a tick later
|
||||
// (which would briefly open two WebSockets). Later `source` prop changes are
|
||||
// still handled by `updated` below.
|
||||
if (this.source && this.source !== this._wsSource) this._activeSource = this.source;
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (changed.has('visible') && this.visible) {
|
||||
this._scrollToBottom();
|
||||
}
|
||||
// The owner (mobile-app) re-points this chat by changing `source`. Switch the
|
||||
// live connection — base `_switchSource` tears down the WS, reloads that
|
||||
// source's history, and reconnects. The guard skips the initial no-op render.
|
||||
if (changed.has('source') && this.source !== this._source) {
|
||||
this._switchSource(this.source);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Source identity ────────────────────────────────────────────────────────
|
||||
|
||||
// Static fallback used only before the first `source` prop is applied.
|
||||
get _wsSource() { return 'mobile'; }
|
||||
|
||||
get _inProject() {
|
||||
return typeof this.source === 'string' && this.source.startsWith('project-');
|
||||
}
|
||||
|
||||
_exitProject() {
|
||||
this.dispatchEvent(new CustomEvent('project-exit', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
// ── DOM hooks ──────────────────────────────────────────────────────────────
|
||||
|
||||
_inputEl() {
|
||||
return this.querySelector('.chat-page-textarea');
|
||||
}
|
||||
|
||||
_scrollToBottom() {
|
||||
this.updateComplete.then(() => {
|
||||
const el = this.querySelector('.chat-page-messages');
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
_onMessagePushed(item) {
|
||||
if (item.kind === 'pending_write') {
|
||||
this.updateComplete.then(() => {
|
||||
const panels = this.querySelectorAll('.copilot-approval');
|
||||
const el = panels[panels.length - 1];
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
});
|
||||
} else {
|
||||
this._scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Input ──────────────────────────────────────────────────────────────────
|
||||
// Note: unlike the desktop copilot, Enter does NOT send here. On mobile there
|
||||
// is no practical Shift+Enter, so Enter inserts a newline (the textarea's
|
||||
// default) and the explicit send button is the only way to submit — making
|
||||
// multi-line messages possible.
|
||||
|
||||
// ── Toggle expand ──────────────────────────────────────────────────────────
|
||||
|
||||
_toggleExpand(id) {
|
||||
const next = new Set(this._expanded);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
this._expanded = next;
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
if (!this.visible) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="chat-page">
|
||||
|
||||
<div class="mobile-section-header">
|
||||
<span class="mobile-section-title">
|
||||
${this._inProject ? html`
|
||||
<button class="chat-page-back" title="Back to General"
|
||||
@click=${() => this._exitProject()}>
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<i class="bi bi-folder2-open"></i> ${this.label || 'Project'}
|
||||
` : html`<i class="bi bi-chat-dots-fill"></i> Chat`}
|
||||
</span>
|
||||
<div class="chat-page-header-actions">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
title="New conversation"
|
||||
@click=${() => this._startNewSession()}
|
||||
><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-page-messages">
|
||||
${this._messages.length === 0 ? html`
|
||||
<div class="chat-page-empty">
|
||||
<i class="bi bi-stars"></i>
|
||||
<p>Ask me anything</p>
|
||||
</div>
|
||||
` : this._messages.map(m => renderMsg(this, m))}
|
||||
|
||||
${this._waiting ? html`
|
||||
<div class="copilot-msg assistant copilot-thinking">
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Thinking…
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="chat-page-input-area">
|
||||
<div class="chat-page-composer"
|
||||
@dragover=${(e) => e.preventDefault()}
|
||||
@drop=${(e) => this._onDrop(e)}>
|
||||
${renderAttachmentChips(this, this._attachments, { removable: true })}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
class="chat-page-file-input"
|
||||
style="display:none"
|
||||
@change=${(e) => { this._addFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<textarea
|
||||
class="chat-page-textarea"
|
||||
rows="1"
|
||||
placeholder="Type a message…"
|
||||
@input=${(e) => this._autoResize(e.target)}
|
||||
@paste=${(e) => this._onPaste(e)}
|
||||
></textarea>
|
||||
<div class="chat-page-toolbar">
|
||||
<div class="chat-page-toolbar-left">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary chat-page-attach-btn"
|
||||
title="Attach files"
|
||||
@click=${() => this.querySelector('.chat-page-file-input')?.click()}
|
||||
><i class="bi bi-paperclip"></i></button>
|
||||
${this._providers.length > 1 ? html`
|
||||
<select
|
||||
class="chat-page-model-pill"
|
||||
.value=${this._selectedClient ?? 'auto'}
|
||||
@change=${(e) => { this._selectClient(e.target.value); }}
|
||||
>
|
||||
${this._providers.map(p => html`
|
||||
<option value=${p} ?selected=${p === (this._selectedClient ?? 'auto')}>${p}</option>
|
||||
`)}
|
||||
</select>
|
||||
` : nothing}
|
||||
</div>
|
||||
<div class="chat-page-toolbar-right">
|
||||
${this._hasTranscribe ? html`
|
||||
<button
|
||||
class="chat-page-mic-btn ${this._recording ? 'chat-page-mic-btn--recording' : ''}"
|
||||
title="${this._recording ? 'Stop recording' : 'Record voice'}"
|
||||
@click=${() => this._toggleRecording()}
|
||||
>
|
||||
<i class="bi ${this._recording ? 'bi-stop-circle-fill' : 'bi-mic-fill'}"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
${this._waiting
|
||||
? html`<button
|
||||
class="chat-page-send chat-page-send--stop"
|
||||
@click=${() => this._cancel()}
|
||||
title="Stop"
|
||||
><i class="bi bi-stop-fill"></i></button>`
|
||||
: nothing}
|
||||
<button
|
||||
class="chat-page-send"
|
||||
@click=${() => this._send()}
|
||||
title="Send"
|
||||
><i class="bi bi-send-fill"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('chat-page', ChatPage);
|
||||
@@ -0,0 +1,429 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { LightElement, renderMarkdown } from '../../lib/base.js';
|
||||
import { fileWatcher } from '../../lib/file-watcher.js';
|
||||
|
||||
/**
|
||||
* Shared file-viewer engine. Holds all of the fetch / kind-detection /
|
||||
* markdown-asset-rewriting / LaTeX-compile / live-watch logic plus `_renderBody`,
|
||||
* driven purely by two methods: `_show(path)` and `_hide()`. It carries no
|
||||
* navigation or page chrome of its own — subclasses (desktop `<file-viewer-page>`
|
||||
* and mobile `<mobile-file-viewer-page>`) wire visibility/path to those methods
|
||||
* and provide their own `render()` header.
|
||||
*/
|
||||
|
||||
const IMG_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'avif'];
|
||||
const LATEX_EXTS = ['tex', 'latex'];
|
||||
const TEXT_EXTS = [
|
||||
'txt', 'md', 'markdown', 'rs', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx',
|
||||
'py', 'json', 'yml', 'yaml', 'toml', 'sh', 'bash', 'zsh', 'fish',
|
||||
'css', 'scss', 'less',
|
||||
'sql', 'go', 'java', 'c', 'h', 'cpp', 'hpp', 'cc', 'kt', 'scala',
|
||||
'lua', 'pl', 'php', 'rb', 'swift', 'dart',
|
||||
'xml', 'csv', 'tsv', 'log', 'env', 'ini', 'cfg', 'conf',
|
||||
'gitignore', 'dockerignore', 'editorconfig',
|
||||
'vue', 'svelte', 'astro',
|
||||
// LaTeX is also kept here as the fallback when compilation fails — kindFor
|
||||
// still routes it to 'latex' so the viewer knows to attempt a compile first.
|
||||
'tex', 'latex',
|
||||
];
|
||||
|
||||
export function extOf(path) {
|
||||
if (!path) return '';
|
||||
const dot = path.lastIndexOf('.');
|
||||
if (dot < 0) return '';
|
||||
// Reject dots that are inside a directory segment, not the file extension.
|
||||
if (path.indexOf('/', dot + 1) >= 0) return '';
|
||||
return path.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
export function kindFor(path) {
|
||||
const ext = extOf(path);
|
||||
// SVG is excluded from IMG_EXTS on purpose: rendered in a sandboxed iframe
|
||||
// (not <img>), which both scales viewBox-only SVGs to fill the viewport and
|
||||
// isolates any embedded <script> from the host page.
|
||||
if (ext === 'svg') return 'svg';
|
||||
if (IMG_EXTS.includes(ext)) return 'image';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
// HTML is rendered live in a script-enabled but origin-isolated iframe
|
||||
// (srcdoc + sandbox="allow-scripts", no allow-same-origin) — see _renderBody.
|
||||
if (ext === 'html' || ext === 'htm') return 'html';
|
||||
if (LATEX_EXTS.includes(ext)) return 'latex';
|
||||
if (TEXT_EXTS.includes(ext)) return 'text';
|
||||
return 'binary';
|
||||
}
|
||||
|
||||
/** Directory portion of a path: `docs/guide.md` → `docs`, `guide.md` → ``. */
|
||||
function dirOf(path) {
|
||||
const i = path.lastIndexOf('/');
|
||||
return i < 0 ? '' : path.slice(0, i);
|
||||
}
|
||||
|
||||
/** Lexically resolve `.`/`..` segments, preserving a leading slash for absolute paths. */
|
||||
function normalizePath(p) {
|
||||
const abs = p.startsWith('/');
|
||||
const out = [];
|
||||
for (const seg of p.split('/')) {
|
||||
if (seg === '' || seg === '.') continue;
|
||||
if (seg === '..') { out.pop(); continue; }
|
||||
out.push(seg);
|
||||
}
|
||||
return (abs ? '/' : '') + out.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an asset reference found inside a markdown file. External URLs, data
|
||||
* URIs, protocol-relative and root-relative paths are left untouched; a path
|
||||
* relative to the markdown file's directory is routed through `/api/file` so it
|
||||
* loads from disk instead of resolving against the SPA origin.
|
||||
*/
|
||||
function resolveAssetSrc(src, baseDir) {
|
||||
if (!src || /^([a-z][a-z0-9+.-]*:|\/\/|#|\/)/i.test(src)) return src;
|
||||
const joined = baseDir ? `${baseDir}/${src}` : src;
|
||||
return `/api/file?path=${encodeURIComponent(normalizePath(joined))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite relative `<img>` sources in rendered markdown HTML so they resolve
|
||||
* against the markdown file's location on disk (via `/api/file`). Parsed in an
|
||||
* inert <template> so the original (broken) URLs never trigger a fetch.
|
||||
*/
|
||||
function rewriteMarkdownAssets(htmlStr, baseDir) {
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = htmlStr;
|
||||
let changed = false;
|
||||
for (const img of tpl.content.querySelectorAll('img[src]')) {
|
||||
const src = img.getAttribute('src');
|
||||
const resolved = resolveAssetSrc(src, baseDir);
|
||||
if (resolved !== src) { img.setAttribute('src', resolved); changed = true; }
|
||||
}
|
||||
return changed ? tpl.innerHTML : htmlStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distil a raw latexmk / xelatex log into its actionable error block.
|
||||
*
|
||||
* The 422 body carries the *full* log. Under `-file-line-error` the meaningful
|
||||
* `path:line: message` errors (and `! TeX error` lines) sit deep in the log —
|
||||
* the opening lines are only the engine banner and package preamble. Slicing
|
||||
* the first N characters therefore hid the real error; instead we extract the
|
||||
* error lines plus a few trailing context lines (LaTeX echoes the offending
|
||||
* source line right after) so the user can read it — or paste it straight into
|
||||
* an agent. Falls back to the log tail when no error line is recognised.
|
||||
*/
|
||||
function formatLatexError(log) {
|
||||
if (!log) return '';
|
||||
const lines = log.split('\n');
|
||||
const blocks = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/:\d+: /.test(lines[i]) || lines[i].startsWith('! ')) {
|
||||
blocks.push(lines.slice(i, i + 4).join('\n').trimEnd());
|
||||
}
|
||||
}
|
||||
const excerpt = blocks.join('\n\n').trim();
|
||||
if (excerpt) return excerpt;
|
||||
return (log.length > 4000 ? log.slice(-4000) : log).trim();
|
||||
}
|
||||
|
||||
export class FileViewerBase extends LightElement {
|
||||
static properties = {
|
||||
_path: { state: true },
|
||||
_kind: { state: true },
|
||||
_content: { state: true },
|
||||
_blobUrl: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
_compileError: { state: true },
|
||||
_htmlMode: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._path = null;
|
||||
this._kind = null;
|
||||
this._content = '';
|
||||
this._blobUrl = null;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._compileError = null;
|
||||
this._htmlMode = 'preview'; // HTML view: 'preview' (live iframe) | 'source'
|
||||
this._watchPath = null; // path currently being watched (async-verified)
|
||||
this._watchUnsub = null; // unsubscribe function returned by fileWatcher
|
||||
this._reloadTimer = null; // debounce timer for change-triggered reloads
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._teardownWatch();
|
||||
if (this._reloadTimer) clearTimeout(this._reloadTimer);
|
||||
this._revokeBlobUrl();
|
||||
}
|
||||
|
||||
// ── Drivers used by subclasses ──────────────────────────────────────────────
|
||||
|
||||
/** Show `path`: (re)subscribe the watcher and load it. No-op if unchanged. */
|
||||
_show(path) {
|
||||
if (!path) return;
|
||||
if (path === this._path && !this._error) return; // already loaded
|
||||
this._setupWatch(path);
|
||||
this._load(path);
|
||||
}
|
||||
|
||||
/** Hide: drop the content and release the watcher. */
|
||||
_hide() {
|
||||
this._reset();
|
||||
this._teardownWatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the current file. LaTeX sources always download the compiled PDF
|
||||
* (`compile-latex=true`); every kind is served with `force_download=true` so
|
||||
* the server sets `Content-Disposition: attachment` and the browser saves it
|
||||
* (with the server-supplied name) instead of rendering inline.
|
||||
*/
|
||||
_download() {
|
||||
const path = this._path;
|
||||
if (!path) return;
|
||||
const params = new URLSearchParams({ path });
|
||||
if (this._kind === 'latex') params.set('compile-latex', 'true');
|
||||
params.set('force_download', 'true');
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/file?${params.toString()}`;
|
||||
a.download = ''; // server Content-Disposition supplies the name
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
_revokeBlobUrl() {
|
||||
if (this._blobUrl) {
|
||||
URL.revokeObjectURL(this._blobUrl);
|
||||
this._blobUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
_reset() {
|
||||
this._path = null;
|
||||
this._kind = null;
|
||||
this._content = '';
|
||||
this._error = null;
|
||||
this._compileError = null;
|
||||
this._htmlMode = 'preview';
|
||||
this._revokeBlobUrl();
|
||||
}
|
||||
|
||||
async _load(path, silent = false) {
|
||||
if (!silent) {
|
||||
this._path = path;
|
||||
this._kind = kindFor(path);
|
||||
this._content = '';
|
||||
this._error = null;
|
||||
this._compileError = null;
|
||||
this._revokeBlobUrl();
|
||||
this._loading = true;
|
||||
} else {
|
||||
// Silent reload (file changed externally): keep showing the old content
|
||||
// until the new fetch lands; only update visible state on success.
|
||||
this._error = null;
|
||||
}
|
||||
try {
|
||||
const url = `/api/file?path=${encodeURIComponent(path)}`;
|
||||
if (this._kind === 'image' || this._kind === 'pdf' || this._kind === 'svg') {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
// Swap URLs only after the new blob is ready so the preview never flickers.
|
||||
const oldUrl = this._blobUrl;
|
||||
this._blobUrl = URL.createObjectURL(blob);
|
||||
if (oldUrl) URL.revokeObjectURL(oldUrl);
|
||||
} else if (this._kind === 'latex') {
|
||||
await this._loadLatex(path);
|
||||
} else if (this._kind === 'text' || this._kind === 'html') {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._content = await res.text();
|
||||
}
|
||||
// binary: nothing to fetch
|
||||
} catch (e) {
|
||||
this._error = e.message || String(e);
|
||||
} finally {
|
||||
if (!silent) this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a `.tex` / `.latex` file. Tries to compile to PDF server-side first;
|
||||
* on any non-OK response (422 compilation error, 501 no latexmk, etc.) it
|
||||
* falls back to showing the raw source as plain text, preserving the error
|
||||
* message so the user can see why the compile failed.
|
||||
*/
|
||||
async _loadLatex(path) {
|
||||
const compileUrl = `/api/file?path=${encodeURIComponent(path)}&compile-latex=true`;
|
||||
try {
|
||||
const res = await fetch(compileUrl);
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const oldUrl = this._blobUrl;
|
||||
this._blobUrl = URL.createObjectURL(blob);
|
||||
if (oldUrl) URL.revokeObjectURL(oldUrl);
|
||||
this._content = '';
|
||||
this._compileError = null;
|
||||
return;
|
||||
}
|
||||
// The 422 body is the full latexmk log. Extract the actionable error
|
||||
// block (see formatLatexError) instead of slicing from the top, which
|
||||
// under -file-line-error only shows the preamble and hides the real error.
|
||||
let detail = '';
|
||||
try { detail = formatLatexError(await res.text()); } catch { /* ignore */ }
|
||||
this._compileError = detail || `HTTP ${res.status}`;
|
||||
} catch (e) {
|
||||
this._compileError = e.message || String(e);
|
||||
}
|
||||
// Fallback: fetch the raw .tex source.
|
||||
this._revokeBlobUrl();
|
||||
const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._content = await res.text();
|
||||
}
|
||||
|
||||
// ── File watcher ────────────────────────────────────────────────────────────
|
||||
|
||||
_setupWatch(path) {
|
||||
this._teardownWatch();
|
||||
if (!path) return;
|
||||
this._watchPath = path;
|
||||
fileWatcher.watch(path, () => this._onFileChanged())
|
||||
.then(unsub => {
|
||||
// Race: if the path changed or the page closed while awaiting, release
|
||||
// the subscription immediately so the OS watcher is torn down.
|
||||
if (this._watchPath !== path) {
|
||||
try { unsub(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
this._watchUnsub = unsub;
|
||||
})
|
||||
.catch(() => { /* WS error; client auto-reconnects and re-subscribes */ });
|
||||
}
|
||||
|
||||
_teardownWatch() {
|
||||
if (this._reloadTimer) {
|
||||
clearTimeout(this._reloadTimer);
|
||||
this._reloadTimer = null;
|
||||
}
|
||||
if (this._watchUnsub) {
|
||||
try { this._watchUnsub(); } catch { /* ignore */ }
|
||||
this._watchUnsub = null;
|
||||
}
|
||||
this._watchPath = null;
|
||||
}
|
||||
|
||||
_onFileChanged() {
|
||||
// Debounce: collapse bursts of FS events into a single reload. `_watchPath`
|
||||
// is cleared by `_teardownWatch` (called on hide/path-change), so a queued
|
||||
// change never reloads a file the viewer has already navigated away from.
|
||||
if (this._reloadTimer) return;
|
||||
this._reloadTimer = setTimeout(() => {
|
||||
this._reloadTimer = null;
|
||||
const path = this._watchPath;
|
||||
if (path) this._load(path, true);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ── HTML preview/source toggle ──────────────────────────────────────────────
|
||||
|
||||
_toggleHtmlMode() {
|
||||
this._htmlMode = this._htmlMode === 'preview' ? 'source' : 'preview';
|
||||
}
|
||||
|
||||
/**
|
||||
* Header button that flips an HTML file between the live preview and its raw
|
||||
* source. Returns `nothing` for every other kind, so subclasses can drop it
|
||||
* unconditionally into their header. `btnClass` carries the chrome-specific
|
||||
* button styling (desktop vs mobile use different classes).
|
||||
*/
|
||||
_renderModeToggle(btnClass) {
|
||||
if (this._kind !== 'html') return nothing;
|
||||
const showingSource = this._htmlMode === 'source';
|
||||
return html`<button
|
||||
class=${btnClass}
|
||||
title=${showingSource ? 'Show preview' : 'Show source'}
|
||||
@click=${() => this._toggleHtmlMode()}>
|
||||
<i class="bi ${showingSource ? 'bi-eye' : 'bi-code-slash'}"></i>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// ── Body rendering (shared by both chromes) ─────────────────────────────────
|
||||
|
||||
_renderBody() {
|
||||
// Spinner while loading, and also in the pre-load window: the mobile viewer
|
||||
// is prop-driven, so Lit runs render() (visible just flipped true) before
|
||||
// `updated()` kicks off `_show()` — at that point no kind/content exists yet.
|
||||
if (this._loading || (!this._kind && !this._error)) {
|
||||
return html`<div class="fv-state"><span class="spinner-border"></span></div>`;
|
||||
}
|
||||
if (this._error) {
|
||||
return html`<div class="fv-state text-danger">
|
||||
<i class="bi bi-exclamation-triangle fs-3 d-block mb-2"></i>${this._error}
|
||||
</div>`;
|
||||
}
|
||||
if (this._kind === 'image' && this._blobUrl) {
|
||||
return html`<div class="fv-image-wrap"><img src=${this._blobUrl} alt=${this._path} class="fv-image" /></div>`;
|
||||
}
|
||||
if (this._kind === 'pdf' && this._blobUrl) {
|
||||
return html`<iframe class="fv-pdf" src=${this._blobUrl} title=${this._path}></iframe>`;
|
||||
}
|
||||
if (this._kind === 'latex' && this._blobUrl) {
|
||||
// Successfully compiled server-side — render the resulting PDF the same
|
||||
// way a native .pdf would be rendered.
|
||||
return html`<iframe class="fv-pdf" src=${this._blobUrl} title=${this._path}></iframe>`;
|
||||
}
|
||||
if (this._kind === 'svg' && this._blobUrl) {
|
||||
// `allow-same-origin` (and nothing else) is required so the iframe can load
|
||||
// the blob: URL — those are only readable from their creating origin. With
|
||||
// `allow-scripts` absent, any <script> inside the SVG still cannot execute,
|
||||
// so this stays an isolated, script-free render.
|
||||
return html`<div class="fv-image-wrap">
|
||||
<iframe class="fv-svg" sandbox="allow-same-origin" src=${this._blobUrl} title=${this._path}></iframe>
|
||||
</div>`;
|
||||
}
|
||||
if (this._kind === 'binary') {
|
||||
return html`<div class="fv-state text-muted">
|
||||
<i class="bi bi-file-earmark-binary fs-3 d-block mb-2"></i>
|
||||
Preview not available for this file type.
|
||||
</div>`;
|
||||
}
|
||||
if (this._kind === 'html') {
|
||||
if (this._htmlMode === 'source') {
|
||||
return html`<pre class="fv-code"><code>${this._content}</code></pre>`;
|
||||
}
|
||||
// Live render. `srcdoc` (not a blob: src) gives the frame a unique opaque
|
||||
// origin, so `allow-scripts` can run the page's JS while it stays fully
|
||||
// isolated from the app origin — no `allow-same-origin`, so it cannot read
|
||||
// cookies/localStorage or reach `/api/*`. Never add allow-same-origin here:
|
||||
// combined with allow-scripts it lets the frame remove its own sandbox.
|
||||
return html`<iframe
|
||||
class="fv-html"
|
||||
sandbox="allow-scripts allow-forms allow-modals allow-popups"
|
||||
srcdoc=${this._content}
|
||||
title=${this._path}></iframe>`;
|
||||
}
|
||||
const ext = extOf(this._path);
|
||||
if (ext === 'md' || ext === 'markdown') {
|
||||
const rendered = rewriteMarkdownAssets(renderMarkdown(this._content), dirOf(this._path || ''));
|
||||
return html`<div class="fv-md">${unsafeHTML(rendered)}</div>`;
|
||||
}
|
||||
if (this._kind === 'latex') {
|
||||
// Compile failed — show why, then fall back to the source.
|
||||
return html`
|
||||
${this._compileError
|
||||
? html`<details class="fv-compile-error">
|
||||
<summary><i class="bi bi-exclamation-triangle text-warning"></i> LaTeX compilation failed — showing source instead</summary>
|
||||
<pre>${this._compileError}</pre>
|
||||
</details>`
|
||||
: nothing}
|
||||
<pre class="fv-code"><code>${this._content}</code></pre>
|
||||
`;
|
||||
}
|
||||
return html`<pre class="fv-code"><code>${this._content}</code></pre>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { FileViewerBase } from './file-viewer-base.js';
|
||||
|
||||
/**
|
||||
* Mobile file-viewer page. Same engine as the desktop `<file-viewer-page>`, but
|
||||
* prop-driven: `<mobile-app>` binds `visible` / `path` from its hash router
|
||||
* (`#file_viewer?path=...`) instead of the component listening to the hash. The
|
||||
* back button returns to the previous mobile section via history.
|
||||
*/
|
||||
export class MobileFileViewerPage extends FileViewerBase {
|
||||
static properties = {
|
||||
visible: { type: Boolean },
|
||||
path: { type: String },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.visible = false;
|
||||
this.path = null;
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (changed.has('visible') || changed.has('path')) {
|
||||
if (this.visible && this.path) this._show(this.path);
|
||||
else if (!this.visible) this._hide();
|
||||
}
|
||||
}
|
||||
|
||||
_back() {
|
||||
history.back();
|
||||
}
|
||||
|
||||
// Filename portion of the path, for the compact mobile header title.
|
||||
_basename() {
|
||||
const p = this.path || '';
|
||||
const i = p.lastIndexOf('/');
|
||||
return i < 0 ? p : p.slice(i + 1);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) return nothing;
|
||||
return html`
|
||||
<div class="mobile-file-viewer">
|
||||
<div class="mobile-section-header">
|
||||
<span class="mobile-section-title">
|
||||
<button class="chat-page-back" title="Back" @click=${() => this._back()}>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
<span class="fv-mobile-name" title=${this.path ?? ''}><bdi>${this._basename()}</bdi></span>
|
||||
</span>
|
||||
<span class="fv-header-actions">
|
||||
${this._renderModeToggle('chat-page-back')}
|
||||
<button class="chat-page-back" title="Download" @click=${() => this._download()}>
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="fv-body">${this._renderBody()}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('mobile-file-viewer-page', MobileFileViewerPage);
|
||||
@@ -0,0 +1,271 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
|
||||
export class InboxPage extends LightElement {
|
||||
static properties = {
|
||||
visible: { type: Boolean },
|
||||
_data: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.visible = false;
|
||||
this._data = null;
|
||||
this._error = null;
|
||||
this._pollTimer = null;
|
||||
this._expanded = new Set();
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (!changed.has('visible')) return;
|
||||
if (this.visible) {
|
||||
this._load();
|
||||
this._startPolling();
|
||||
} else {
|
||||
this._stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopPolling();
|
||||
}
|
||||
|
||||
_startPolling() {
|
||||
this._stopPolling();
|
||||
this._pollTimer = setInterval(() => this._load(), 8000);
|
||||
}
|
||||
|
||||
_stopPolling() {
|
||||
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const res = await fetch('/api/inbox');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._data = await res.json();
|
||||
this._error = null;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _resolveApproval(requestId, action, note = '', toolCallId = null) {
|
||||
try {
|
||||
// Live items resolve by request_id; DB-persisted (post-restart) items carry
|
||||
// request_id 0 → resolve by the durable, source-agnostic tool_call_id.
|
||||
const url = requestId
|
||||
? `/api/inbox/approvals/${requestId}/resolve`
|
||||
: `/api/tools/${toolCallId}/resolve`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, note }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
_rejectWithNote(requestId, toolCallId) {
|
||||
const note = prompt('Rejection reason (optional):') ?? '';
|
||||
this._resolveApproval(requestId, 'reject', note, toolCallId);
|
||||
}
|
||||
|
||||
async _resolveClarification(requestId, inputEl) {
|
||||
const answer = inputEl.value.trim();
|
||||
if (!answer) return;
|
||||
try {
|
||||
const res = await fetch(`/api/inbox/clarifications/${requestId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answer }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
_toggleRaw(id) {
|
||||
if (this._expanded.has(id)) this._expanded.delete(id);
|
||||
else this._expanded.add(id);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_keyArgs(args) {
|
||||
const entries = [];
|
||||
for (const key of ['path', 'command', 'url', 'origin', 'destination', 'name', 'message', 'query']) {
|
||||
if (args[key] !== undefined) {
|
||||
let val = args[key];
|
||||
if (typeof val === 'object') val = JSON.stringify(val);
|
||||
entries.push({ key, value: String(val) });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
_fmt(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
_renderApprovalCard(item) {
|
||||
const id = `raw-${item.request_id}`;
|
||||
const open = this._expanded.has(id);
|
||||
const label = item.context_label ?? item.source;
|
||||
const args = item.arguments ?? {};
|
||||
const keyArgs = this._keyArgs(args);
|
||||
|
||||
return html`
|
||||
<div class="inbox-card approval-card">
|
||||
<div class="inbox-card-header">
|
||||
<span class="badge bg-warning text-dark">Approval</span>
|
||||
<span class="inbox-card-origin" title="${label}">${label}</span>
|
||||
<span class="inbox-card-time">${this._fmt(item.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-body">
|
||||
<div class="inbox-tool-name">
|
||||
<i class="bi bi-tools"></i>
|
||||
<strong>${item.tool_name}</strong>
|
||||
<span class="inbox-agent-tag">
|
||||
<i class="bi bi-person"></i> ${item.agent_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${keyArgs.length > 0 ? html`
|
||||
<div class="inbox-args-structured">
|
||||
${keyArgs.map(kv => html`
|
||||
<div class="inbox-arg-row">
|
||||
<span class="inbox-arg-key">${kv.key}</span>
|
||||
<span class="inbox-arg-value">${kv.value}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<button class="inbox-args-toggle" @click=${() => this._toggleRaw(id)}>
|
||||
<i class="bi ${open ? 'bi-chevron-up' : 'bi-chevron-down'}"></i>
|
||||
${open ? 'Hide raw JSON' : 'Show raw JSON'}
|
||||
</button>
|
||||
<pre class="inbox-args-raw ${open ? 'open' : ''}">${JSON.stringify(args, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-footer">
|
||||
<button class="inbox-btn inbox-btn-approve"
|
||||
@click=${() => this._resolveApproval(item.request_id, 'approve', '', item.tool_call_id)}>
|
||||
<i class="bi bi-check-lg"></i> Approve
|
||||
</button>
|
||||
<button class="inbox-btn inbox-btn-reject"
|
||||
@click=${() => this._rejectWithNote(item.request_id, item.tool_call_id)}>
|
||||
<i class="bi bi-x-lg"></i> Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderClarificationCard(item) {
|
||||
const label = item.context_label ?? item.source;
|
||||
|
||||
return html`
|
||||
<div class="inbox-card clarification-card">
|
||||
<div class="inbox-card-header">
|
||||
<span class="badge bg-info text-dark">Question</span>
|
||||
<span class="inbox-card-origin" title="${label}">${label}</span>
|
||||
<span class="inbox-card-time">${this._fmt(item.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-body">
|
||||
<div class="inbox-card-title">${item.title}</div>
|
||||
<div class="inbox-question">${item.question}</div>
|
||||
|
||||
${item.suggested_answers?.length ? html`
|
||||
<div class="inbox-chips">
|
||||
${item.suggested_answers.map(a => html`
|
||||
<button class="inbox-chip"
|
||||
@click=${(e) => {
|
||||
const inp = e.target.closest('.inbox-card')?.querySelector('.inbox-answer-input');
|
||||
if (inp) { inp.value = a; inp.focus(); }
|
||||
}}>${a}</button>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="inbox-answer-area">
|
||||
<textarea class="inbox-answer-input" rows="2" placeholder="Your answer…"
|
||||
@keydown=${(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this._resolveClarification(item.request_id, e.target);
|
||||
}
|
||||
}}></textarea>
|
||||
<button class="inbox-answer-send"
|
||||
@click=${(e) => {
|
||||
const inp = e.target.closest('.inbox-card')?.querySelector('.inbox-answer-input');
|
||||
if (inp) this._resolveClarification(item.request_id, inp);
|
||||
}}>
|
||||
<i class="bi bi-send"></i> Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) return nothing;
|
||||
|
||||
const approvals = this._data?.approvals ?? [];
|
||||
const clarifications = this._data?.clarifications ?? [];
|
||||
const total = approvals.length + clarifications.length;
|
||||
|
||||
return html`
|
||||
<div class="mobile-inbox">
|
||||
<div class="mobile-section-header">
|
||||
<span class="mobile-section-title">
|
||||
Inbox
|
||||
${total > 0 ? html`<span class="badge bg-danger ms-2">${total}</span>` : nothing}
|
||||
</span>
|
||||
<button class="inbox-refresh-btn" @click=${() => this._load()}>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="mobile-alert-error">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${total === 0 ? html`
|
||||
<div class="inbox-empty">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<p>No pending requests</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="mobile-inbox-list">
|
||||
${approvals.length > 0 ? html`
|
||||
<div class="inbox-section-label">
|
||||
Approvals <span class="badge bg-warning text-dark">${approvals.length}</span>
|
||||
</div>
|
||||
${approvals.map(item => this._renderApprovalCard(item))}
|
||||
` : nothing}
|
||||
|
||||
${clarifications.length > 0 ? html`
|
||||
<div class="inbox-section-label">
|
||||
Questions <span class="badge bg-info text-dark">${clarifications.length}</span>
|
||||
</div>
|
||||
${clarifications.map(item => this._renderClarificationCard(item))}
|
||||
` : nothing}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('inbox-page', InboxPage);
|
||||
@@ -0,0 +1,108 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
|
||||
/**
|
||||
* Mobile project list. Lists projects from `GET /api/projects`; tapping one opens
|
||||
* (or resumes) its coordinator session via `POST /api/projects/{id}/session` and
|
||||
* emits a `project-open` event so the shell can re-point the chat to that source.
|
||||
*
|
||||
* The session machinery is shared with the desktop copilot: the same `project-{id}`
|
||||
* source, the same provisioning (`project-coordinator` + project RunContext), so a
|
||||
* project chat is continuous across desktop, mobile browser, and the native shell.
|
||||
*/
|
||||
export class ProjectsPage extends LightElement {
|
||||
static properties = {
|
||||
visible: { type: Boolean },
|
||||
_data: { state: true },
|
||||
_error: { state: true },
|
||||
_loading: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.visible = false;
|
||||
this._data = null;
|
||||
this._error = null;
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (changed.has('visible') && this.visible) this._load();
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._loading = true;
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._data = await res.json();
|
||||
this._error = null;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _open(project) {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${project.id}/session`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { source } = await res.json();
|
||||
this.dispatchEvent(new CustomEvent('project-open', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { source, label: project.name },
|
||||
}));
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) return nothing;
|
||||
const projects = this._data ?? [];
|
||||
|
||||
return html`
|
||||
<div class="mobile-projects">
|
||||
<div class="mobile-section-header">
|
||||
<span class="mobile-section-title">
|
||||
<i class="bi bi-folder2-open"></i> Projects
|
||||
</span>
|
||||
<button class="inbox-refresh-btn" @click=${() => this._load()}>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="mobile-alert-error">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${projects.length === 0 ? html`
|
||||
<div class="inbox-empty">
|
||||
<i class="bi bi-folder"></i>
|
||||
<p>${this._loading ? 'Loading…' : 'No projects yet'}</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="mobile-projects-list">
|
||||
${projects.map(p => html`
|
||||
<div class="project-card" @click=${() => this._open(p)}>
|
||||
<div class="project-card-icon">
|
||||
<i class="bi bi-folder2-open"></i>
|
||||
</div>
|
||||
<div class="project-card-main">
|
||||
<div class="project-card-name">${p.name}</div>
|
||||
${p.description ? html`
|
||||
<div class="project-card-desc">${p.description}</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
<i class="bi bi-chevron-right project-card-chevron"></i>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('projects-page', ProjectsPage);
|
||||
@@ -0,0 +1,304 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
|
||||
export class AppSidebar extends LightElement {
|
||||
static properties = {
|
||||
_activePage: { state: true },
|
||||
_tasksSection: { state: true },
|
||||
_inboxCount: { state: true },
|
||||
_debugMode: { state: true },
|
||||
_recentProjects: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._activePage = null;
|
||||
this._tasksSection = 'running';
|
||||
this._inboxCount = 0;
|
||||
this._pollTimer = null;
|
||||
this._debugMode = false;
|
||||
this._recentProjects = [];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('popstate', (e) => {
|
||||
const page = e.state?.page ?? this._pageFromHash();
|
||||
if (page === 'tasks') this._tasksSection = this._tasksSectionFromHash();
|
||||
this._applyPage(page);
|
||||
});
|
||||
window.addEventListener('hashchange', () => {
|
||||
const page = this._pageFromHash();
|
||||
if (page === 'tasks') this._tasksSection = this._tasksSectionFromHash();
|
||||
this._applyPage(page);
|
||||
});
|
||||
window.addEventListener('inbox-count', (e) => {
|
||||
this._inboxCount = e.detail.count;
|
||||
});
|
||||
window.addEventListener('debug-mode-change', (e) => {
|
||||
this._debugMode = e.detail.enabled;
|
||||
});
|
||||
// On load: home (root) if no hash, otherwise the matching page
|
||||
setTimeout(() => {
|
||||
const page = this._pageFromHash();
|
||||
if (page === 'tasks') this._tasksSection = this._tasksSectionFromHash();
|
||||
this._applyPage(page);
|
||||
}, 0);
|
||||
// Poll inbox count independently of whether the page is open.
|
||||
this._pollInbox();
|
||||
this._pollTimer = setInterval(() => this._pollInbox(), 10000);
|
||||
this._loadDebugMode();
|
||||
this._loadRecentProjects();
|
||||
window.addEventListener('project-updated', () => this._loadRecentProjects());
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
clearInterval(this._pollTimer);
|
||||
}
|
||||
|
||||
async _loadDebugMode() {
|
||||
try {
|
||||
const res = await fetch('/api/dev/debug_mode');
|
||||
if (res.ok) this._debugMode = (await res.json()).enabled;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async _loadRecentProjects() {
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
if (!res.ok) return;
|
||||
const projects = await res.json();
|
||||
this._recentProjects = projects
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))
|
||||
.slice(0, 5);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async _openProjectChat(projectId, projectName, e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/session`, { method: 'POST' });
|
||||
if (!res.ok) return;
|
||||
const { source } = await res.json();
|
||||
window.dispatchEvent(new CustomEvent('project-chat-open', {
|
||||
detail: { source, label: projectName },
|
||||
}));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async _pollInbox() {
|
||||
try {
|
||||
const res = await fetch('/api/inbox');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this._inboxCount = data.total ?? 0;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_pageFromHash() {
|
||||
const hash = location.hash.slice(1);
|
||||
if (!hash) return 'home';
|
||||
// Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`).
|
||||
const match = hash.match(/^([^/?]+)/);
|
||||
const segment = match ? match[1] : '';
|
||||
return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home';
|
||||
}
|
||||
|
||||
_tasksSectionFromHash() {
|
||||
const parts = location.hash.slice(1).split('/');
|
||||
if (parts[0] === 'tasks' && parts[1]) {
|
||||
return ['running', 'cron', 'scheduled', 'history'].includes(parts[1]) ? parts[1] : 'running';
|
||||
}
|
||||
return 'running';
|
||||
}
|
||||
|
||||
_applyPage(page) {
|
||||
this._activePage = page;
|
||||
window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } }));
|
||||
}
|
||||
|
||||
_togglePage(page, e) {
|
||||
e.preventDefault();
|
||||
if (page === 'home') {
|
||||
history.pushState({ page: 'home' }, '', location.pathname + location.search);
|
||||
this._applyPage('home');
|
||||
return;
|
||||
}
|
||||
if (this._activePage === page) {
|
||||
// In a sub-section (e.g. #models/image) → go back to page root
|
||||
if (location.hash.slice(1) !== page) {
|
||||
history.pushState({ page }, '', '#' + page);
|
||||
this._applyPage(page);
|
||||
}
|
||||
return;
|
||||
}
|
||||
history.pushState({ page }, '', '#' + page);
|
||||
this._applyPage(page);
|
||||
}
|
||||
|
||||
_navigateTasksSection(sec, e) {
|
||||
e.preventDefault();
|
||||
this._tasksSection = sec;
|
||||
history.pushState({ page: 'tasks', section: sec }, '', '#tasks/' + sec);
|
||||
if (this._activePage !== 'tasks') {
|
||||
this._applyPage('tasks');
|
||||
} else {
|
||||
// page already open — tell the TasksPage to switch section
|
||||
window.dispatchEvent(new CustomEvent('tasks-section-change', { detail: { section: sec } }));
|
||||
}
|
||||
}
|
||||
|
||||
_openTaskManager(e) {
|
||||
e.preventDefault();
|
||||
if (this._activePage === 'tasks') return; // already open, submenu visible
|
||||
const sec = this._tasksSection || 'cron';
|
||||
history.pushState({ page: 'tasks', section: sec }, '', '#tasks/' + sec);
|
||||
this._applyPage('tasks');
|
||||
}
|
||||
|
||||
_renderTasksMenu() {
|
||||
const active = this._activePage === 'tasks';
|
||||
const sec = this._tasksSection;
|
||||
return html`
|
||||
<a href="#tasks/cron"
|
||||
class="sidebar-link ${active ? 'active' : ''}"
|
||||
@click=${(e) => this._openTaskManager(e)}>
|
||||
<i class="bi bi-lightning-charge"></i>
|
||||
<span class="sidebar-link-name">Task Manager</span>
|
||||
<i class="bi bi-chevron-${active ? 'up' : 'down'} sidebar-link-chevron"></i>
|
||||
</a>
|
||||
${active ? html`
|
||||
<div class="sidebar-submenu">
|
||||
<a href="#tasks/running"
|
||||
class="sidebar-sublink ${sec === 'running' ? 'active' : ''}"
|
||||
@click=${(e) => this._navigateTasksSection('running', e)}>
|
||||
<i class="bi bi-activity"></i> Running Tasks
|
||||
</a>
|
||||
<a href="#tasks/cron"
|
||||
class="sidebar-sublink ${sec === 'cron' ? 'active' : ''}"
|
||||
@click=${(e) => this._navigateTasksSection('cron', e)}>
|
||||
<i class="bi bi-repeat"></i> Cron Jobs
|
||||
</a>
|
||||
<a href="#tasks/scheduled"
|
||||
class="sidebar-sublink ${sec === 'scheduled' ? 'active' : ''}"
|
||||
@click=${(e) => this._navigateTasksSection('scheduled', e)}>
|
||||
<i class="bi bi-clock"></i> Scheduled Tasks
|
||||
</a>
|
||||
<a href="#tasks/history"
|
||||
class="sidebar-sublink ${sec === 'history' ? 'active' : ''}"
|
||||
@click=${(e) => this._navigateTasksSection('history', e)}>
|
||||
<i class="bi bi-journal-text"></i> History
|
||||
</a>
|
||||
</div>
|
||||
` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
_renderRecentProjects() {
|
||||
if (!this._recentProjects.length) return nothing;
|
||||
return html`
|
||||
<div class="sidebar-submenu">
|
||||
${this._recentProjects.map(p => html`
|
||||
<div class="sidebar-project-link"
|
||||
@click=${(e) => { e.preventDefault(); history.pushState({ page: 'projects' }, '', '#projects'); this._applyPage('projects'); window.dispatchEvent(new CustomEvent('sidebar-open-project', { detail: { id: p.id } })); }}>
|
||||
<i class="bi bi-folder2" style="font-size:0.78rem;opacity:0.65;flex-shrink:0"></i>
|
||||
<span class="sidebar-project-name">${p.name}</span>
|
||||
<button class="sidebar-project-chat-btn"
|
||||
title="Open chat"
|
||||
@click=${(e) => this._openProjectChat(p.id, p.name, e)}>
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="sidebar-brand">
|
||||
<img src="/assets/icons/icon-1024.png" alt="" class="sidebar-brand-icon" />
|
||||
<span>Skald</span>
|
||||
</div>
|
||||
|
||||
<hr class="sidebar-divider" />
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'home' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('home', e)}>
|
||||
<i class="bi bi-house-door"></i>
|
||||
<span class="sidebar-link-name">Home</span>
|
||||
</a>
|
||||
|
||||
<a href="#inbox" class="sidebar-link ${this._activePage === 'inbox' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('inbox', e)}>
|
||||
<i class="bi bi-inbox"></i>
|
||||
<span class="sidebar-link-name">
|
||||
Inbox
|
||||
${this._inboxCount > 0
|
||||
? html`<span class="badge bg-danger ms-1" style="font-size:0.65rem">${this._inboxCount}</span>`
|
||||
: ''}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a href="#projects"
|
||||
class="sidebar-link ${this._activePage === 'projects' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('projects', e)}>
|
||||
<i class="bi bi-kanban"></i>
|
||||
<span class="sidebar-link-name">Projects</span>
|
||||
</a>
|
||||
${this._renderRecentProjects()}
|
||||
|
||||
${this._renderTasksMenu()}
|
||||
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'models' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('models', e)}>
|
||||
<i class="bi bi-cpu"></i>
|
||||
<span class="sidebar-link-name">Models</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'providers' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('providers', e)}>
|
||||
<i class="bi bi-plug"></i>
|
||||
<span class="sidebar-link-name">Providers</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'approval' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('approval', e)}>
|
||||
<i class="bi bi-shield-check"></i>
|
||||
<span class="sidebar-link-name">Security</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'agents' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('agents', e)}>
|
||||
<i class="bi bi-people"></i>
|
||||
<span class="sidebar-link-name">Agents</span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link ${this._activePage === 'config' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('config', e)}>
|
||||
<i class="bi bi-gear"></i>
|
||||
<span class="sidebar-link-name">Config</span>
|
||||
</a>
|
||||
|
||||
${this._debugMode ? html`
|
||||
<hr class="sidebar-divider" />
|
||||
<a href="#llm-requests"
|
||||
class="sidebar-link ${this._activePage === 'llm-requests' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('llm-requests', e)}>
|
||||
<i class="bi bi-journal-code"></i>
|
||||
<span class="sidebar-link-name">LLM Requests</span>
|
||||
</a>
|
||||
<a href="#tic"
|
||||
class="sidebar-link ${this._activePage === 'tic' ? 'active' : ''}"
|
||||
@click=${(e) => this._togglePage('tic', e)}>
|
||||
<i class="bi bi-bell"></i>
|
||||
<span class="sidebar-link-name">TIC Sessions</span>
|
||||
</a>
|
||||
` : nothing}
|
||||
</nav>
|
||||
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { toString as cronToString } from 'cronstrue';
|
||||
import { formatDate } from './utils.js';
|
||||
|
||||
export class CronJobsSection extends LightElement {
|
||||
static properties = {
|
||||
_jobs: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._jobs = [];
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/cron/jobs');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const allJobs = await res.json();
|
||||
this._jobs = allJobs.filter(j => j.kind === 'cron' && !j.single_run);
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _delete(job) {
|
||||
if (!confirm(`Delete job "${job.title}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/cron/jobs/${job.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this.load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
async _toggle(job) {
|
||||
try {
|
||||
const res = await fetch(`/api/cron/jobs/${job.id}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !job.enabled }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this.load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
_statusBadge(job) {
|
||||
if (job.running_session_id != null)
|
||||
return html`<span class="task-badge task-badge--running">running</span>`;
|
||||
if (!job.enabled)
|
||||
return html`<span class="task-badge task-badge--disabled">disabled</span>`;
|
||||
return html`<span class="task-badge task-badge--idle">idle</span>`;
|
||||
}
|
||||
|
||||
_renderCard(job) {
|
||||
return html`
|
||||
<div class="task-card ${job.enabled ? '' : 'task-card--disabled'}">
|
||||
<div class="task-card-header">
|
||||
<div class="task-card-title-row">
|
||||
<span class="task-card-title">${job.title}</span>
|
||||
${this._statusBadge(job)}
|
||||
</div>
|
||||
<button class="task-card-delete" title="Delete" @click=${() => this._delete(job)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${job.description ? html`<div class="task-card-desc">${job.description}</div>` : nothing}
|
||||
|
||||
<div class="task-card-expr">
|
||||
<i class="bi bi-clock"></i>
|
||||
<div class="task-card-expr-text">
|
||||
<span class="task-card-human">${cronToString(job.cron)}</span>
|
||||
<code class="task-card-raw">${job.cron}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-card-meta">
|
||||
<div class="task-card-meta-item">
|
||||
<span class="task-card-meta-label">Agent</span>
|
||||
<span class="task-card-meta-value">${job.agent_id}</span>
|
||||
</div>
|
||||
<div class="task-card-meta-item">
|
||||
<span class="task-card-meta-label">Last run</span>
|
||||
<span class="task-card-meta-value">${formatDate(job.last_run_at)}</span>
|
||||
</div>
|
||||
<div class="task-card-meta-item">
|
||||
<span class="task-card-meta-label">Next run</span>
|
||||
<span class="task-card-meta-value">${formatDate(job.next_run_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-card-footer">
|
||||
<div class="form-check form-switch mb-0 task-card-toggle">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
.checked=${job.enabled}
|
||||
@change=${() => this._toggle(job)} />
|
||||
<span class="task-card-toggle-label">${job.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="task-page">
|
||||
<div class="task-page-header">
|
||||
<h2 class="task-page-title"><i class="bi bi-repeat"></i> Cron Jobs</h2>
|
||||
<div style="font-size:0.82rem;color:var(--bs-secondary-color)">
|
||||
${this._jobs.length} job${this._jobs.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${this._jobs.length === 0 ? html`
|
||||
<div class="task-empty">
|
||||
<i class="bi bi-repeat"></i>
|
||||
<p>No recurring cron jobs. Ask the agent to create one with <code>execute_task</code>.</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="task-grid">
|
||||
${this._jobs.map(j => this._renderCard(j))}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { formatDate, formatDuration } from './utils.js';
|
||||
|
||||
export class TaskHistorySection extends LightElement {
|
||||
static properties = {
|
||||
_runs: { state: true },
|
||||
_error: { state: true },
|
||||
_loading: { state: true },
|
||||
_expanded: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._runs = [];
|
||||
this._error = null;
|
||||
this._loading = false;
|
||||
this._expanded = null;
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._error = null;
|
||||
this._loading = true;
|
||||
try {
|
||||
const res = await fetch('/api/cron/runs');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._runs = await res.json();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_statusClass(status) {
|
||||
return { completed: 'success', failed: 'danger', cancelled: 'warning' }[status] ?? 'secondary';
|
||||
}
|
||||
|
||||
_toggleExpand(id) {
|
||||
this._expanded = this._expanded === id ? null : id;
|
||||
}
|
||||
|
||||
_renderRow(run) {
|
||||
const isExpanded = this._expanded === run.id;
|
||||
return html`
|
||||
<tr class="task-history-row ${isExpanded ? 'task-history-row--expanded' : ''}"
|
||||
@click=${() => this._toggleExpand(run.id)}>
|
||||
<td>
|
||||
<span class="badge bg-${this._statusClass(run.status)}">${run.status}</span>
|
||||
</td>
|
||||
<td class="task-history-title">${run.job_title ?? `Job #${run.job_id}`}</td>
|
||||
<td>${run.agent_id ?? '—'}</td>
|
||||
<td>${formatDate(run.completed_at)}</td>
|
||||
<td>${formatDuration(run.duration_ms)}</td>
|
||||
<td><i class="bi bi-chevron-${isExpanded ? 'up' : 'down'} task-history-chevron"></i></td>
|
||||
</tr>
|
||||
${isExpanded ? html`
|
||||
<tr class="task-history-detail">
|
||||
<td colspan="6">
|
||||
${run.session_id != null ? html`
|
||||
<div style="margin-bottom:0.5rem">
|
||||
<a href="#session/${run.session_id}"
|
||||
style="font-size:0.8rem;font-family:var(--bs-font-monospace)">
|
||||
<i class="bi bi-chat-text me-1"></i>View session #${run.session_id}
|
||||
</a>
|
||||
</div>
|
||||
` : nothing}
|
||||
${run.error ? html`
|
||||
<div class="task-history-error"><strong>Error:</strong> ${run.error}</div>
|
||||
` : nothing}
|
||||
${run.final_response ? html`
|
||||
<div class="task-history-response"><pre>${run.final_response}</pre></div>
|
||||
` : nothing}
|
||||
${!run.error && !run.final_response ? html`
|
||||
<div class="text-muted" style="font-size:0.82rem">No output recorded.</div>
|
||||
` : nothing}
|
||||
</td>
|
||||
</tr>
|
||||
` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="task-page">
|
||||
<div class="task-page-header">
|
||||
<h2 class="task-page-title"><i class="bi bi-journal-text"></i> History</h2>
|
||||
<div style="font-size:0.82rem;color:var(--bs-secondary-color)">
|
||||
${this._runs.length} run${this._runs.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-2" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${this._loading ? html`
|
||||
<div class="task-empty"><i class="bi bi-hourglass-split"></i><p>Loading…</p></div>
|
||||
` : this._runs.length === 0 ? html`
|
||||
<div class="task-empty">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
<p>No completed runs yet.</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="task-history-table-wrap">
|
||||
<table class="task-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Task</th>
|
||||
<th>Agent</th>
|
||||
<th>Completed</th>
|
||||
<th>Duration</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this._runs.map(r => this._renderRow(r))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { RunningTasksSection } from './running.js';
|
||||
import { CronJobsSection } from './cron.js';
|
||||
import { ScheduledTasksSection } from './scheduled.js';
|
||||
import { TaskHistorySection } from './history.js';
|
||||
|
||||
const SECTIONS = ['running', 'cron', 'scheduled', 'history'];
|
||||
|
||||
export class TasksPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_section: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._section = 'running';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
const open = e.detail.page === 'tasks';
|
||||
this._open = open;
|
||||
this.style.display = open ? 'flex' : 'none';
|
||||
if (open) {
|
||||
const sec = this._sectionFromHash();
|
||||
this._section = sec;
|
||||
this._loadSection(sec);
|
||||
if (!location.hash.includes('/')) {
|
||||
history.replaceState({ page: 'tasks', section: sec }, '', '#tasks/' + sec);
|
||||
}
|
||||
}
|
||||
});
|
||||
window.addEventListener('tasks-section-change', (e) => {
|
||||
if (!this._open) return;
|
||||
const sec = e.detail.section;
|
||||
this._section = sec;
|
||||
this._loadSection(sec);
|
||||
});
|
||||
}
|
||||
|
||||
_sectionFromHash() {
|
||||
const parts = location.hash.slice(1).split('/');
|
||||
if (parts[0] === 'tasks' && parts[1]) {
|
||||
return SECTIONS.includes(parts[1]) ? parts[1] : 'running';
|
||||
}
|
||||
return 'running';
|
||||
}
|
||||
|
||||
_loadSection(sec) {
|
||||
this.updateComplete.then(() => {
|
||||
const el = this.querySelector(`[data-section="${sec}"]`);
|
||||
if (el?.load) el.load();
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
return html`
|
||||
${this._section === 'running'
|
||||
? html`<task-running-section data-section="running"></task-running-section>`
|
||||
: nothing}
|
||||
${this._section === 'cron'
|
||||
? html`<task-cron-jobs-section data-section="cron"></task-cron-jobs-section>`
|
||||
: nothing}
|
||||
${this._section === 'scheduled'
|
||||
? html`<task-scheduled-section data-section="scheduled"></task-scheduled-section>`
|
||||
: nothing}
|
||||
${this._section === 'history'
|
||||
? html`<task-history-section data-section="history"></task-history-section>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('task-running-section', RunningTasksSection);
|
||||
customElements.define('task-cron-jobs-section', CronJobsSection);
|
||||
customElements.define('task-scheduled-section', ScheduledTasksSection);
|
||||
customElements.define('task-history-section', TaskHistorySection);
|
||||
@@ -0,0 +1,151 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { formatDate, formatElapsed } from './utils.js';
|
||||
|
||||
export class RunningTasksSection extends LightElement {
|
||||
static properties = {
|
||||
_jobs: { state: true },
|
||||
_error: { state: true },
|
||||
_tick: { state: true },
|
||||
_killing: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._jobs = [];
|
||||
this._error = null;
|
||||
this._tick = 0;
|
||||
this._killing = new Set();
|
||||
this._timer = null;
|
||||
this._pollTimer = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._timer = setInterval(() => { this._tick++; }, 1000);
|
||||
this._pollTimer = setInterval(() => this.load(), 10000);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
clearInterval(this._timer);
|
||||
clearInterval(this._pollTimer);
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/cron/jobs');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const all = await res.json();
|
||||
this._jobs = all.filter(j => j.running_session_id != null);
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _kill(job) {
|
||||
const confirmed = window.confirm(
|
||||
`Terminate task "${job.title}"?\n\nThe task will be stopped immediately and recorded as failed.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
this._killing = new Set([...this._killing, job.id]);
|
||||
try {
|
||||
const res = await fetch(`/api/cron/jobs/${job.id}/kill`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
this._error = `Failed to terminate task: ${e.message}`;
|
||||
} finally {
|
||||
const next = new Set(this._killing);
|
||||
next.delete(job.id);
|
||||
this._killing = next;
|
||||
}
|
||||
}
|
||||
|
||||
_kindLabel(job) {
|
||||
if (job.kind === 'cron' && !job.single_run)
|
||||
return html`<span class="task-badge task-badge--cron">cron</span>`;
|
||||
if (job.kind === 'cron' && job.single_run)
|
||||
return html`<span class="task-badge task-badge--oneshot">one-shot</span>`;
|
||||
return html`<span class="task-badge task-badge--async">async</span>`;
|
||||
}
|
||||
|
||||
_renderCard(job) {
|
||||
const killing = this._killing.has(job.id);
|
||||
return html`
|
||||
<div class="task-card task-card--running">
|
||||
<div class="task-card-header">
|
||||
<div class="task-card-title-row">
|
||||
<span class="task-card-title">${job.title}</span>
|
||||
${this._kindLabel(job)}
|
||||
<span class="task-badge task-badge--running">running</span>
|
||||
</div>
|
||||
<button
|
||||
class="task-kill-btn"
|
||||
title="Terminate task"
|
||||
?disabled=${killing}
|
||||
@click=${() => this._kill(job)}
|
||||
>
|
||||
${killing
|
||||
? html`<i class="bi bi-hourglass-split"></i>`
|
||||
: html`<i class="bi bi-x-lg"></i>`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${job.description ? html`<div class="task-card-desc">${job.description}</div>` : nothing}
|
||||
|
||||
<div class="task-running-elapsed">
|
||||
<i class="bi bi-stopwatch"></i>
|
||||
<span>${formatElapsed(job.running_since)}</span>
|
||||
<span class="task-running-since">since ${formatDate(job.running_since)}</span>
|
||||
</div>
|
||||
|
||||
<div class="task-card-meta">
|
||||
<div class="task-card-meta-item">
|
||||
<span class="task-card-meta-label">Agent</span>
|
||||
<span class="task-card-meta-value">${job.agent_id}</span>
|
||||
</div>
|
||||
<div class="task-card-meta-item">
|
||||
<span class="task-card-meta-label">Session</span>
|
||||
<a href="#session/${job.running_session_id}"
|
||||
class="task-card-meta-value task-card-session-link">#${job.running_session_id}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
void this._tick; // drives re-render every second for live elapsed time
|
||||
return html`
|
||||
<div class="task-page">
|
||||
<div class="task-page-header">
|
||||
<h2 class="task-page-title"><i class="bi bi-activity"></i> Running Tasks</h2>
|
||||
<div style="font-size:0.82rem;color:var(--bs-secondary-color)">
|
||||
${this._jobs.length} running
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${this._jobs.length === 0 ? html`
|
||||
<div class="task-empty">
|
||||
<i class="bi bi-activity"></i>
|
||||
<p>No tasks currently running.</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="task-grid">
|
||||
${this._jobs.map(j => this._renderCard(j))}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { toString as cronToString } from 'cronstrue';
|
||||
import { formatDate } from './utils.js';
|
||||
|
||||
export class ScheduledTasksSection extends LightElement {
|
||||
static properties = {
|
||||
_jobs: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._jobs = [];
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._error = null;
|
||||
try {
|
||||
const res = await fetch('/api/cron/jobs');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const all = await res.json();
|
||||
// one-shot scheduled or async/immediate, still active (pending or running)
|
||||
this._jobs = all.filter(j =>
|
||||
(j.kind !== 'cron' || j.single_run) &&
|
||||
(j.enabled || j.running_session_id != null)
|
||||
);
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async _delete(job) {
|
||||
if (!confirm(`Delete task "${job.title}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/cron/jobs/${job.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this.load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
_statusBadge(job) {
|
||||
if (job.running_session_id != null)
|
||||
return html`<span class="task-badge task-badge--running">running</span>`;
|
||||
if (job.kind === 'cron' && job.single_run)
|
||||
return html`<span class="task-badge task-badge--pending">pending</span>`;
|
||||
return html`<span class="task-badge task-badge--pending">queued</span>`;
|
||||
}
|
||||
|
||||
_kindLabel(job) {
|
||||
if (job.kind === 'cron' && job.single_run)
|
||||
return html`<span class="task-badge task-badge--oneshot">one-shot</span>`;
|
||||
if (job.kind === 'async')
|
||||
return html`<span class="task-badge task-badge--async">async</span>`;
|
||||
return nothing;
|
||||
}
|
||||
|
||||
_renderCard(job) {
|
||||
return html`
|
||||
<div class="task-card">
|
||||
<div class="task-card-header">
|
||||
<div class="task-card-title-row">
|
||||
<span class="task-card-title">${job.title}</span>
|
||||
${this._kindLabel(job)}
|
||||
${this._statusBadge(job)}
|
||||
</div>
|
||||
<button class="task-card-delete" title="Delete" @click=${() => this._delete(job)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${job.description ? html`<div class="task-card-desc">${job.description}</div>` : nothing}
|
||||
|
||||
${job.kind === 'cron' && job.cron ? html`
|
||||
<div class="task-card-expr">
|
||||
<i class="bi bi-clock"></i>
|
||||
<div class="task-card-expr-text">
|
||||
<span class="task-card-human">${cronToString(job.cron)}</span>
|
||||
<code class="task-card-raw">${job.cron}</code>
|
||||
</div>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="task-card-meta">
|
||||
<div class="task-card-meta-item">
|
||||
<span class="task-card-meta-label">Agent</span>
|
||||
<span class="task-card-meta-value">${job.agent_id}</span>
|
||||
</div>
|
||||
<div class="task-card-meta-item">
|
||||
<span class="task-card-meta-label">${job.next_run_at ? 'Scheduled at' : 'Created'}</span>
|
||||
<span class="task-card-meta-value">${formatDate(job.next_run_at || job.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="task-page">
|
||||
<div class="task-page-header">
|
||||
<h2 class="task-page-title"><i class="bi bi-clock"></i> Scheduled Tasks</h2>
|
||||
<div style="font-size:0.82rem;color:var(--bs-secondary-color)">
|
||||
${this._jobs.length} task${this._jobs.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error ? html`
|
||||
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
|
||||
` : nothing}
|
||||
|
||||
${this._jobs.length === 0 ? html`
|
||||
<div class="task-empty">
|
||||
<i class="bi bi-clock"></i>
|
||||
<p>No pending or running tasks.</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="task-grid">
|
||||
${this._jobs.map(j => this._renderCard(j))}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDuration(ms) {
|
||||
if (ms == null) return '—';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const m = Math.floor(ms / 60000);
|
||||
const s = Math.round((ms % 60000) / 1000);
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
// SQLite stores UTC without timezone suffix — normalise before parsing.
|
||||
export function parseUtc(iso) {
|
||||
if (!iso) return null;
|
||||
return new Date(iso.replace(' ', 'T') + 'Z');
|
||||
}
|
||||
|
||||
export function formatElapsed(since) {
|
||||
const d = parseUtc(since);
|
||||
if (!d) return '—';
|
||||
const s = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ${s % 60}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
const PAGE_ID = 'tic';
|
||||
const PER_PAGE = 20;
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateShort(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export class TicSessionsPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_items: { state: true },
|
||||
_total: { state: true },
|
||||
_page: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._items = [];
|
||||
this._total = 0;
|
||||
this._page = 1;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open && this._items.length === 0) this._fetch(1);
|
||||
});
|
||||
}
|
||||
|
||||
async _fetch(page) {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const params = new URLSearchParams({ source: 'tic', page, per_page: PER_PAGE });
|
||||
const res = await fetch(`/api/sessions?${params}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
this._items = data.items;
|
||||
this._total = data.total;
|
||||
this._page = data.page;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_openSession(id) {
|
||||
window.location.hash = `session/${id}`;
|
||||
}
|
||||
|
||||
get _totalPages() { return Math.max(1, Math.ceil(this._total / PER_PAGE)); }
|
||||
|
||||
_renderTable() {
|
||||
if (this._loading) return html`
|
||||
<div class="tic-state">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
<span>Loading…</span>
|
||||
</div>
|
||||
`;
|
||||
if (this._error) return html`
|
||||
<div class="tic-state tic-state--error">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
<span>${this._error}</span>
|
||||
</div>
|
||||
`;
|
||||
if (this._items.length === 0) return html`
|
||||
<div class="tic-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<span>No TIC sessions found.</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="tic-table-wrap">
|
||||
<table class="table table-sm tic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Agent</th>
|
||||
<th>Started</th>
|
||||
<th class="text-end">Messages</th>
|
||||
<th>Last activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this._items.map(r => html`
|
||||
<tr class="tic-row--clickable" @click=${() => this._openSession(r.id)}>
|
||||
<td class="tic-id">${r.id}</td>
|
||||
<td><span class="tic-agent">${r.agent_id ?? '—'}</span></td>
|
||||
<td class="tic-date">${formatDateShort(r.created_at)}</td>
|
||||
<td class="text-end tic-num">${r.message_count}</td>
|
||||
<td class="tic-date">${formatDate(r.last_message_at)}</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderPagination() {
|
||||
if (this._totalPages <= 1) return nothing;
|
||||
const pages = this._totalPages;
|
||||
const cur = this._page;
|
||||
return html`
|
||||
<div class="tic-pagination">
|
||||
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur <= 1}
|
||||
@click=${() => this._fetch(cur - 1)}>
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<span class="tic-page-info">Page ${cur} of ${pages} — ${this._total} sessions</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages}
|
||||
@click=${() => this._fetch(cur + 1)}>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<style>
|
||||
.tic-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tic-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.tic-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.tic-total-badge {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-secondary-color);
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 1rem;
|
||||
padding: 0.1rem 0.6rem;
|
||||
}
|
||||
.tic-refresh-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
.tic-table-wrap {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tic-table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.tic-row--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.tic-row--clickable:hover td {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
.tic-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.82rem;
|
||||
color: var(--bs-secondary-color);
|
||||
width: 4rem;
|
||||
}
|
||||
.tic-agent {
|
||||
font-family: monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.tic-date {
|
||||
font-size: 0.82rem;
|
||||
color: var(--bs-secondary-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tic-num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.tic-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 3rem;
|
||||
justify-content: center;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.tic-state--error { color: var(--bs-danger); }
|
||||
.tic-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.tic-page-info {
|
||||
font-size: 0.82rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="tic-page">
|
||||
<div class="tic-header">
|
||||
<h2 class="tic-title"><i class="bi bi-bell"></i> TIC Sessions</h2>
|
||||
<span class="tic-total-badge">${this._total} total</span>
|
||||
<button class="btn btn-sm btn-outline-secondary tic-refresh-btn"
|
||||
?disabled=${this._loading}
|
||||
@click=${() => this._fetch(this._page)}>
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
${this._renderTable()}
|
||||
${this._renderPagination()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { html } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
|
||||
export class AppTopbar extends LightElement {
|
||||
static properties = {
|
||||
_theme: { state: true },
|
||||
_copilotCollapsed: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._theme = document.documentElement.getAttribute('data-bs-theme') ?? 'light';
|
||||
this._copilotCollapsed = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('copilot-collapsed', (e) => {
|
||||
this._copilotCollapsed = e.detail.collapsed;
|
||||
});
|
||||
}
|
||||
|
||||
_toggleTheme() {
|
||||
const next = this._theme === 'dark' ? 'light' : 'dark';
|
||||
this._theme = next;
|
||||
document.documentElement.setAttribute('data-bs-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
}
|
||||
|
||||
render() {
|
||||
const isDark = this._theme === 'dark';
|
||||
return html`
|
||||
<span class="topbar-title">Skald</span>
|
||||
<span class="topbar-spacer"></span>
|
||||
${this._copilotCollapsed ? html`
|
||||
<button class="topbar-copilot-btn" title="Open copilot"
|
||||
@click=${() => window.dispatchEvent(new CustomEvent('copilot-open'))}>
|
||||
<i class="bi bi-stars"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="topbar-theme-btn" title="${isDark ? 'Switch to light mode' : 'Switch to dark mode'}"
|
||||
@click=${() => this._toggleTheme()}>
|
||||
<i class="bi ${isDark ? 'bi-sun' : 'bi-moon-stars'}"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
Vendored
+1157
File diff suppressed because it is too large
Load Diff
Vendored
+4
@@ -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;
|
||||
Vendored
+120
File diff suppressed because one or more lines are too long
Vendored
+2503
File diff suppressed because it is too large
Load Diff
Vendored
+1471
File diff suppressed because it is too large
Load Diff
Vendored
+18936
File diff suppressed because it is too large
Load Diff
Vendored
+474
@@ -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);
|
||||
}
|
||||
Vendored
+1597
File diff suppressed because one or more lines are too long
Vendored
+35880
File diff suppressed because it is too large
Load Diff
Vendored
+412
@@ -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%; }
|
||||
Vendored
+12360
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user