Files
agentic-mobile-control/apps/device-host-agent/host_agent/web/templates/task_detail.html
T
q792602257 18f053e64b Add Host Agent local console task cancellation (task-cancellation 7.1-7.3)
- New internal API route POST /internal/v1/hosts/{host_id}/tasks/{task_id}/cancel,
  authenticated via the host's own bearer credential (authorize_host) with an
  ownership check, since host tokens carry no scopes and cannot reach the
  public SDK's tasks:submit-scoped cancel endpoint.
- HostAgentClient.cancel_task() calls the new internal route directly.
- create_console_app() gains a cancel_task callable with automatic default
  wiring from host_client, so production app.py needs no changes.
- Local console: POST /tasks/{task_id}/cancel route resolves the local
  execution id to its Cloud source_task_id before cancelling, and the task
  detail page/template show a Cancel button plus notice/error banners.
- Tests across all three layers: internal API route, Jinja2 template
  rendering, and FastAPI console route behavior.
2026-07-15 19:13:40 +08:00

245 lines
11 KiB
HTML

{% extends "base.html" %}
{% block styles %}
{{ super() }}
.task-back { margin-top: 0; }
.timeline-step { border: 1px solid #c8d0d6; background: #fff; padding: 1rem; margin-bottom: 1rem; }
.step-heading { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: baseline; margin-bottom: 0.75rem; }
.step-heading p { margin: 0; color: #4d5a63; }
.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; 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; }
.operation-grid { display: grid; grid-template-columns: minmax(7rem, 0.4fr) minmax(0, 1fr); gap: 0.35rem 0.75rem; margin: 0.65rem 0 0; }
.operation-grid dt { font-weight: 600; }
.operation-grid dd { margin: 0; overflow-wrap: anywhere; }
.observation-list, .ui-tree-nodes { margin: 0.65rem 0 0; padding-left: 1.25rem; }
.observation-list li, .ui-tree-nodes li { margin-bottom: 0.45rem; overflow-wrap: anywhere; }
.observation-list span, .ui-tree-nodes span { color: #4d5a63; margin-left: 0.4rem; }
.ui-tree-nodes code { overflow-wrap: anywhere; }
@media (max-width: 640px) {
.evidence-grid { grid-template-columns: minmax(0, 1fr); }
.timeline-step { padding: 0.75rem; }
}
{% endblock %}
{% block body %}
<p class="task-back"><a href="/tasks">&larr; Back to executions</a></p>
<h1>Execution</h1>
<table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>{% for row in task_rows %}<tr><td>{{ row[0] }}</td><td>{{ row[1] }}</td></tr>{% endfor %}</tbody>
</table>
{% if cancel_notice %}<p class="notice" id="cancel-notice">{{ cancel_notice }}</p>{% endif %}
{% if cancel_error %}<p class="error" id="cancel-error">{{ cancel_error }}</p>{% endif %}
{% if can_cancel %}
<form method="post" action="/tasks/{{ task['id'] }}/cancel">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p><button type="submit">Cancel task</button></p>
</form>
{% endif %}
<h2>Timeline</h2>
{% 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">
<strong>Step {{ step.index }}</strong>
<p>{{ step.timestamp }}</p>
</div>
<div class="evidence-grid">
<figure class="evidence-pane">
<h3>Before action</h3>
<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 %}
</div>
</figure>
<figure class="evidence-pane">
<h3>After action</h3>
<div class="screenshot-frame">
{% if step.after_screenshot_src %}
<img src="{{ step.after_screenshot_src }}" alt="Screenshot after action">
{% else %}
<span>No screenshot</span>
{% endif %}
</div>
</figure>
</div>
<details class="step-details" open>
<summary>Operation</summary>
<dl class="operation-grid">
<dt>Action</dt><dd>{{ step.tool_call.get("action") or "-" }}</dd>
<dt>Description</dt><dd>{{ step.tool_call.get("description") or "-" }}</dd>
</dl>
<pre>{{ step.tool_call | tojson(indent=2) }}</pre>
</details>
<details class="step-details">
<summary>Result</summary>
<pre>{{ step.result | tojson(indent=2) }}</pre>
</details>
{% if step.prompt %}
<details class="step-details">
<summary>Planner prompt</summary>
<pre>{{ step.prompt }}</pre>
</details>
{% endif %}
{% if step.ocr_results %}
<details class="step-details">
<summary>OCR results ({{ step.ocr_results|length }})</summary>
<ul class="observation-list">
{% for ocr in step.ocr_results %}
<li>
<strong>{{ ocr.get("text") or "-" }}</strong>
<span>{{ ocr.get("bounds") | tojson }}</span>
{% if ocr.get("confidence") is not none %}<span>confidence {{ "%.3f" | format(ocr.get("confidence")) }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</details>
{% endif %}
{% if step.ui_tree_nodes %}
<details class="step-details">
<summary>UI tree ({{ step.ui_tree_nodes|length }} normalized nodes)</summary>
<ul class="ui-tree-nodes">
{% for node in step.ui_tree_nodes %}
<li>
<strong>{{ node.get("type") or "unknown" }}</strong>
<span>{{ node.get("text") or node.get("id") or "-" }}</span>
<code>{{ node.get("bounds") | tojson }}</code>
{% if node.get("confidence") is not none %}<span>confidence {{ "%.3f" | format(node.get("confidence")) }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</details>
{% endif %}
</section>
{% endfor %}
<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 %}