feat: add Clear History button to delete all consumed instructions

- Backend: instruction_service.clear_consumed() bulk-deletes consumed rows
- Backend: DELETE /api/instructions/consumed route (preserves pending)
- Frontend: Clear button in consumed panel header (hidden when empty)
- Frontend: SSE handler for history.cleared event - instant UI update
- Frontend: api.clearConsumed() fetch wrapper
This commit is contained in:
2026-03-27 04:16:24 +08:00
parent 86eba27a24
commit 256a445e2f
6 changed files with 57 additions and 7 deletions

View File

@@ -57,6 +57,14 @@ def update_instruction(instruction_id: str, body: UpdateInstructionRequest):
return InstructionCreateResponse(item=item)
@router.delete("/consumed", status_code=200)
def clear_consumed_instructions():
"""Delete all consumed instructions. Pending instructions are never affected."""
count = instruction_service.clear_consumed()
event_service.broadcast("history.cleared", {"count": count})
return {"cleared": count}
@router.delete("/{instruction_id}", status_code=204)
def delete_instruction(instruction_id: str):
try:

View File

@@ -168,6 +168,19 @@ def delete_instruction(instruction_id: str) -> None:
logger.info("Instruction deleted id=%s", instruction_id)
def clear_consumed() -> int:
"""Delete all consumed instructions. Returns the number of rows deleted."""
with get_write_conn() as conn:
row = conn.execute(
"SELECT COUNT(*) FROM instructions WHERE status = 'consumed'"
).fetchone()
count = row[0] if row else 0
conn.execute("DELETE FROM instructions WHERE status = 'consumed'")
logger.info("Consumed instructions cleared count=%d", count)
return count
def consume_next(agent_id: str = "unknown") -> Optional[InstructionItem]:
"""
Atomically claim and return the next pending instruction.

View File

@@ -111,7 +111,13 @@
<svg class="icon icon--sm" viewBox="0 0 24 24"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
Consumed
</span>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span id="consumed-badge" class="badge badge--muted">0</span>
<button id="clear-history-btn" class="btn btn--ghost btn--sm" title="Clear all consumed instructions" style="display:none">
<svg class="icon icon--sm" 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>
Clear
</button>
</div>
</div>
<div class="panel-body panel-body--flush">
<div id="consumed-list">
@@ -151,9 +157,9 @@
<div class="panel-body">
<form id="config-form">
<div class="config-field">
<label class="config-label" for="cfg-wait">Min Wait (sec)</label>
<label class="config-label" for="cfg-wait">Wait (sec)</label>
<input id="cfg-wait" class="input input--sm" type="number" min="0" max="300" value="10" />
<span class="config-hint">Server enforces this as the minimum wait before returning an empty response. Agent may request longer but never shorter.</span>
<span class="config-hint">How long the tool waits for a new instruction before returning an empty response. Set exclusively here — agents cannot override this.</span>
</div>
<div class="config-field">
<label class="config-label" for="cfg-empty">Empty Response</label>

View File

@@ -35,6 +35,7 @@ export const api = {
createInstruction: (content) => request('POST', '/api/instructions', { content }),
updateInstruction: (id, content) => request('PATCH', `/api/instructions/${id}`, { content }),
deleteInstruction: (id) => request('DELETE', `/api/instructions/${id}`),
clearConsumed: () => request('DELETE', '/api/instructions/consumed'),
// Config
getConfig: () => request('GET', '/api/config'),

View File

@@ -104,6 +104,11 @@ function _handleEvent(event) {
}
break;
}
case 'history.cleared': {
const next = (state.get('instructions') || []).filter(i => i.status !== 'consumed');
state.set('instructions', next);
break;
}
case 'status.changed': {
api.status().then(s => state.set('status', s)).catch(console.error);
break;

View File

@@ -166,6 +166,7 @@ export function initInstructions() {
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;
@@ -190,14 +191,30 @@ export function initInstructions() {
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 => consumedList.appendChild(renderConsumedCard(item)));
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();
toast(`Cleared ${res.cleared} consumed instruction${res.cleared !== 1 ? 's' : ''}`, 'info');
} catch (e) {
toast(e.message, 'error');
} finally {
clearHistoryBtn.disabled = false;
}
});
}
// ── Composer ──────────────────────────────────────────────────────────────