feat(host-agent): persist UI-tree evidence and add overlay/action visualization
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:
2026-07-15 14:39:28 +08:00
parent 367fd0d412
commit 8162509158
12 changed files with 767 additions and 23 deletions
+20 -9
View File
@@ -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 %}