Move runtime log and events modal

This commit is contained in:
mirivlad 2026-05-11 00:02:45 +08:00
parent 77c2e37b95
commit be70f4356b
5 changed files with 150 additions and 29 deletions

View File

@ -67,6 +67,17 @@ def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/events")
def list_events(limit: int = 500) -> dict[str, object]:
safe_limit = max(1, min(limit, 2000))
return {
"events": [
event.model_dump(mode="json")
for event in runtime.event_bus.list_recent(limit=safe_limit)
]
}
@app.post("/chat")
def chat(task: UserTask) -> dict[str, object]:
return runtime.handle_task(task)

View File

@ -76,7 +76,7 @@
button.danger {
background: #b42318;
}
.messages, .events {
.messages, .runtime-log, .events {
display: grid;
gap: 12px;
max-height: 520px;
@ -86,10 +86,13 @@
.messages {
width: 100%;
}
.events {
width: 100%;
.runtime-log {
margin-top: 12px;
}
.bubble, .event {
.events {
max-height: 70vh;
}
.bubble, .event, .runtime-entry {
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
@ -109,6 +112,15 @@
flex-wrap: wrap;
margin-top: 10px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-header button {
margin-top: 0;
}
dialog {
border: 1px solid var(--border);
border-radius: 8px;
@ -119,6 +131,9 @@
dialog::backdrop {
background: rgba(20, 18, 15, 0.4);
}
dialog.wide {
width: min(920px, calc(100vw - 32px));
}
.modal-body {
display: grid;
gap: 12px;
@ -163,10 +178,25 @@
</div>
</section>
<aside class="panel">
<strong>Events</strong>
<div class="events" id="events"></div>
<div class="panel-header">
<strong>Runtime</strong>
<button class="secondary" id="eventsBtn">Events</button>
</div>
<div class="runtime-log" id="runtimeLog"></div>
</aside>
</div>
<dialog id="eventsDialog" class="wide">
<div class="modal-body">
<div class="panel-header">
<strong>Events</strong>
<div>
<button class="secondary" id="refreshEventsBtn">Обновить</button>
<button class="secondary" id="closeEventsBtn">Закрыть</button>
</div>
</div>
<div class="events" id="events"></div>
</div>
</dialog>
<dialog id="feedbackDialog">
<form method="dialog" class="modal-body" id="feedbackForm">
<strong>Что было неверно?</strong>
@ -218,8 +248,13 @@
<script>
const messages = document.getElementById("messages");
const events = document.getElementById("events");
const runtimeLog = document.getElementById("runtimeLog");
const promptEl = document.getElementById("prompt");
const sendBtn = document.getElementById("sendBtn");
const eventsBtn = document.getElementById("eventsBtn");
const eventsDialog = document.getElementById("eventsDialog");
const refreshEventsBtn = document.getElementById("refreshEventsBtn");
const closeEventsBtn = document.getElementById("closeEventsBtn");
let lastPermissionRequest = null;
let lastSecretRequest = null;
let lastPasswordRequest = null;
@ -227,6 +262,7 @@
let activePermissionBubble = null;
let activeSecretBubble = null;
let activePasswordBubble = null;
const allEvents = [];
let currentTaskId = null;
let currentSessionId = "web-session";
let pendingFeedback = null;
@ -248,6 +284,15 @@
return el;
}
function addRuntimeEntry(title, body) {
const el = document.createElement("div");
el.className = "runtime-entry";
el.innerHTML = `<strong>${title}</strong><div>${body}</div>`;
runtimeLog.appendChild(el);
runtimeLog.scrollTop = runtimeLog.scrollHeight;
return el;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
@ -256,13 +301,17 @@
}
function addSystemMessage(title, text) {
return addBubble(title, `<div>${escapeHtml(text)}</div>`);
return addRuntimeEntry(title, `<div>${escapeHtml(text)}</div>`);
}
function addJsonBubble(title, data) {
return addBubble(title, `<pre>${escapeHtml(JSON.stringify(data, null, 2))}</pre>`);
}
function addRuntimeJson(title, data) {
return addRuntimeEntry(title, `<pre>${escapeHtml(JSON.stringify(data, null, 2))}</pre>`);
}
function addFeedbackControls(bubble, answerText) {
if (!currentTaskId || !bubble) return;
const actions = document.createElement("div");
@ -355,11 +404,8 @@
return;
}
seenEvents.add(eventKey);
const el = document.createElement("div");
el.className = "event";
el.innerHTML = `<div><code>${event.type}</code></div><pre>${JSON.stringify(event.payload ?? event, null, 2)}</pre>`;
events.appendChild(el);
events.scrollTop = events.scrollHeight;
allEvents.push(event);
renderEvents(allEvents);
if (event.type === "permission_requested") {
lastPermissionRequest = event.payload;
renderPermissionControls(event.payload);
@ -401,6 +447,33 @@
}
}
function renderEvents(eventList) {
events.innerHTML = "";
for (const event of eventList) {
const el = document.createElement("div");
el.className = "event";
el.innerHTML = `<div><code>${escapeHtml(event.type)}</code> <span style="color:var(--muted)">${escapeHtml(event.task_id || "")}</span></div><pre>${escapeHtml(JSON.stringify(event.payload ?? event, null, 2))}</pre>`;
events.appendChild(el);
}
events.scrollTop = events.scrollHeight;
}
async function loadRecentEvents() {
const response = await fetch("/events?limit=1000");
const data = await response.json();
if (!data || !Array.isArray(data.events)) {
return;
}
allEvents.length = 0;
seenEvents.clear();
for (const event of data.events) {
const eventKey = `${event.task_id || "na"}:${event.sequence || JSON.stringify(event)}`;
seenEvents.add(eventKey);
allEvents.push(event);
}
renderEvents(allEvents);
}
function renderPermissionControls(request) {
clearPermissionControls();
const command = request.command || JSON.stringify(request);
@ -422,8 +495,8 @@
});
});
activePermissionBubble = el;
messages.appendChild(el);
messages.scrollTop = messages.scrollHeight;
runtimeLog.appendChild(el);
runtimeLog.scrollTop = runtimeLog.scrollHeight;
}
function clearPermissionControls() {
@ -456,8 +529,8 @@
await resolveSecret(input.value);
});
activeSecretBubble = el;
messages.appendChild(el);
messages.scrollTop = messages.scrollHeight;
runtimeLog.appendChild(el);
runtimeLog.scrollTop = runtimeLog.scrollHeight;
input.focus();
}
@ -493,8 +566,8 @@
await resolvePassword(input.value);
});
activePasswordBubble = el;
messages.appendChild(el);
messages.scrollTop = messages.scrollHeight;
runtimeLog.appendChild(el);
runtimeLog.scrollTop = runtimeLog.scrollHeight;
input.focus();
}
@ -536,8 +609,6 @@
} else {
addSystemMessage("Permission", "Permission granted once.");
}
events.innerHTML = "";
seenEvents.clear();
if (data.events && Array.isArray(data.events)) {
data.events.forEach(addEvent);
}
@ -570,8 +641,6 @@
})
});
const data = await response.json();
events.innerHTML = "";
seenEvents.clear();
data.events.forEach(addEvent);
renderRuntimeResult(data.result, data.status);
}
@ -602,8 +671,6 @@
})
});
const data = await response.json();
events.innerHTML = "";
seenEvents.clear();
data.events.forEach(addEvent);
renderRuntimeResult(data.result, data.status);
}
@ -640,8 +707,6 @@
clearPermissionControls();
clearSecretControls();
clearPasswordControls();
events.innerHTML = "";
seenEvents.clear();
data.events.forEach(addEvent);
renderRuntimeResult(data.result, data.status);
if (data.task_id) {
@ -674,8 +739,6 @@
});
const data = await response.json();
if (data.retry_result) {
events.innerHTML = "";
seenEvents.clear();
if (Array.isArray(data.retry_result.events)) {
data.retry_result.events.forEach(addEvent);
}
@ -715,6 +778,16 @@
}
});
eventsBtn.addEventListener("click", async () => {
await loadRecentEvents();
eventsDialog.showModal();
});
refreshEventsBtn.addEventListener("click", async () => {
await loadRecentEvents();
});
closeEventsBtn.addEventListener("click", () => {
eventsDialog.close();
});
sendBtn.addEventListener("click", sendTask);
</script>
</body>

