Move runtime log and events modal
This commit is contained in:
parent
77c2e37b95
commit
be70f4356b
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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("&", "&")
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue