feat(host-agent): persist UI-tree evidence and add overlay/action visualization
Tests / Test passed: 863
Tests / Test passed: 863
Fixes issue 3: the host-agent console showed OCR results but never real UI-tree data, because _ui_tree_nodes() checked for a get_ui_tree/ui_tree tool action that has never existed anywhere in the codebase. - storage/timeline.py: add a ui_tree_results field to TimelineRecord and Timeline.append(), mirroring the existing ocr_results field. - runtime/task.py: _append_timeline() now extracts scene.elements with source == "ui" into ui_tree_results (scene_builder.build_scene() already preserved these; they were just never persisted). - host_agent/web/app.py: _ui_tree_nodes() reads the new field directly instead of the dead tool-action check. New _overlay_payload() exposes each step's scene dimensions and fused element list for client-side rendering. - task_detail.html: adds a toggle to overlay OCR (orange) and UI-tree (blue) bounding boxes on the before-action screenshot, plus a visual marker for the actually executed action (tap circle, or an animated swipe path) using an SVG viewBox so no manual coordinate-scaling JS is needed. Legacy/incomplete records degrade to no overlay, never an error. Also corrects openspec/specs/runtime-task-evidence and host-agent-console-task-pages, which had encoded the same nonexistent-tool assumption, via the new host-agent-console-visual-evidence change. 600 tests passing; ruff/compileall/openspec validate all clean.
This commit is contained in:
@@ -94,20 +94,30 @@ def _ocr_results(record: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def _ui_tree_nodes(record: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
tool_call = record.get("tool_call")
|
||||
if not isinstance(tool_call, dict):
|
||||
return []
|
||||
if tool_call.get("action") not in {"get_ui_tree", "ui_tree"}:
|
||||
return []
|
||||
step_result = record.get("result")
|
||||
if not isinstance(step_result, dict):
|
||||
return []
|
||||
raw_nodes = step_result.get("result")
|
||||
raw_nodes = record.get("ui_tree_results")
|
||||
if not isinstance(raw_nodes, list):
|
||||
return []
|
||||
return [node for node in raw_nodes if isinstance(node, dict)]
|
||||
|
||||
|
||||
def _overlay_payload(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Combined perception elements + screen size for client-side bounding-box
|
||||
overlay and action-effect rendering on the before-screenshot.
|
||||
"""
|
||||
scene = record.get("scene")
|
||||
screen = scene.get("screen") if isinstance(scene, dict) else None
|
||||
width = screen.get("width") if isinstance(screen, dict) else None
|
||||
height = screen.get("height") if isinstance(screen, dict) else None
|
||||
elements = scene.get("elements") if isinstance(scene, dict) else None
|
||||
return {
|
||||
"width": width if isinstance(width, (int, float)) else 0,
|
||||
"height": height if isinstance(height, (int, float)) else 0,
|
||||
"elements": [element for element in elements if isinstance(element, dict)]
|
||||
if isinstance(elements, list)
|
||||
else [],
|
||||
}
|
||||
|
||||
|
||||
def _timeline_step_context(record: dict[str, Any]) -> dict[str, Any]:
|
||||
tool_call = record.get("tool_call")
|
||||
result = record.get("result")
|
||||
@@ -126,6 +136,7 @@ def _timeline_step_context(record: dict[str, Any]) -> dict[str, Any]:
|
||||
or _screenshot_data_uri(record),
|
||||
"ocr_results": _ocr_results(record),
|
||||
"ui_tree_nodes": _ui_tree_nodes(record),
|
||||
"overlay": _overlay_payload(record),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,18 @@
|
||||
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin-bottom: 1rem; }
|
||||
.evidence-pane { margin: 0; min-width: 0; }
|
||||
.evidence-pane h3 { font-size: 1rem; margin: 0 0 0.35rem; }
|
||||
.screenshot-frame { min-height: 6rem; border: 1px solid #c8d0d6; background: #f8fafb; display: grid; place-items: center; overflow: hidden; color: #5e6b73; }
|
||||
.screenshot-frame { min-height: 6rem; border: 1px solid #c8d0d6; background: #f8fafb; display: grid; place-items: center; overflow: hidden; color: #5e6b73; position: relative; }
|
||||
.screenshot-frame img { display: block; width: 100%; height: auto; }
|
||||
.overlay-svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.overlay-svg .overlay-box { fill: none; stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||||
.overlay-svg .overlay-box.source-ui { stroke: #1e88e5; }
|
||||
.overlay-svg .overlay-box.source-ocr { stroke: #fb8c00; }
|
||||
.overlay-svg .overlay-boxes { display: none; }
|
||||
.overlay-svg.show-boxes .overlay-boxes { display: inline; }
|
||||
.overlay-svg .action-tap { fill: #e53935; fill-opacity: 0.25; stroke: #e53935; stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||||
.overlay-svg .action-swipe-line { stroke: #e53935; stroke-width: 3; vector-effect: non-scaling-stroke; fill: none; }
|
||||
.overlay-svg .action-swipe-dot { fill: #e53935; }
|
||||
.overlay-toggle { margin-bottom: 1rem; }
|
||||
.step-details { margin-top: 0.75rem; }
|
||||
.step-details summary { cursor: pointer; font-weight: 600; }
|
||||
.step-details pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0.65rem 0 0; padding: 0.65rem; border: 1px solid #d5dce0; background: #f8fafb; }
|
||||
@@ -36,6 +46,10 @@
|
||||
{% if not timeline_steps %}
|
||||
<p>No timeline records.</p>
|
||||
{% else %}
|
||||
<p class="overlay-toggle">
|
||||
<label><input type="checkbox" id="overlay-boxes-toggle"> Show OCR/UI-tree bounding boxes on before-action screenshots</label>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% for step in timeline_steps %}
|
||||
<section class="timeline-step">
|
||||
<div class="step-heading">
|
||||
@@ -48,6 +62,12 @@
|
||||
<div class="screenshot-frame">
|
||||
{% if step.before_screenshot_src %}
|
||||
<img src="{{ step.before_screenshot_src }}" alt="Screenshot before action">
|
||||
{% if step.overlay.width and step.overlay.height %}
|
||||
<svg class="overlay-svg" data-step-overlay
|
||||
viewBox="0 0 {{ step.overlay.width }} {{ step.overlay.height }}"
|
||||
preserveAspectRatio="none"></svg>
|
||||
<script type="application/json" class="step-overlay-data">{{ {"overlay": step.overlay, "tool_call": step.tool_call} | tojson }}</script>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span>No screenshot</span>
|
||||
{% endif %}
|
||||
@@ -113,5 +133,104 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<script>
|
||||
(function () {
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function rect(bounds, className) {
|
||||
const el = document.createElementNS(SVG_NS, "rect");
|
||||
el.setAttribute("x", bounds.x);
|
||||
el.setAttribute("y", bounds.y);
|
||||
el.setAttribute("width", bounds.width);
|
||||
el.setAttribute("height", bounds.height);
|
||||
el.setAttribute("class", className);
|
||||
return el;
|
||||
}
|
||||
|
||||
function buildBoxesGroup(elements) {
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("class", "overlay-boxes");
|
||||
(elements || []).forEach(function (element) {
|
||||
const bounds = element.bounds;
|
||||
if (!bounds) return;
|
||||
const source = element.source === "ui" ? "source-ui" : "source-ocr";
|
||||
const box = rect(bounds, "overlay-box " + source);
|
||||
const label = element.text || element.id || "";
|
||||
if (label) {
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
title.textContent = label;
|
||||
box.appendChild(title);
|
||||
}
|
||||
group.appendChild(box);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
function buildActionGroup(toolCall) {
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("class", "overlay-action");
|
||||
if (!toolCall) return group;
|
||||
const action = toolCall.action;
|
||||
const args = toolCall.args || {};
|
||||
if (action === "tap" && isFinite(args.x) && isFinite(args.y)) {
|
||||
const circle = document.createElementNS(SVG_NS, "circle");
|
||||
circle.setAttribute("cx", args.x);
|
||||
circle.setAttribute("cy", args.y);
|
||||
circle.setAttribute("r", 14);
|
||||
circle.setAttribute("class", "action-tap");
|
||||
group.appendChild(circle);
|
||||
} else if (
|
||||
action === "swipe" &&
|
||||
isFinite(args.start_x) &&
|
||||
isFinite(args.start_y) &&
|
||||
isFinite(args.end_x) &&
|
||||
isFinite(args.end_y)
|
||||
) {
|
||||
const line = document.createElementNS(SVG_NS, "line");
|
||||
line.setAttribute("x1", args.start_x);
|
||||
line.setAttribute("y1", args.start_y);
|
||||
line.setAttribute("x2", args.end_x);
|
||||
line.setAttribute("y2", args.end_y);
|
||||
line.setAttribute("class", "action-swipe-line");
|
||||
group.appendChild(line);
|
||||
|
||||
const dot = document.createElementNS(SVG_NS, "circle");
|
||||
dot.setAttribute("r", 8);
|
||||
dot.setAttribute("class", "action-swipe-dot");
|
||||
const motion = document.createElementNS(SVG_NS, "animateMotion");
|
||||
motion.setAttribute("dur", "1.2s");
|
||||
motion.setAttribute("repeatCount", "indefinite");
|
||||
motion.setAttribute(
|
||||
"path",
|
||||
"M" + args.start_x + "," + args.start_y + " L" + args.end_x + "," + args.end_y
|
||||
);
|
||||
dot.appendChild(motion);
|
||||
group.appendChild(dot);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
document.querySelectorAll("svg.overlay-svg[data-step-overlay]").forEach(function (svg) {
|
||||
const dataScript = svg.nextElementSibling;
|
||||
if (!dataScript || !dataScript.classList.contains("step-overlay-data")) return;
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(dataScript.textContent);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
svg.appendChild(buildBoxesGroup(payload.overlay && payload.overlay.elements));
|
||||
svg.appendChild(buildActionGroup(payload.tool_call));
|
||||
});
|
||||
|
||||
const toggle = document.getElementById("overlay-boxes-toggle");
|
||||
if (toggle) {
|
||||
toggle.addEventListener("change", function () {
|
||||
document.querySelectorAll("svg.overlay-svg").forEach(function (svg) {
|
||||
svg.classList.toggle("show-boxes", toggle.checked);
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user