View File

@ -30,3 +30,6 @@ class EventBus:
def list_for_task(self, task_id: str) -> list[RuntimeEvent]:
return self._store.list_for_task(task_id)
def list_recent(self, limit: int = 500) -> list[RuntimeEvent]:
return self._store.list_recent(limit=limit)

View File

@ -65,6 +65,34 @@ class SQLiteEventStore:
for row in rows
]
def list_recent(self, limit: int = 500) -> list[RuntimeEvent]:
with sqlite3.connect(self._db_path) as conn:
rows = conn.execute(
"""
SELECT event_id, task_id, session_id, sequence, type, timestamp,
payload_json, causation_id, correlation_id
FROM events
ORDER BY timestamp DESC, task_id DESC, sequence DESC
LIMIT ?
""",
(limit,),
).fetchall()
events = [
RuntimeEvent(
event_id=row[0],
task_id=row[1],
session_id=row[2],
sequence=row[3],
type=row[4],
timestamp=row[5],
payload=json.loads(row[6]),
causation_id=row[7],
correlation_id=row[8],
)
for row in rows
]
return list(reversed(events))
def get_latest_sequence(self, task_id: str) -> int:
with sqlite3.connect(self._db_path) as conn:
row = conn.execute(

View File

@ -1,4 +1,4 @@
from app.api.server import chat, critic_feedback, health, resolve_permission, resolve_secret
from app.api.server import chat, critic_feedback, health, list_events, resolve_permission, resolve_secret
from app.core.permission_resolution import PermissionResolutionRequest, SecretResolutionRequest
from app.api.server import CriticFeedbackRequest
from app.core.contracts import UserTask
@ -8,6 +8,12 @@ def test_health_handler() -> None:
assert health() == {"status": "ok"}
def test_events_handler_returns_event_list() -> None:
body = list_events(limit=10)
assert "events" in body
assert isinstance(body["events"], list)
def test_chat_handler_returns_runtime_events() -> None:
body = chat(UserTask(input="hello from handler test"))
assert body["status"] == "completed"