Files
local-mcp/static/js/instructions.js
2026-03-27 18:38:28 +08:00

288 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* static/js/instructions.js
* Renders the pending and consumed instruction panels.
* Handles add, edit, delete interactions.
*/
import { state } from './state.js';
import { api } from './api.js';
import { toast } from './app.js';
// ── SVG icon helpers ──────────────────────────────────────────────────────
function iconEdit() {
return `<svg class="icon" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`;
}
function iconDelete() {
return `<svg class="icon" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`;
}
function iconCheck() {
return `<svg class="icon" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`;
}
function iconX() {
return `<svg class="icon" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
}
// ── Time formatters ───────────────────────────────────────────────────────
function fmtRelativeTime(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return d.toLocaleDateString();
}
function fmtAbsTime(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function comparePending(a, b) {
return (a.position - b.position) || String(a.created_at).localeCompare(String(b.created_at));
}
function compareConsumed(a, b) {
const consumedDelta = new Date(b.consumed_at || 0).getTime() - new Date(a.consumed_at || 0).getTime();
if (consumedDelta !== 0) return consumedDelta;
return b.position - a.position;
}
/** Refresh all relative-time spans in the lists (called by app.js on a timer). */
export function refreshTimestamps() {
document.querySelectorAll('[data-ts]').forEach(el => {
el.textContent = fmtRelativeTime(el.dataset.ts);
});
}
// ── Card renderer ─────────────────────────────────────────────────────────
function renderPendingCard(item, index) {
const card = document.createElement('div');
card.className = 'instruction-card';
card.dataset.id = item.id;
card.style.animationDelay = `${index * 30}ms`;
card.innerHTML = `
<div class="instruction-card__meta">
<span class="instruction-card__pos">#${item.position}</span>
<span class="instruction-card__time" data-ts="${item.created_at}">${fmtRelativeTime(item.created_at)}</span>
</div>
<div class="instruction-card__content">${escapeHtml(item.content)}</div>
<div class="instruction-card__actions">
<button class="btn btn--ghost btn--icon btn--edit" title="Edit instruction" aria-label="Edit">${iconEdit()}</button>
<button class="btn btn--danger btn--icon btn--delete" title="Delete instruction" aria-label="Delete">${iconDelete()}</button>
</div>
<div class="instruction-card__edit-area" style="display:none; grid-column:1/-1;">
<textarea class="textarea edit-textarea" rows="3">${escapeHtml(item.content)}</textarea>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
<button class="btn btn--primary btn--sm btn--save" title="Save">${iconCheck()}</button>
<button class="btn btn--ghost btn--sm btn--cancel" title="Cancel">${iconX()}</button>
</div>
</div>
`;
const editBtn = card.querySelector('.btn--edit');
const deleteBtn = card.querySelector('.btn--delete');
const cancelBtn = card.querySelector('.btn--cancel');
const saveBtn = card.querySelector('.btn--save');
const editArea = card.querySelector('.instruction-card__edit-area');
const content = card.querySelector('.instruction-card__content');
const actions = card.querySelector('.instruction-card__actions');
const editTA = card.querySelector('.edit-textarea');
function showEdit() {
editTA.value = item.content;
editArea.style.display = 'flex';
content.style.display = 'none';
actions.style.display = 'none';
editTA.focus();
editTA.setSelectionRange(editTA.value.length, editTA.value.length);
}
function hideEdit() {
editArea.style.display = 'none';
content.style.display = '';
actions.style.display = '';
}
editBtn.addEventListener('click', showEdit);
cancelBtn.addEventListener('click', hideEdit);
editTA.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { e.preventDefault(); hideEdit(); }
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveBtn.click(); }
// Shift+Enter → default browser behaviour (newline)
});
saveBtn.addEventListener('click', async () => {
const newContent = editTA.value.trim();
if (!newContent) { toast('Content cannot be empty', 'error'); return; }
saveBtn.disabled = true;
try {
await api.updateInstruction(item.id, newContent);
toast('Instruction updated', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
saveBtn.disabled = false;
}
});
deleteBtn.addEventListener('click', async () => {
if (!confirm('Delete this instruction?')) return;
deleteBtn.disabled = true;
try {
await api.deleteInstruction(item.id);
toast('Instruction deleted', 'info');
} catch (e) {
toast(e.message, 'error');
deleteBtn.disabled = false;
}
});
return card;
}
function renderConsumedCard(item, index) {
const card = document.createElement('div');
card.className = 'instruction-card instruction-card--consumed';
card.dataset.id = item.id;
card.innerHTML = `
<div class="instruction-card__meta">
<span class="instruction-card__pos">#${item.position}</span>
<span class="instruction-card__time">${fmtAbsTime(item.consumed_at)}</span>
${item.consumed_by_agent_id
? `<span class="instruction-card__consumed-by">→ ${escapeHtml(item.consumed_by_agent_id)}</span>`
: ''}
</div>
<div class="instruction-card__content">${escapeHtml(item.content)}</div>
<div></div>
`;
return card;
}
// ── List renderers ────────────────────────────────────────────────────────
export function initInstructions() {
const pendingList = document.getElementById('pending-list');
const pendingBadge = document.getElementById('pending-badge');
const consumedList = document.getElementById('consumed-list');
const consumedBadge = document.getElementById('consumed-badge');
const clearHistoryBtn = document.getElementById('clear-history-btn');
function render(instructions) {
if (!instructions) return;
const pending = instructions.filter(i => i.status === 'pending').sort(comparePending);
const consumed = instructions.filter(i => i.status === 'consumed').sort(compareConsumed);
// Pending
pendingList.innerHTML = '';
if (pending.length === 0) {
pendingList.innerHTML = `
<div class="empty-state">
<div class="empty-state__icon">◈</div>
Queue is empty add an instruction above
</div>`;
} else {
pending.forEach((item, i) => pendingList.appendChild(renderPendingCard(item, i)));
}
pendingBadge.textContent = pending.length;
pendingBadge.className = `badge ${pending.length > 0 ? 'badge--cyan' : 'badge--muted'}`;
// Consumed
consumedList.innerHTML = '';
if (consumed.length === 0) {
consumedList.innerHTML = `<div class="empty-state"><div class="empty-state__icon">◈</div>No consumed instructions yet</div>`;
clearHistoryBtn.style.display = 'none';
} else {
consumed.forEach((item, i) => consumedList.appendChild(renderConsumedCard(item, i)));
clearHistoryBtn.style.display = '';
}
consumedBadge.textContent = consumed.length;
consumedBadge.className = `badge ${consumed.length > 0 ? 'badge--amber' : 'badge--muted'}`;
}
state.subscribe('instructions', render);
// Clear history button
clearHistoryBtn.addEventListener('click', async () => {
if (!confirm('Clear all consumed instruction history? This cannot be undone.')) return;
clearHistoryBtn.disabled = true;
try {
const res = await api.clearConsumed();
const cleared = res?.cleared ?? 0;
toast(`Cleared ${cleared} consumed instruction${cleared !== 1 ? 's' : ''}`, 'info');
} catch (e) {
toast(e.message, 'error');
} finally {
clearHistoryBtn.disabled = false;
}
});
}
// ── Composer ──────────────────────────────────────────────────────────────
export function initComposer() {
const form = document.getElementById('composer-form');
const textarea = document.getElementById('composer-input');
const btn = document.getElementById('composer-submit');
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
// Plain Enter → submit (like a chat box)
e.preventDefault();
form.requestSubmit();
}
// Shift+Enter → default browser behaviour (newline)
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const content = textarea.value.trim();
if (!content) return;
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner"></span>';
try {
await api.createInstruction(content);
textarea.value = '';
textarea.style.height = '';
toast('Instruction queued', 'success');
} catch (err) {
toast(err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = original;
textarea.focus();
}
});
// Auto-resize textarea
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
});
}
// ── Utility ───────────────────────────────────────────────────────────────
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}