Spaces:
Running
Running
Download src/miniweb/annotation/templates/annotate.html from sparklabuofu/MiniWeb-demo: direct link, hf CLI and curl.
- Browser
- Download file 190 kB
-
https://huggingface.co/spaces/sparklabuofu/MiniWeb-demo/resolve/main/src/miniweb/annotation/templates/annotate.html
- Command line
-
hf download hf://spaces/sparklabuofu/MiniWeb-demo/src/miniweb/annotation/templates/annotate.html
-
curl -L -o annotate.html https://huggingface.co/spaces/sparklabuofu/MiniWeb-demo/resolve/main/src/miniweb/annotation/templates/annotate.html
190 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Annotate</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; background: #f8fafc; color: #1e293b; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
.topbar { background: #fff; border-bottom: 1px solid #e2e8f0; padding: .5rem 1rem; display: flex; align-items: center; gap: .8rem; flex-shrink: 0; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.topbar h1 { font-size: .95rem; color: #0f172a; font-weight: 700; }
.topbar select, .topbar input { background: #fff; border: 1px solid #cbd5e1; color: #1e293b; padding: .3rem .5rem; border-radius: .3rem; font-size: .8rem; }
.topbar a { color: #2563eb; text-decoration: none; font-size: .8rem; }
.site-pill { display: inline-block; padding: .2rem .5rem; border-radius: 1rem; font-size: .75rem; font-weight: 600; cursor: pointer; border: 2px solid transparent; transition: all .15s; }
.site-pill.active { border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,.2); }
.split { display: flex; flex: 1; overflow: hidden; }
.action-sidebar { width: 360px; background: #fff; border-right: 1px solid #e2e8f0; display: none; flex-direction: column; overflow: hidden; flex-shrink: 0; }
.action-sidebar.visible { display: flex; }
.action-sidebar-header { padding: .5rem .6rem; border-bottom: 1px solid #e2e8f0; background: #f8fafc; display: flex; align-items: center; gap: .4rem; }
.action-sidebar-header h3 { font-size: .82rem; font-weight: 700; color: #0f172a; }
.action-sidebar-header .rec-dot { width: 8px; height: 8px; border-radius: 50%; background: #ef4444; animation: pulse 1s infinite; }
.action-sidebar-body { flex: 1; overflow-y: auto; padding: .3rem; }
.action-sidebar-footer { border-top: 1px solid #e2e8f0; padding: .4rem .6rem; background: #f8fafc; }
/* Action timeline entries */
.act-entry { padding: .4rem .6rem; margin-bottom: .25rem; border-radius: .35rem; border: 1.5px solid transparent; cursor: pointer; font-size: .82rem; transition: all .1s; position: relative; user-select: none; }
.act-entry:hover { background: #f1f5f9; }
.act-entry.selected { border-color: #2563eb; background: #eff6ff; }
.act-entry.in-range { background: #dbeafe; border-color: #93c5fd; }
.act-entry.rewound { opacity: .35; }
.net-entry { padding: .2rem .6rem .2rem 2rem; font-size: .72rem; color: #64748b; font-family: monospace; border-left: 2px solid #e2e8f0; margin-left: 1rem; margin-bottom: .15rem; }
.net-entry.net-err { border-left-color: #ef4444; background: #fef2f2; }
.net-entry .net-method { font-weight: 700; margin-right: .3rem; }
.net-entry .net-method.get { color: #16a34a; } .net-entry .net-method.post { color: #2563eb; }
.net-entry .net-method.put { color: #d97706; } .net-entry .net-method.delete { color: #ef4444; }
.net-entry .net-status { margin: 0 .3rem; font-weight: 600; }
.net-entry .net-status.ok { color: #16a34a; } .net-entry .net-status.err { color: #ef4444; }
.net-entry .net-dur { color: #94a3b8; margin-left: .3rem; }
.net-entry .net-body { color: #94a3b8; font-size: .6rem; max-height: 1.2em; overflow: hidden; cursor: pointer; }
.net-entry .net-body.expanded { max-height: none; white-space: pre-wrap; }
.net-entry .net-error-msg { color: #ef4444; font-weight: 600; font-size: .7rem; font-family: sans-serif; display: block; padding: .1rem 0; }
.act-entry .rewind-btn { display: none; position: absolute; right: 4px; top: 4px; background: #2563eb; color: #fff; border: none; border-radius: .25rem; font-size: .55rem; padding: .1rem .3rem; cursor: pointer; }
.act-entry:hover .rewind-btn { display: block; }
.act-entry .rewind-btn:hover { background: #1d4ed8; }
.act-entry .act-num { display: inline-block; width: 20px; color: #94a3b8; font-size: .7rem; font-weight: 600; }
.act-entry .act-icon { margin-right: .2rem; }
.act-entry .act-verb { font-weight: 600; color: #1e293b; }
.act-entry .act-target { color: #475569; }
.act-entry .act-detail { color: #64748b; font-size: .72rem; font-style: italic; }
.act-entry .act-tags { display: flex; flex-wrap: wrap; gap: .15rem; margin-top: .2rem; }
.act-entry .macro-tag { font-size: .55rem; padding: .05rem .25rem; border-radius: .6rem; font-weight: 500; cursor: pointer; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: middle; }
/* Range selection hint */
.range-hint { font-size: .72rem; color: #2563eb; padding: .3rem .5rem; background: #eff6ff; border-radius: .3rem; margin-bottom: .3rem; text-align: center; }
.main-layout { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
.top-row { display: flex; flex: 1; min-height: 0; }
.left-panel { flex: 1 1 0; min-width: 300px; display: flex; flex-direction: column; overflow: hidden; }
.left-panel iframe { flex: 1; border: none; background: #fff; }
.right-panel { width: 380px; flex: 0 0 380px; background: #fff; border-left: 1px solid #e2e8f0; display: flex; flex-direction: column; overflow: auto; }
.panel-resizer { width: 6px; background: #e2e8f0; cursor: col-resize; flex-shrink: 0; transition: background .15s; position: relative; z-index: 50; }
.panel-resizer::after { content: ''; position: absolute; top: 0; bottom: 0; left: -4px; right: -4px; z-index: 51; }
.panel-resizer:hover, .panel-resizer.active { background: #2563eb; }
.bottom-track { height: 220px; min-height: 120px; border-top: 1px solid #e2e8f0; background: #f8fafc; display: flex; flex-direction: column; overflow: hidden; flex-shrink: 0; }
.track-resizer { height: 5px; background: #e2e8f0; cursor: row-resize; flex-shrink: 0; transition: background .15s; }
.track-resizer:hover, .track-resizer.active { background: #2563eb; }
.steps-bar { display: flex; border-bottom: 1px solid #e2e8f0; flex-shrink: 0; background: #f8fafc; }
.steps-bar .step { flex: 1; padding: .5rem .2rem; text-align: center; font-size: .7rem; color: #94a3b8; cursor: pointer; border-bottom: 2px solid transparent; font-weight: 500; }
.steps-bar .step.active { color: #2563eb; border-bottom-color: #2563eb; }
.steps-bar .step.done { color: #16a34a; }
.steps-bar .step .num { display: inline-block; width: 18px; height: 18px; line-height: 18px; border-radius: 50%; border: 1.5px solid currentColor; font-size: .65rem; margin-right: .2rem; }
.steps-bar .step.done .num { background: #16a34a; border-color: #16a34a; color: #fff; }
.steps-bar .step.active .num { background: #2563eb; border-color: #2563eb; color: #fff; }
.step-content { flex: 1; overflow-y: auto; padding: .8rem; display: flex; flex-direction: column; }
.step-panel { display: none; }
.step-panel.active { display: flex; flex-direction: column; flex: 1; }
.step-panel h3 { font-size: .95rem; margin-bottom: .4rem; color: #0f172a; font-weight: 700; }
.step-panel .hint { font-size: .8rem; color: #64748b; margin-bottom: .6rem; line-height: 1.4; }
label { display: block; font-size: .78rem; color: #475569; margin: .6rem 0 .2rem; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
textarea, input[type="text"], input[type="number"], select { width: 100%; padding: .45rem .5rem; background: #fff; border: 1px solid #cbd5e1; color: #0f172a; border-radius: .4rem; font-size: .85rem; font-family: inherit; }
textarea:focus, input:focus, select:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,.15); }
textarea { min-height: 60px; resize: vertical; white-space: pre-wrap; word-wrap: break-word; }
/* Instruction textarea — extra prominent */
textarea#instruction {
font-size: 1rem;
line-height: 1.5;
min-height: 100px;
border: 2px solid #2563eb;
background: #eff6ff;
color: #0f172a;
padding: .6rem .7rem;
font-weight: 500;
}
textarea#instruction:focus {
background: #fff;
box-shadow: 0 0 0 3px rgba(37,99,235,.2);
}
.btn { padding: .35rem .7rem; border: none; border-radius: .4rem; cursor: pointer; font-size: .8rem; font-weight: 600; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-secondary { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
.btn-secondary:hover { background: #e2e8f0; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-success { background: #16a34a; color: #fff; }
.btn-success:hover { background: #15803d; }
.btn-sm { padding: .25rem .4rem; font-size: .72rem; }
.btn-lg { padding: .5rem 1.2rem; font-size: .9rem; }
.btn-recording { background: #ef4444; color: #fff; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .7; } }
/* Trajectory entries */
.traj-entry { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: .4rem; margin-bottom: .3rem; overflow: hidden; }
.traj-action { padding: .35rem .5rem; font-size: .75rem; display: flex; gap: .3rem; align-items: center; border-bottom: 1px solid #f1f5f9; }
.traj-action .badge { padding: .1rem .35rem; border-radius: .2rem; font-weight: 700; font-size: .65rem; background: #dbeafe; color: #1e40af; }
.traj-action .url { color: #475569; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: monospace; font-size: .72rem; }
.traj-action .ts { color: #94a3b8; font-size: .65rem; font-family: monospace; }
.traj-obs { padding: .35rem .5rem; font-size: .72rem; color: #64748b; max-height: 60px; overflow: hidden; cursor: pointer; }
.traj-obs:hover { max-height: none; color: #475569; }
.traj-obs .obs-label { color: #16a34a; font-weight: 700; font-size: .65rem; }
/* Verifier builder */
.vb-card { border: 1px solid #e2e8f0; border-radius: .5rem; margin-bottom: .5rem; overflow: hidden; }
.vb-card-header { padding: .4rem .6rem; display: flex; align-items: center; gap: .4rem; }
.vb-card-body { padding: .5rem .6rem; border-top: 1px solid #f1f5f9; min-height: 40px; }
.vb-card-body.drag-over { background: #eff6ff; border-color: #2563eb; outline: 2px dashed #93c5fd; }
.vb-card-body.empty-hint::after { content: 'Drop checks here'; color: #94a3b8; font-size: .75rem; font-style: italic; }
/* Palette check chips (draggable) */
.pal-check { display: inline-flex; align-items: center; gap: .3rem; padding: .25rem .5rem; background: #fff; border: 1px solid #e2e8f0; border-radius: .35rem; font-size: .72rem; cursor: grab; user-select: none; transition: all .1s; }
.pal-check:hover { border-color: #2563eb; background: #eff6ff; }
.pal-check:active { cursor: grabbing; opacity: .7; }
.pal-check .pal-icon { font-size: .8rem; }
.pal-check .pal-name { font-weight: 600; color: #1e293b; }
.pal-check .pal-hint { color: #94a3b8; font-size: .65rem; }
/* Dropped check items (in macro zone) */
.dropped-check { display: flex; align-items: flex-start; gap: .4rem; padding: .3rem .5rem; background: #fff; border: 1px solid #e2e8f0; border-radius: .35rem; margin-bottom: .25rem; font-size: .75rem; }
.dropped-check .dc-icon { font-size: .8rem; flex-shrink: 0; margin-top: .1rem; }
.dropped-check .dc-body { flex: 1; }
.dropped-check .dc-title { font-weight: 600; color: #1e293b; font-size: .75rem; }
.dropped-check .dc-fields { margin-top: .2rem; }
.dropped-check .dc-fields input, .dropped-check .dc-fields select { font-size: .72rem; padding: .15rem .3rem; border: 1px solid #cbd5e1; border-radius: .2rem; }
.dropped-check .dc-fields input[type="text"] { width: 100%; }
.dropped-check .dc-remove { color: #94a3b8; cursor: pointer; font-size: .85rem; flex-shrink: 0; }
.dropped-check .dc-remove:hover { color: #ef4444; }
.dropped-check .dc-result { font-size: .68rem; margin-top: .15rem; }
.dropped-check .dc-result.pass { color: #16a34a; } .dropped-check .dc-result.fail { color: #ef4444; }
.vb-section { margin-bottom: .5rem; }
.vb-section-title { font-size: .72rem; font-weight: 600; color: #64748b; margin-bottom: .3rem; text-transform: uppercase; letter-spacing: .03em; }
.vb-action-list { font-size: .75rem; color: #475569; }
.vb-action-item { padding: .15rem 0; display: flex; align-items: center; gap: .3rem; }
.vb-action-item .act-icon { font-size: .7rem; }
.vb-api-call { font-size: .72rem; padding: .2rem .4rem; background: #f1f5f9; border-radius: .25rem; margin-bottom: .2rem; display: flex; align-items: center; gap: .3rem; cursor: pointer; }
.vb-api-call:hover { background: #e2e8f0; }
.vb-api-call.checked { background: #dbeafe; border: 1px solid #93c5fd; }
.vb-api-call .method { font-weight: 700; font-size: .65rem; }
.vb-api-call .method.post { color: #2563eb; } .vb-api-call .method.get { color: #16a34a; }
.vb-api-call .method.put { color: #d97706; } .vb-api-call .method.delete { color: #ef4444; }
.vb-check-row { display: flex; align-items: center; gap: .4rem; padding: .25rem 0; font-size: .78rem; }
.vb-check-row select, .vb-check-row input { font-size: .75rem; padding: .2rem .3rem; border: 1px solid #cbd5e1; border-radius: .25rem; }
.vb-check-row select { max-width: 180px; }
.vb-check-row input[type="text"] { flex: 1; min-width: 80px; }
.vb-test-pass { color: #16a34a; font-weight: 600; } .vb-test-fail { color: #ef4444; font-weight: 600; }
/* Task suggestions */
.sug-card { background: #fff; border: 1px solid #e2e8f0; border-radius: .4rem; padding: .4rem .6rem; margin-bottom: .3rem; cursor: pointer; font-size: .78rem; transition: all .1s; }
.sug-card:hover { border-color: #2563eb; background: #eff6ff; }
.sug-card .sug-diff { font-size: .6rem; padding: .1rem .3rem; border-radius: .2rem; font-weight: 600; margin-right: .3rem; }
.sug-card .sug-diff.easy { background: #d1fae5; color: #065f46; }
.sug-card .sug-diff.medium { background: #fef3c7; color: #92400e; }
.sug-card .sug-diff.hard { background: #fecaca; color: #9f1239; }
.sug-card .sug-text { color: #1e293b; line-height: 1.4; }
.verifier-option .v-info { flex: 1; }
.verifier-option .v-label { font-size: .82rem; color: #0f172a; font-weight: 500; }
.verifier-option .v-desc { font-size: .72rem; color: #64748b; margin-top: .1rem; }
.indicator { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; margin-top: .2rem; }
.indicator.pass { background: #16a34a; }
.indicator.fail { background: #ef4444; }
.indicator.pending { background: #cbd5e1; }
.macro-chips { display: flex; flex-wrap: wrap; gap: .25rem; }
.macro-chip { background: #f1f5f9; color: #475569; padding: .15rem .4rem; border-radius: 1rem; font-size: .72rem; cursor: pointer; border: 1px solid transparent; }
.macro-chip.selected { background: #dbeafe; color: #1e40af; border-color: #2563eb; }
/* Macro tagging colors */
.macro-tag { display: inline-block; padding: .05rem .3rem; border-radius: .8rem; font-size: .6rem; font-weight: 600; cursor: pointer; margin-left: .2rem; }
.traj-entry.tagging { border-color: #2563eb !important; box-shadow: 0 0 0 2px rgba(37,99,235,.15); }
.traj-entry .tag-row { display: flex; flex-wrap: wrap; gap: .15rem; padding: .15rem .4rem; border-top: 1px solid #f1f5f9; }
.tag-palette { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: .4rem; padding: .4rem .5rem; margin-top: .4rem; }
.tag-palette-title { font-size: .72rem; color: #64748b; margin-bottom: .3rem; font-weight: 600; }
.tag-palette .macro-chip { font-size: .68rem; padding: .1rem .35rem; }
.tag-palette .macro-chip:hover { background: #dbeafe; }
.bottom-bar { border-top: 1px solid #e2e8f0; padding: .5rem 1rem; display: flex; gap: .3rem; align-items: center; flex-shrink: 0; background: #f8fafc; }
.bottom-bar .status { flex: 1; font-size: .78rem; color: #64748b; }
.nav-btns { display: flex; gap: .4rem; margin-top: .8rem; justify-content: flex-end; }
/* API Reference panel */
/* Macro items with tooltips */
.macro-node { position: absolute; display: flex; flex-direction: column; gap: .15rem; padding: .5rem .7rem; background: #fff; border: 1.5px solid #e2e8f0; border-radius: .5rem; font-size: .92rem; cursor: grab; min-width: 200px; max-width: 330px; box-shadow: 0 1px 4px rgba(0,0,0,.08); user-select: none; z-index: 2; }
.macro-node .mn-subtask { width: 100%; font-size: .86rem; padding: .2rem .3rem; border: 1px solid #e2e8f0; border-radius: .25rem; resize: none; font-family: inherit; cursor: text; }
.macro-node .mn-op { width: 100%; font-size: .86rem; padding: .18rem .3rem; border: 1px solid #e2e8f0; border-radius: .25rem; font-family: inherit; cursor: pointer; margin-top: .2rem; background: #fff; color: #475569; }
.macro-node .mn-op.set { border-color: #a78bfa; background: #f5f3ff; color: #6d28d9; font-weight: 600; }
.macro-node .mn-op-hint { font-size: .74rem; color: #8b93a4; line-height: 1.25; margin-top: .12rem; }
.macro-node .mn-span { font-size: .78rem; margin-top: .25rem; padding: .15rem .35rem; border-radius: .25rem; background: #eef2ff; color: #4338ca; border: 1px solid #c7d2fe; line-height: 1.3; }
.macro-node .mn-span b { font-weight: 700; }
.macro-node .mn-name { font-family: ui-monospace, Menlo, Consolas, monospace; }
.macro-node .mn-subtask:focus { border-color: #93c5fd; outline: none; }
.macro-node:hover { border-color: #93c5fd; box-shadow: 0 2px 6px rgba(37,99,235,.15); }
.macro-node.dragging { opacity: .5; cursor: grabbing; z-index: 10; }
.macro-node.arrow-target { border-color: #2563eb; background: #eff6ff; }
.macro-node .mn-header { display: flex; align-items: center; gap: .3rem; }
.macro-node .mn-name { font-weight: 700; color: #1e293b; font-size: .92rem; flex: 1; }
.macro-node .mn-arrow-handle { cursor: crosshair; color: #93c5fd; font-size: 1rem; padding: .1rem .2rem; border-radius: .2rem; }
.macro-node .mn-arrow-handle:hover { background: #dbeafe; color: #2563eb; }
.macro-node .mn-btns { display: flex; gap: .15rem; }
.macro-node .mn-btns button { background: none; border: 1px solid #e2e8f0; border-radius: .2rem; font-size: .8rem; padding: .1rem .3rem; cursor: pointer; color: #94a3b8; }
.macro-node .mn-btns button:hover { color: #2563eb; border-color: #93c5fd; }
.macro-node .mn-btns button.active { background: #fef3c7; border-color: #fcd34d; color: #92400e; }
.bottom-track.expanded { position: fixed !important; top: 0; left: 0; width: 100vw !important; height: 100vh !important; z-index: 9999; border-radius: 0; border: none; }
.bottom-track.expanded #graph-close-btn { display: block !important; }
.bottom-track.expanded #macro-graph { flex: 1; }
.bottom-track.expanded .track-instruction { display: flex !important; }
.macro-locs { padding: .1rem 0 0 0; margin: 0; list-style: none; }
.macro-locs li { font-size: .82rem; color: #94a3b8; line-height: 1.3; }
.macro-locs li b { color: #64748b; }
.macro-locs { width: 100%; padding: .15rem 0 0 1.4rem; margin: 0; list-style: none; }
.macro-locs li { font-size: .8rem; color: #64748b; line-height: 1.35; padding: .05rem 0; }
.macro-locs li b { color: #475569; font-weight: 600; }
/* Chain display */
.chain-display { font-family: monospace; font-size: .75rem; color: #2563eb; background: #eff6ff; padding: .3rem .5rem; border-radius: .3rem; margin-top: .4rem; min-height: 1.4em; }
/* Site tabs for switching iframe */
.site-tabs { display: flex; gap: .3rem; margin-bottom: .5rem; }
.site-tab { padding: .25rem .6rem; border-radius: .3rem; border: 1px solid #e2e8f0; background: #fff; color: #64748b; cursor: pointer; font-size: .78rem; font-weight: 500; }
.site-tab.active { border-color: #2563eb; color: #2563eb; background: #eff6ff; }
/* Skip modal */
.skip-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,.5); z-index: 200; display: flex; align-items: center; justify-content: center; }
.skip-modal { background: #fff; padding: 1.5rem; border-radius: .6rem; width: 400px; max-width: 90vw; box-shadow: 0 8px 30px rgba(0,0,0,.15); }
.skip-modal h3 { font-size: .95rem; margin-bottom: .6rem; color: #0f172a; }
.skip-modal select { width: 100%; padding: .4rem .5rem; border: 1px solid #cbd5e1; border-radius: .3rem; font-size: .85rem; margin-bottom: .5rem; }
.skip-modal textarea { width: 100%; padding: .35rem .5rem; border: 1px solid #cbd5e1; border-radius: .3rem; font-size: .82rem; min-height: 60px; resize: vertical; }
/* Name entry gate */
.name-gate { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(15,23,42,.6); z-index: 9000; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); }
.name-gate-box { background: #fff; padding: 2rem; border-radius: .8rem; width: 360px; max-width: 90vw; box-shadow: 0 12px 40px rgba(0,0,0,.2); text-align: center; }
.name-gate-box h2 { font-size: 1.2rem; color: #0f172a; margin-bottom: .3rem; }
.name-gate-box p { font-size: .85rem; color: #64748b; margin-bottom: 1.2rem; }
.name-gate-box input { width: 100%; padding: .6rem .8rem; border: 2px solid #2563eb; border-radius: .5rem; font-size: 1rem; text-align: center; outline: none; }
.name-gate-box input:focus { box-shadow: 0 0 0 3px rgba(37,99,235,.2); }
.name-gate-box button { margin-top: 1rem; padding: .6rem 2rem; background: #2563eb; color: #fff; border: none; border-radius: .5rem; font-size: .95rem; font-weight: 600; cursor: pointer; }
.name-gate-box button:hover { background: #1d4ed8; }
.name-gate-box button:disabled { opacity: .4; cursor: not-allowed; }
</style>
</head>
<body>
<!-- Name entry gate -->
<div class="name-gate" id="name-gate" style="display:none">
<div class="name-gate-box">
<h2>Welcome to MiniWeb Annotator</h2>
<p>Enter your name to begin this annotation session.</p>
<input type="text" id="name-gate-input" placeholder="Your name" autofocus
onkeydown="if(event.key==='Enter')submitNameGate()"
oninput="document.getElementById('name-gate-btn').disabled=!this.value.trim()">
<br>
<button id="name-gate-btn" disabled onclick="submitNameGate()">Start Annotating</button>
</div>
</div>
<div class="topbar">
<h1>MiniWeb Annotator</h1>
<span id="annotator-display" style="font-size:.8rem;color:#64748b"></span>
<span style="flex:1"></span>
<button class="btn btn-secondary btn-sm" onclick="resetSession()" title="Reset all site data to pristine state">Reset Session</button>
<a href="{{ url_for('annotation.index') }}">Home</a>
<a href="{{ url_for('annotation.verify') }}">Verifiers</a>
</div>
<div id="instruction-banner" style="display:none;background:#eff6ff;border-bottom:1px solid #bfdbfe;padding:.3rem .8rem;font-size:.8rem;color:#1e40af;flex-shrink:0">
<span style="font-weight:600;margin-right:.3rem">Task:</span>
<span id="instruction-banner-text"></span>
</div>
<div class="main-layout">
<!-- Top row: browser + right panel -->
<div class="top-row">
<div class="action-sidebar" id="action-sidebar">
<div class="action-sidebar-header">
<span class="rec-dot" id="sidebar-rec-dot"></span>
<h3>Actions</h3>
<span style="margin-left:auto;font-size:.7rem;color:#64748b" id="action-count">0</span>
</div>
<div class="action-sidebar-body" id="action-timeline"></div>
<div class="action-sidebar-footer">
<div id="range-controls" style="display:none">
<div class="range-hint" id="range-hint">Select start action</div>
<div id="range-macro-chips" class="macro-chips" style="margin-top:.3rem"></div>
</div>
<div id="range-tip" style="font-size:.62rem;color:#94a3b8;margin-top:.3rem">Click an action, then <b>⇧ shift-click</b> another to tag the range</div>
<div style="display:flex;gap:.3rem;margin-top:.3rem">
<button class="btn btn-sm btn-secondary" onclick="clearAllTags()" title="Clear all macro tags">Clear Tags</button>
<button class="btn btn-sm btn-secondary" onclick="suggestTagsAI()" id="suggest-tags-btn" title="Let AI suggest macro tags" style="background:#eff6ff;color:#2563eb;border-color:#bfdbfe">Suggest Tags</button>
<span id="suggest-tags-status" style="font-size:.6rem;color:#94a3b8"></span>
</div>
</div>
</div>
<div class="left-panel">
<iframe id="site-iframe" src="{% if prompt and prompt.sites %}/?open={{ prompt.sites | map(attribute='id') | join(',') }}{% else %}/{% endif %}" allow="clipboard-write"></iframe>
</div>
<div class="panel-resizer" id="panel-resizer"></div>
<div class="right-panel" id="right-panel">
<div class="steps-bar">
<div class="step active" onclick="goStep(1)"><span class="num">1</span> Design</div>
<div class="step" onclick="goStep(2)"><span class="num">2</span> Record</div>
<div class="step" onclick="goStep(3)"><span class="num">3</span> Submit</div>
<span id="agent-status-badge" style="display:none;font-size:.6rem;padding:.15rem .4rem;border-radius:1rem;background:#fef3c7;color:#92400e;margin:auto .5rem;white-space:nowrap"></span>
</div>
<div class="step-content">
<!-- STEP 1: Design a Task -->
<div class="step-panel active" id="step-1">
<!-- Workflow guide -->
<div style="flex-shrink:0;background:#eff6ff;border:1px solid #bfdbfe;border-radius:.35rem;padding:.4rem .6rem;margin-bottom:.4rem">
<span style="font-size:.78rem;color:#1e40af">
<strong>① Explore</strong> the site freely →
<strong>② Design</strong> macro graph →
<strong>③ Write</strong> instruction →
hit Record when ready
</span>
</div>
<!-- Sites + options -->
<div style="flex-shrink:0;display:flex;align-items:center;gap:.5rem;margin-bottom:.4rem">
<div id="site-labels" style="font-size:.78rem;color:#1e293b;font-weight:600"></div>
<span style="flex:1"></span>
<label style="display:flex;align-items:center;gap:.3rem;margin:0;cursor:pointer;font-size:.7rem;color:#475569;">
<input type="checkbox" id="auto-login-toggle" checked onchange="handleLoginToggle(this.checked)" style="accent-color:#2563eb;width:13px;height:13px;">
Logged in
</label>
<label style="display:flex;align-items:center;gap:.3rem;margin:0;cursor:pointer;font-size:.7rem;color:#475569;">
<input type="checkbox" id="disable-2fa-toggle" checked onchange="handle2FAToggle(this.checked)" style="accent-color:#f59e0b;width:13px;height:13px;">
Skip 2FA
</label>
<label style="display:flex;align-items:center;gap:.3rem;margin:0;font-size:.7rem;color:#475569;">
Sites
<select id="site-count-picker" onchange="handleSiteCountChange()" title="How many sites the sampled task should span" style="font-size:.68rem;padding:.15rem .3rem;border:1px solid #cbd5e1;border-radius:.3rem;color:#475569;">
<option value="">🎲 Auto</option>
<option value="1">1 site</option>
<option value="2">2 sites</option>
<option value="3">3 sites</option>
</select>
</label>
<select id="site-picker" onchange="reshufflePrompt()" style="display:none;font-size:.68rem;padding:.15rem .3rem;border:1px solid #cbd5e1;border-radius:.3rem;color:#475569;max-width:220px;" title="Choose the site. Status bar shows remaining tasks needed per chain length.">
<option value="">🎲 Random site</option>
</select>
<span id="login-status" style="font-size:.65rem;color:#16a34a;"></span>
<button class="btn btn-secondary" style="font-size:.62rem;padding:.15rem .4rem" onclick="reshufflePrompt()">↻ New</button>
<button class="btn btn-danger" style="font-size:.62rem;padding:.15rem .4rem" onclick="openSkipModal()">Skip</button>
</div>
<!-- Instruction (output from macro subtasks, editable) -->
<label style="font-size:.72rem">Task Instruction <span style="font-weight:400;color:#94a3b8">(write in your own words)</span></label>
<textarea id="instruction" rows="3" placeholder="Write subtasks on each macro node below, then click '✨ Combine' for an LLM suggestion to rephrase. Or write directly." style="font-size:.85rem;margin-bottom:.3rem;width:100%;flex-shrink:0"></textarea>
<div id="combined-suggestion-box" style="display:none;margin-bottom:.3rem;flex-shrink:0">
<div style="font-size:.62rem;font-weight:600;color:#92400e;margin-bottom:.15rem">✨ LLM suggestion — rephrase it in your own words (copying disabled)</div>
<div id="combined-suggestion-text"
style="background:#fffbeb;border:1px solid #fde68a;border-radius:.3rem;padding:.4rem .5rem;font-size:.82rem;line-height:1.4;color:#78350f;user-select:none;-webkit-user-select:none;-moz-user-select:none;cursor:default"
oncopy="return false" oncut="return false" oncontextmenu="return false" ondragstart="return false" onselectstart="return false"></div>
</div>
<div id="refined-box" style="display:none;margin-bottom:.3rem;flex-shrink:0">
<div id="refined-text" style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:.3rem;padding:.4rem .5rem;font-size:.82rem;line-height:1.4;color:#0f172a;cursor:pointer" onclick="acceptRefinement()"></div>
<div style="display:flex;gap:.3rem;margin-top:.2rem">
<button class="btn btn-success btn-sm" onclick="acceptRefinement()">Use this</button>
<button class="btn btn-secondary btn-sm" onclick="document.getElementById('refined-box').style.display='none'">Keep mine</button>
</div>
</div>
<span id="refine-status" style="font-size:.65rem;color:#94a3b8;flex-shrink:0"></span>
<span style="flex:1"></span>
<button class="btn btn-primary" onclick="startRecording()" style="width:100%;padding:.55rem;font-size:.88rem;font-weight:600;flex-shrink:0" id="refine-btn">Start Recording →</button>
</div>
<!-- STEP 2: Record trajectory -->
<div class="step-panel" id="step-2">
<h3><span style="display:inline-block;width:8px;height:8px;background:#ef4444;border-radius:50%;margin-right:.4rem;animation:pulse 1s infinite"></span>Recording</h3>
<p class="hint">Do the task on the left. All actions are being captured.</p>
<div style="display:flex;gap:.3rem;margin-bottom:.5rem;align-items:center">
<button id="rec-btn" class="btn btn-recording" onclick="toggleRecording()">Recording...</button>
<span id="rec-status" style="font-size:.78rem;color:#ef4444;font-weight:600">REC</span>
<span id="capture-badge" style="font-size:.62rem;color:#94a3b8;border:1px solid #e2e8f0;border-radius:.6rem;padding:.05rem .4rem">screen: derived</span>
<a id="capture-retry" href="#" onclick="retryCapture();return false" style="display:none;font-size:.62rem;color:#2563eb">enable screen capture</a>
<span id="rec-count" style="font-size:.78rem;color:#64748b;margin-left:auto">0 actions</span>
</div>
<!-- Macro span summary -->
<div id="macro-span-summary" style="margin-top:.5rem"></div>
<div class="nav-btns">
<button class="btn btn-secondary" onclick="goStep(1)">← Back</button>
<button class="btn btn-primary" onclick="stopAndNext()">Stop Recording →</button>
</div>
</div>
<!-- STEP 3: Type answer -->
<div class="step-panel" id="step-3">
<h3>Step 3: Answer & Submit</h3>
<p class="hint">Provide the expected answer, then submit. Verifiers will be built later.</p>
<label>Answer <span style="font-weight:400;text-transform:none;color:#94a3b8">(if the task asks for a value)</span></label>
<textarea id="final-answer" rows="2" placeholder="The answer you found (e.g. '5', '$1,234.50', 'John Smith')"></textarea>
<div style="display:flex;gap:.5rem">
<div style="flex:1">
<label>Type</label>
<select id="answer-type">
<option value="string">Text</option>
<option value="number">Number</option>
<option value="boolean">Yes/No</option>
<option value="string_list">List</option>
</select>
</div>
<div style="flex:1">
<label>Alternatives</label>
<textarea id="alternatives" rows="1" placeholder="other valid answers (one per line)"></textarea>
</div>
</div>
<label>Expected Outcome <span style="font-weight:400;text-transform:none;color:#94a3b8">(plain English — what should the verifier check later?)</span></label>
<textarea id="expected-outcome" rows="3" placeholder="Describe what should be true after the task completes. Examples: - 'A new transaction of $45.20 should appear in banking' - 'The category filter shows only 5 grocery transactions' - 'The post was deleted from the blog'"></textarea>
<div style="margin-top:.6rem;padding:.5rem;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:.4rem;font-size:.75rem;color:#065f46">
<strong>What gets saved:</strong> instruction, macro, trajectory (all actions + network requests), expected answer, and outcome description.
Verifiers will be built in a separate review pass.
</div>
<div class="nav-btns">
<button class="btn btn-secondary" onclick="goStep(2)">← Back</button>
<button class="btn btn-success btn-lg" onclick="submitTask()">Submit & Next Task</button>
</div>
</div>
<!-- Hidden elements needed by verifier code (kept for future verifier view) -->
<div id="check-palette" style="display:none"><div id="palette-checks"></div></div>
<div id="verifier-builder" style="display:none"></div>
<div id="task-verdict" style="display:none"></div>
<div id="agent-results" style="display:none"></div>
<span id="verifier-status" style="display:none"></span>
<button id="agent-btn" style="display:none"></button>
</div>
<div class="bottom-bar">
<span class="status" id="status">Ready</span>
</div>
</div>
</div><!-- /top-row -->
<!-- Bottom: macro track editor (full width, resizable) -->
<div class="track-resizer" id="track-resizer"></div>
<div class="bottom-track" id="bottom-track">
<div style="display:flex;align-items:center;gap:.4rem;padding:.3rem .6rem;flex-shrink:0;border-bottom:1px solid #e2e8f0;background:#fff">
<span style="font-size:.78rem;font-weight:600;color:#1e293b">Macro Graph</span>
<button id="macro-help-btn" class="btn btn-secondary" style="font-size:.6rem;padding:.1rem .35rem" onclick="toggleMacroHelp()" title="How to work with macros">ⓘ How macros work</button>
<div style="position:relative">
<input type="text" id="macro-search" placeholder="+ add macro (Enter = AI search)" title="Type to filter by keyword; press Enter to search by meaning (semantic)" oninput="showMacroSearch(this.value)" onclick="showMacroSearch(this.value)" onkeydown="macroSearchKey(event)" style="font-size:.72rem;padding:.15rem .4rem;border:1px solid #cbd5e1;border-radius:.25rem;width:180px">
<div id="macro-search-results" style="display:none;position:fixed;background:#fff;border:1px solid #e2e8f0;border-radius:.3rem;box-shadow:0 4px 16px rgba(0,0,0,.18);max-height:220px;overflow-y:auto;z-index:9999;width:300px;font-size:.75rem"></div>
</div>
<button id="macro-add-btn" class="btn btn-secondary" style="font-size:.65rem;padding:.1rem .4rem" onclick="toggleAddMacroPanel()" title="Browse all macros by family, or propose a new one">+ Add macro ▾</button>
<div id="macro-add-panel" style="display:none;position:fixed;background:#fff;border:1px solid #e2e8f0;border-radius:.4rem;box-shadow:0 8px 28px rgba(0,0,0,.2);max-height:60vh;overflow-y:auto;z-index:9999;width:340px;font-size:.75rem"></div>
<button class="btn btn-secondary" style="font-size:.65rem;padding:.1rem .4rem;background:#f0fdf4;color:#166534;border-color:#bbf7d0" onclick="draftTask()" id="draft-task-btn" title="Draft a full task (subtasks + instruction) from the sampled macros + real site data — then edit it">✨ Draft task (LLM)</button>
<span id="draft-status" style="font-size:.6rem;color:#94a3b8"></span>
<button class="btn btn-secondary" style="font-size:.65rem;padding:.1rem .4rem" onclick="generateInstruction()">✨ Suggest instruction (LLM)</button>
<button class="btn btn-secondary" style="font-size:.65rem;padding:.1rem .4rem;background:#eff6ff;color:#1d4ed8;border-color:#bfdbfe" onclick="inferMacros()" id="infer-macros-btn" title="Reverse flow: write the task instruction first (top panel), then let the LLM decompose it into a macro chain (nodes + subtasks + reasoning ops) for you to correct">✨ Infer macros from instruction (LLM)</button>
<span id="infer-status" style="font-size:.6rem;color:#94a3b8"></span>
<button class="btn btn-secondary" style="font-size:.6rem;padding:.1rem .3rem" onclick="macroEdges=[];_drawArrows()">Clear arrows</button>
<span style="flex:1"></span>
<button onclick="toggleGraphExpand()" id="graph-expand-btn" class="btn btn-secondary" style="font-size:.6rem;padding:.1rem .3rem">⛶ Fullscreen</button>
</div>
<!-- Instruction panel (visible in fullscreen mode) -->
<div class="track-instruction" style="display:none;flex-direction:column;gap:.3rem;padding:.5rem .8rem;background:#fff;border-bottom:1px solid #e2e8f0;flex-shrink:0">
<label style="font-size:.72rem;font-weight:600;color:#475569;margin:0">Task Instruction</label>
<textarea id="track-instruction" rows="2" placeholder="Auto-generated from subtasks, or write directly..." style="font-size:.85rem;width:100%;border:1px solid #e2e8f0;border-radius:.3rem;padding:.3rem .5rem" oninput="document.getElementById('instruction').value=this.value"></textarea>
</div>
<!-- Graph + side help (row so help doesn't push the nodes down) -->
<div id="graph-row" style="display:flex;flex:1;min-height:0">
<div id="macro-graph" style="position:relative;flex:1;background:#f8fafc;overflow:auto">
<button id="graph-close-btn" onclick="toggleGraphExpand()" style="display:none;position:fixed;top:12px;right:16px;z-index:10000;background:#1e293b;color:#fff;border:none;border-radius:.4rem;padding:.4rem .8rem;font-size:.85rem;cursor:pointer">✕ Close</button>
<svg id="macro-arrows" style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:1"></svg>
<div id="macro-nodes" style="position:relative;z-index:2;min-height:100%"></div>
</div>
<!-- How-to-work-with-macros help (side column) -->
<div id="macro-help" style="width:320px;flex-shrink:0;border-left:1px solid #e2e8f0;background:#f8fafc;font-size:.82rem;color:#475569;line-height:1.5;padding:.6rem .8rem;overflow-y:auto">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.4rem">
<b style="color:#1e293b">Working with macros</b>
<span onclick="toggleMacroHelp()" style="cursor:pointer;color:#94a3b8;font-size:.66rem">hide ✕</span>
</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:.5rem .5rem;align-items:start">
<span style="font-weight:700;color:#2563eb">①</span><span><b>Add each interaction</b> the task performs — type in <span style="font-family:ui-monospace,monospace">+ add macro</span> or open <b>+ Add macro</b> to browse by family (form, filter, search, toggle, media, …).</span>
<span style="font-weight:700;color:#7c3aed">②</span><span><b>Set the reasoning op</b> on any macro that reads/derives a value: <span style="font-family:ui-monospace,monospace">read/memorize · extremum · count · compute · compare · verify · spatial</span> <a onclick="openOpsReference()" style="cursor:pointer;color:#2563eb;text-decoration:underline;font-size:.8em">what do these mean?</a>. The single value you <b>report back to the human</b> is a <b>report_information</b> macro — place it <b>last</b>, where the answer is on screen.</span>
<span style="font-weight:700;color:#0d9488">③</span><span><b>Draw dependencies</b> — drag from a node's <span style="color:#2563eb">➡</span> handle onto another node to order the steps (all macros must connect into one flow).</span>
<span style="font-weight:700;color:#d97706">④</span><span><b>Tag spans</b> — in <b>Record</b> (Step 2), select the action range for each macro and assign it.</span>
<span style="font-weight:700;color:#8b5cf6">+</span><span><b>Macro not in the list?</b> <b>+ Add macro → Propose a new macro</b> — name + description + span start / span end required.</span>
</div>
</div>
</div>
</div>
</div><!-- /main-layout -->
<!-- Spatial-macro design warning -->
<div id="spatial-warning-modal" class="skip-overlay" style="display:none">
<div class="skip-modal" style="border-top:4px solid #f59e0b">
<h3 style="color:#b45309">⚠ Tasks design notes based on sampled macros</h3>
<div id="spatial-warning-body" style="font-size:.82rem;color:#374151;line-height:1.45;max-height:50vh;overflow-y:auto"></div>
<div style="display:flex;margin-top:.8rem;justify-content:flex-end">
<button class="btn btn-primary" onclick="closeSpatialWarning()">Got it</button>
</div>
</div>
</div>
<!-- Reasoning-op reference (what each op means) -->
<div id="ops-reference-modal" class="skip-overlay" style="display:none">
<div class="skip-modal" style="border-top:4px solid #7c3aed;max-width:640px">
<h3 style="color:#6d28d9">Reasoning operations — what each one means</h3>
<p style="font-size:.78rem;color:#64748b;margin:.2rem 0 .6rem">The reasoning op is the operation the agent performs on values it reads from the page. Set it on any macro that reads or derives a value.</p>
<div id="ops-reference-body" style="font-size:.82rem;color:#374151;line-height:1.4;max-height:55vh;overflow-y:auto"></div>
<div style="display:flex;margin-top:.8rem;justify-content:flex-end">
<button class="btn btn-primary" onclick="closeOpsReference()">Got it</button>
</div>
</div>
</div>
<!-- Skip Modal -->
<div id="skip-modal" class="skip-overlay" style="display:none">
<div class="skip-modal">
<h3>Why skip this prompt?</h3>
<select id="skip-reason">
<option value="ambiguous">Ambiguous -- macros don't combine well</option>
<option value="broken">Broken -- site has bugs</option>
<option value="impossible">Impossible -- can't design a valid task</option>
<option value="other">Other</option>
</select>
<textarea id="skip-details" placeholder="Details..."></textarea>
<div style="display:flex;gap:.5rem;margin-top:.8rem;justify-content:flex-end">
<button class="btn btn-secondary" onclick="closeSkipModal()">Cancel</button>
<button class="btn btn-danger" onclick="confirmSkip()">Skip & Get New Prompt</button>
</div>
</div>
</div>
<script>
const macroDescriptions = {{ macro_descriptions | tojson }};
const macroLocations = {{ macro_locations | tojson }};
const OPERATIONS = {{ operations | tojson }}; // {op: {desc, ...}} reasoning axis
const ALL_MACROS = {{ all_macros | tojson }}; // [{key, group, description}] full registry
const MACRO_COVERAGE = {{ macro_coverage | tojson }}; // {macro: #tasks} — how covered each macro is
// A small badge showing how represented a macro is, so annotators can prefer the
// under-covered ones. 0 = red (none yet), 1-3 = amber (needs more), else gray.
function _covBadge(key) {
const n = MACRO_COVERAGE[key] || 0;
let bg = '#f1f5f9', fg = '#64748b', label = n + ' task' + (n === 1 ? '' : 's');
if (n === 0) { bg = '#fee2e2'; fg = '#b91c1c'; label = '⚠ none yet'; }
else if (n <= 3) { bg = '#fef3c7'; fg = '#92400e'; label = n + ' – needs more'; }
return `<span title="${n} annotated task(s) use this macro" style="background:${bg};color:${fg};padding:0 .35rem;border-radius:.25rem;font-size:.6rem;font-weight:600;white-space:nowrap">${label}</span>`;
}
const rerecordTask = {{ rerecord | tojson }}; // set when replacing an existing task
let currentPrompt = {{ prompt | tojson }};
let currentStep = 1;
// --- Macro INSTANCE ids (duplicate-macro support) -------------------------
// selectedMacros holds *instance ids*: the first occurrence of a macro is its
// plain name ("create_by_form"); a 2nd+ occurrence is suffixed with a delimiter
// unlikely to collide with any macro name ("create_by_form#2", "#3", ...). All
// per-node state (subtasks/ops/positions/spans/skip/NA/QA/edges) is keyed by
// instance id; anything about the macro ITSELF (description, ops, warnings, QA,
// span guidance, coverage, locations) is looked up via baseOf(id).
const MACRO_INST_DELIM = '#';
function baseOf(id) {
id = String(id);
const i = id.indexOf(MACRO_INST_DELIM);
return i < 0 ? id : id.slice(0, i);
}
// Derive instance ids for a plain list of base names (first = name, repeats suffixed)
function makeInstanceIds(bases) {
const seen = {}, out = [];
for (const raw of (bases || [])) {
const b = baseOf(raw);
seen[b] = (seen[b] || 0) + 1;
out.push(seen[b] === 1 ? b : b + MACRO_INST_DELIM + seen[b]);
}
return out;
}
// Next free instance id for a base, given what's already on the task
function nextInstanceId(base) {
base = baseOf(base);
if (!selectedMacros.includes(base)) return base;
let n = 2;
while (selectedMacros.includes(base + MACRO_INST_DELIM + n)) n++;
return base + MACRO_INST_DELIM + n;
}
// Whether the current task uses any duplicate macro (needs macro_instances on save)
function _hasDupMacros() { return selectedMacros.some(id => id !== baseOf(id)); }
// Prefer explicit instance ids (dup-aware saved tasks); else derive from base names.
function _initInstances(p) {
if (!p) return [];
if (p.macro_instances && p.macro_instances.length) return p.macro_instances.slice();
return makeInstanceIds(p.macros || []);
}
let selectedMacros = _initInstances(currentPrompt);
let trajectory = [];
let recording = false;
let recordInterval = null;
let macroSpans = {}; // {macroName: [actionIndex, ...]}
let selectedActionIdx = null; // currently selected action for tagging
let lastUrl = '';
// verifierConfigs populated by buildVerifiers()
let verifierCounter = 0;
let activeSiteIdx = 0;
let rewindPoint = null; // action index where we rewound to (null = no rewind)
let agentResult = null; // cached result from background agent run
let agentRunning = false;
function startBackgroundAgent() {
if (!currentPrompt || !currentPrompt.sites || agentRunning) return;
const instruction = document.getElementById('instruction')?.value;
if (!instruction || instruction.length < 10) return;
agentRunning = true;
const badge = document.getElementById('agent-status-badge');
if (badge) { badge.textContent = 'Agent running...'; badge.style.display = ''; }
fetch('/annotate/api/agent_validate', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
configs: [], // no verifiers yet — agent just solves the task
instruction: instruction,
sites: currentPrompt.sites,
})
}).then(r => r.json()).then(d => {
agentResult = d;
agentRunning = false;
if (badge) {
badge.textContent = d.agent_passed ? 'Agent: done' : 'Agent: done';
badge.style.background = '#dbeafe';
badge.style.color = '#1e40af';
}
}).catch(e => {
agentRunning = false;
agentResult = {agent_errors: [e.message], agent_answer: '', agent_passed: false, agent_response: 'Failed'};
if (badge) { badge.textContent = 'Agent: error'; badge.style.background = '#fecaca'; }
});
}
function _getLocationsForMacro(macroName) {
// Get locations from all currently sampled sites
if (!currentPrompt || !currentPrompt.sites) return [];
const locs = [];
for (const site of currentPrompt.sites) {
const siteId = typeof site === 'string' ? site : site.id;
const siteName = typeof site === 'string' ? site : (site.name || site.id);
const siteLocs = (macroLocations[siteId] || {})[baseOf(macroName)];
if (siteLocs && siteLocs.length) {
for (const loc of siteLocs) {
locs.push({site: siteName, loc});
}
}
}
return locs;
}
function showMacroTooltip(el, macroName) {
const tt = document.getElementById('macro-tooltip-el');
const desc = macroDescriptions[macroName] || {};
const locs = _getLocationsForMacro(macroName);
let html = `<div class="tt-desc">${desc.description || 'No description'}</div>`;
if (locs.length) {
html += '<ul class="tt-locs">';
for (const {site, loc} of locs) {
const siteLabel = currentPrompt.sites.length > 1 ? `<b>${site}</b>: ` : '';
html += `<li>${siteLabel}${loc}</li>`;
}
html += '</ul>';
} else {
html += '<div class="tt-no-loc">No known UI location for this macro on sampled sites</div>';
}
tt.innerHTML = html;
const rect = el.getBoundingClientRect();
tt.style.top = rect.top + 'px';
tt.style.left = (rect.left - 310) + 'px';
tt.style.display = 'block';
if (rect.left - 310 < 0) {
tt.style.left = rect.left + 'px';
tt.style.top = (rect.bottom + 4) + 'px';
}
}
function hideMacroTooltip() {
document.getElementById('macro-tooltip-el').style.display = 'none';
}
function resetSession() {
if (!confirm('Reset all site data to pristine state? This reverts any changes you made while exploring.')) return;
fetch('/_reset_data', { method: 'POST' })
.then(r => r.json())
.then(() => {
_sync2FAToggle(); // re-apply the Skip-2FA checkbox to the fresh session
const iframe = document.getElementById('site-iframe');
if (iframe) _reloadIframe(iframe);
alert('Session reset to pristine state.');
})
.catch(() => alert('Reset failed.'));
}
function _resetSessionSilent() {
return fetch('/_reset_data', { method: 'POST' }).then(r => r.json()).catch(() => {});
}
function getAnnotatorName() {
return sessionStorage.getItem('annotator_name') || '';
}
function submitNameGate() {
const name = document.getElementById('name-gate-input').value.trim();
if (!name) return;
sessionStorage.setItem('annotator_name', name);
document.getElementById('name-gate').style.display = 'none';
document.getElementById('annotator-display').textContent = name;
// Reset session to start fresh (logged out)
_resetSessionSilent().then(() => initAnnotation());
}
function updateInstructionBanner() {
const text = document.getElementById('instruction')?.value?.trim() || '';
const banner = document.getElementById('instruction-banner');
const bannerText = document.getElementById('instruction-banner-text');
if (banner && bannerText) {
if (text) {
bannerText.textContent = text.length > 120 ? text.slice(0, 117) + '...' : text;
banner.style.display = '';
} else {
banner.style.display = 'none';
}
}
}
function renderSiteLabels() {
const container = document.getElementById('site-labels');
if (!container || !currentPrompt || !currentPrompt.sites) { return; }
const bgs = ['#dbeafe','#fce7f3','#d1fae5','#fef3c7','#e0e7ff'];
const fgs = ['#1e40af','#9d174d','#065f46','#92400e','#3730a3'];
container.innerHTML = currentPrompt.sites.map((s, i) => {
const kind = s.type || s.category;
return `<span style="display:inline-block;padding:.15rem .5rem;border-radius:1rem;font-size:.75rem;font-weight:600;background:${bgs[i%5]};color:${fgs[i%5]};margin-right:.3rem">${s.name}${kind ? ` <span style="font-weight:400;opacity:.75">(${kind})</span>` : ''}</span>`;
}).join('');
}
// --- Re-record mode: hydrate the editor from the saved task -----------------
function hydrateRerecord() {
const t = rerecordTask;
if (!t) return;
macroSubtasks = t.macro_subtasks || {};
macroOps = t.macro_operations || {};
macroEdges = t.macro_edges || [];
macroPositions = t.macro_positions || {};
qaAnswers = t.qa_answers || {};
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
set('instruction', t.instruction || '');
set('final-answer', t.expected_answer || '');
set('answer-type', t.answer_type || 'string');
set('alternatives', t.alternatives || '');
set('expected-outcome', t.expected_outcome || '');
const login = document.getElementById('auto-login-toggle');
if (login) login.checked = t.requires_login !== false;
// banner + hide prompt-mutation controls (they would clobber the prefill)
const banner = document.createElement('div');
banner.style.cssText = 'background:#fffbeb;border:1px solid #fcd34d;color:#92400e;' +
'padding:.4rem .7rem;font-size:.75rem;border-radius:.3rem;margin:.3rem .6rem;font-weight:600';
banner.textContent = (window.MINIWEB_DEMO // the public demo (app/demo.py)
? t.task_id + ' (by ' + t.annotator + '), filled in from the released tasks. Explore, record, run its ' +
'verifier: nothing you change is saved.'
: 'Re-recording ' + t.task_id + ' (by ' + t.annotator + ') — saving will REPLACE the original recording.') +
(t.missing_sites && t.missing_sites.length
? ' ⚠ Site(s) no longer in registry: ' + t.missing_sites.join(', ')
: '');
const panel = document.querySelector('.main-layout') || document.body;
panel.parentNode.insertBefore(banner, panel);
document.querySelectorAll('button').forEach(b => {
const txt = (b.textContent || '').trim();
if (txt === '↻ New' || txt === 'Skip') b.style.display = 'none';
});
const sst = document.getElementById('site-count-picker');
if (sst && sst.parentElement) sst.parentElement.style.display = 'none';
const sp = document.getElementById('site-picker');
if (sp) sp.style.display = 'none';
}
function initAnnotation() {
hydrateRerecord();
if (currentPrompt) {
renderSiteLabels();
renderMacroList();
updateChainDisplay();
renderTagPalette();
if (currentPrompt.sites.length > 0) {
loadApiRef(currentPrompt.sites[0].id);
}
// Sync the Skip-2FA checkbox to the session: the box renders checked
// by default but the session flag is only set by its change handler,
// so a fresh/reset session would otherwise still trigger 2FA until
// the box is unchecked and re-checked.
_sync2FAToggle();
// Auto-login if toggle is checked, otherwise ensure logged out + reload iframe
const toggle = document.getElementById('auto-login-toggle');
const ready = (toggle && toggle.checked) ? autoLoginSites() : _logoutSites();
ready.then(() => {
const iframe = document.getElementById('site-iframe');
if (iframe) _reloadIframe(iframe);
});
}
}
function _sync2FAToggle() {
const t = document.getElementById('disable-2fa-toggle');
if (t) handle2FAToggle(t.checked);
}
async function autoLoginSites() {
if (!currentPrompt || !currentPrompt.sites.length) return;
const siteIds = currentPrompt.sites.map(s => s.id);
const statusEl = document.getElementById('login-status');
statusEl.textContent = 'Logging in...';
try {
const r = await fetch('/annotate/api/auto_login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ sites: siteIds })
});
const d = await r.json();
if (d.logged_in && d.logged_in.length) {
statusEl.textContent = 'Logged in to ' + d.logged_in.length + ' site(s)';
} else {
statusEl.textContent = 'No sites to log in';
}
} catch(e) { statusEl.textContent = 'Login failed'; }
}
async function handleLoginToggle(checked) {
const statusEl = document.getElementById('login-status');
const iframe = document.getElementById('site-iframe');
if (checked) {
await autoLoginSites();
} else {
await _logoutSites();
statusEl.textContent = 'Logged out';
}
if (iframe) _reloadIframe(iframe);
}
async function _logoutSites() {
return fetch('/annotate/api/auto_logout', { method: 'POST' }).then(r => r.json()).catch(() => {});
}
async function handle2FAToggle(disabled) {
fetch('/annotate/api/toggle_2fa', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ disable: disabled })
}).catch(() => {});
}
function _reloadIframe(iframe) {
iframe.src = iframe.src;
}
async function refineInstruction() {
const text = document.getElementById('instruction').value.trim();
if (!text) { alert('Write an instruction first, then refine it.'); return; }
const btn = document.getElementById('refine-btn');
const status = document.getElementById('refine-status');
btn.disabled = true;
status.textContent = 'Refining...';
document.getElementById('refined-box').style.display = 'none';
try {
const r = await fetch('/annotate/api/refine', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
instruction: text,
sites: currentPrompt ? currentPrompt.sites.map(s => s.id) : [],
macros: getEnabledMacros(),
})
});
const d = await r.json();
if (d.refined) {
document.getElementById('refined-text').textContent = d.refined;
document.getElementById('refined-box').style.display = 'block';
status.textContent = '';
} else {
status.textContent = d.error || 'Refinement failed';
}
} catch(e) {
status.textContent = 'Error: ' + e.message;
}
btn.disabled = false;
}
function acceptRefinement() {
const refined = document.getElementById('refined-text').textContent;
if (refined) {
document.getElementById('instruction').value = refined;
document.getElementById('refined-box').style.display = 'none';
}
}
document.addEventListener('DOMContentLoaded', () => {
const savedName = getAnnotatorName();
if (savedName) {
document.getElementById('annotator-display').textContent = savedName;
// Reset session for fresh start, then init
_resetSessionSilent().then(() => initAnnotation());
} else {
document.getElementById('name-gate').style.display = 'flex';
document.getElementById('name-gate-input').focus();
}
});
// --- Steps ---
function goStep(n) {
currentStep = n;
document.querySelectorAll('.step-panel').forEach(p => p.classList.remove('active'));
document.getElementById('step-' + n).classList.add('active');
document.querySelectorAll('.steps-bar .step').forEach((s, i) => {
s.classList.remove('active');
s.classList.toggle('done', i + 1 < n);
});
document.querySelectorAll('.steps-bar .step')[n - 1].classList.add('active');
// Redraw macro graph arrows when returning to step 1
if (n === 1) {
setTimeout(() => { _drawArrows(); _resizeGraph(); }, 50);
}
// Show action sidebar from step 2 onward (when there are actions)
const sidebar = document.getElementById('action-sidebar');
if (n >= 2 && trajectory.length > 0) {
sidebar.classList.add('visible');
} else if (n === 1) {
sidebar.classList.remove('visible');
}
// Background agent disabled for now
// if (n === 2 && !agentRunning && !agentResult) {
// startBackgroundAgent();
// }
// Prefill expected outcome with macro labels when entering step 3
if (n === 3) {
const outcomeEl = document.getElementById('expected-outcome');
if (outcomeEl && !outcomeEl.value.trim()) {
const enabled = getEnabledMacros();
const lines = enabled.map(m => {
const label = (macroDescriptions[m] || {}).description || m;
return `${label}: `;
});
outcomeEl.value = lines.join('\n');
}
}
}
// --- Macro list with checkboxes, tooltips, reorder ---
let macrosNotApplicable = {}; // {macro: site_id} — doesn't apply to this site
let macrosSkipped = {}; // {macro: true} — not needed for this task
let qaAnswers = {}; // {macro: answer} — inline answers for QA macros
const QA_VERBS = new Set(['extract','compute','count','compare','verify','calculate','rank','list']);
function isQAMacro(macro) { return QA_VERBS.has(macro.split('_')[0]); }
// Macro graph state
let macroPositions = {}; // {macro: {x, y}}
let macroEdges = []; // [{from, to}]
let macroSubtasks = {}; // {macro: "subtask text"}
let macroOps = {}; // {macro: reasoning op} ← reasoning axis (two-axis model)
let _arrowDrag = null; // {from: macroName, startX, startY}
function renderMacroList() {
const container = document.getElementById('macro-nodes');
const graphEl = document.getElementById('macro-graph');
if (!container) return;
// Alias the description registry for every duplicate INSTANCE id so the many
// `macroDescriptions[id]` read sites (name, warning, span guidance, labels)
// resolve without threading baseOf() through each one.
selectedMacros.forEach(id => {
const b = baseOf(id);
if (id !== b && !macroDescriptions[id] && macroDescriptions[b]) {
macroDescriptions[id] = macroDescriptions[b];
}
});
// Initialize positions if not set — auto-layout in a grid
const gw = (graphEl && graphEl.offsetWidth > 50) ? graphEl.offsetWidth - 20 : 300;
const cols = Math.max(1, Math.floor(gw / 170)) || 2;
selectedMacros.forEach((m, i) => {
if (!macroPositions[m]) {
macroPositions[m] = {
x: 10 + (i % cols) * 160,
y: 10 + Math.floor(i / cols) * 80
};
}
});
// Render nodes
container.innerHTML = '';
selectedMacros.forEach((m, i) => {
const isNA = m in macrosNotApplicable;
const isSkip = m in macrosSkipped;
const excluded = isNA || isSkip;
const isQA = isQAMacro(m);
const macroDesc = (macroDescriptions[m] || {}).description || m;
const pos = macroPositions[m] || {x: 10, y: 10 + i * 70};
const node = document.createElement('div');
node.className = 'macro-node';
node.dataset.macro = m;
node.style.left = pos.x + 'px';
node.style.top = pos.y + 'px';
if (excluded) node.style.opacity = '.35';
// Header: arrow handle + name + skip/NA buttons
let html = `<div class="mn-header">
<span class="mn-arrow-handle" onmousedown="startArrowDrag(event,'${m}')" title="Drag to another node to draw arrow">➡</span>
<span class="mn-name" title="${escHtml(macroDesc)}">${escHtml(m)}</span>
${isQA ? '<span style="font-size:.7rem;color:#2563eb;background:#eff6ff;padding:.05rem .3rem;border-radius:.15rem">QA</span>' : ''}
${macroDesc && macroDescriptions[m] && macroDescriptions[m].warning ? `<span title="${escHtml(macroDescriptions[m].warning)}" style="font-size:.7rem;color:#b45309;background:#fffbeb;border:1px solid #fcd34d;padding:.05rem .3rem;border-radius:.15rem;cursor:help">⚠ spatial</span>` : ''}
${proposedMacros[m] ? '<span title="proposed new macro" style="font-size:.7rem;color:#6d28d9;background:#f5f3ff;border:1px solid #c4b5fd;padding:.05rem .3rem;border-radius:.15rem">NEW</span>' : ''}
</div>`;
// Buttons
html += `<div class="mn-btns">
<button class="${isSkip ? 'active' : ''}" onclick="event.stopPropagation();toggleSkip('${m}')">${isSkip ? 'skip \u2713' : 'skip'}</button>
<button class="${isNA ? 'active' : ''}" onclick="event.stopPropagation();toggleNA('${m}')" style="${isNA ? 'background:#fecaca;border-color:#f87171;color:#dc2626' : ''}">${isNA ? 'N/A \u2713' : 'N/A'}</button>
</div>`;
// Subtask input
if (!excluded) {
const saved = macroSubtasks[m] || '';
html += `<textarea class="mn-subtask" rows="1" placeholder="What to do for this step..."
onmousedown="event.stopPropagation()" onclick="event.stopPropagation()"
oninput="macroSubtasks['${m}']=this.value">${escHtml(saved)}</textarea>`;
}
// Reasoning-op axis (two-axis model): optional operation on this macro
const curOp = macroOps[m] || '';
if (!excluded) {
const opOpts = ['<option value="">— no reasoning —</option>'].concat(
Object.keys(OPERATIONS).map(op =>
`<option value="${op}" ${op === curOp ? 'selected' : ''}>${(OPERATIONS[op] && OPERATIONS[op].label) || op}</option>`)).join('');
html += `<div class="mn-op-row" style="display:flex;align-items:center;gap:.35rem">
<select class="mn-op ${curOp ? 'set' : ''}" title="Reasoning operation on this macro"
onmousedown="event.stopPropagation()" onclick="event.stopPropagation()"
onchange="setMacroOp('${m}', this.value)">${opOpts}</select>
<span class="mn-op-help" title="What do the reasoning ops mean?"
onmousedown="event.stopPropagation()"
onclick="event.stopPropagation();openOpsReference()"
style="cursor:pointer;font-size:.7rem;color:#2563eb;white-space:nowrap;border:1px solid #bfdbfe;background:#eff6ff;border-radius:.3rem;padding:.1rem .35rem">ⓘ ops guide</span>
</div>`;
if (curOp && OPERATIONS[curOp]) {
html += `<div class="mn-op-hint">${escHtml(OPERATIONS[curOp].desc || '')}</div>`;
}
}
// Predefined span guidance from the registry — where this macro's span
// should begin and end, so the annotator tags the right action range.
if (!excluded) {
const md = macroDescriptions[m] || {};
if (md.span_start || md.span_end) {
html += `<div class="mn-span" title="where this macro's span should begin and end">`
+ `<b>starts:</b> ${escHtml(md.span_start || '?')} <b>ends:</b> ${escHtml(md.span_end || '?')}</div>`;
}
}
// Expected-answer input — shown when this macro carries a reasoning op
// (op selected), or for a legacy QA verb macro.
if (!excluded && (curOp || isQA)) {
const savedAns = qaAnswers[m] || '';
html += `<input type="text" class="mn-qa-answer" placeholder="Expected answer / value..."
value="${escHtml(savedAns)}"
onmousedown="event.stopPropagation()" onclick="event.stopPropagation()"
oninput="qaAnswers['${m}']=this.value"
style="width:100%;padding:.25rem .4rem;font-size:.72rem;border:1px solid #93c5fd;border-radius:.25rem;background:#eff6ff;margin-top:.2rem">`;
}
// UI locations (collapsed)
if (!excluded) {
const locs = _getLocationsForMacro(m);
if (locs.length) {
html += '<ul class="macro-locs">';
for (const {site, loc} of locs) {
const siteLabel = currentPrompt && currentPrompt.sites.length > 1 ? `<b>${site}</b>: ` : '';
html += `<li>${siteLabel}${loc}</li>`;
}
html += '</ul>';
}
}
node.innerHTML = html;
// Make node draggable — works from any part of the node
(function(nodeEl, macro, position) {
let isDrag = false;
let offsetX = 0, offsetY = 0;
nodeEl.addEventListener('mousedown', function(e) {
// Skip arrow handle and buttons
const t = e.target;
if (t.closest('.mn-arrow-handle') || t.closest('button') || t.tagName === 'INPUT') return;
e.preventDefault();
e.stopPropagation();
const rect = graphEl.getBoundingClientRect();
offsetX = e.clientX - rect.left - position.x;
offsetY = e.clientY - rect.top - position.y;
isDrag = true;
nodeEl.classList.add('dragging');
nodeEl.style.zIndex = '20';
function onMove(ev) {
if (!isDrag) return;
const r = graphEl.getBoundingClientRect();
position.x = Math.max(0, ev.clientX - r.left - offsetX);
position.y = Math.max(0, ev.clientY - r.top - offsetY);
nodeEl.style.left = position.x + 'px';
nodeEl.style.top = position.y + 'px';
macroPositions[macro] = {x: position.x, y: position.y};
_drawArrows();
}
function onUp() {
isDrag = false;
nodeEl.classList.remove('dragging');
nodeEl.style.zIndex = '2';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
_resizeGraph();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
})(node, m, pos);
container.appendChild(node);
});
_resizeGraph();
_drawArrows();
maybeShowSpatialWarnings();
}
// --- Spatial-macro design warnings ---------------------------------------
// Some macros are graded spatially (where the signature lands, what pan/zoom
// reveals, what a timestamp holds). When such a macro enters the working set,
// pop the registry's design guidance once per distinct warned-set.
let _spatialWarned = '';
function maybeShowSpatialWarnings() {
const warned = (selectedMacros || []).filter(m => (macroDescriptions[m] || {}).warning);
const key = warned.slice().sort().join(',');
if (!key || key === _spatialWarned) { _spatialWarned = key; return; }
_spatialWarned = key;
document.getElementById('spatial-warning-body').innerHTML = warned.map(m =>
`<div style="margin-bottom:.7rem">
<b style="font-family:ui-monospace,monospace">${escHtml(m)}</b>
<div style="margin-top:.2rem">${escHtml(macroDescriptions[m].warning)}</div>
</div>`).join('');
document.getElementById('spatial-warning-modal').style.display = 'flex';
}
function closeSpatialWarning() {
document.getElementById('spatial-warning-modal').style.display = 'none';
}
// --- Reasoning-op reference (what each op means) --------------------------
function openOpsReference() {
const rows = Object.entries(OPERATIONS || {}).map(function(entry) {
const op = entry[0], info = entry[1] || {};
const label = info.label || op;
return `<div style="margin-bottom:.7rem;padding-bottom:.6rem;border-bottom:1px solid #eef2f7">
<b style="font-family:ui-monospace,monospace;color:#6d28d9">${escHtml(label)}</b>
<div style="margin-top:.15rem">${escHtml(info.desc || '')}</div>
${info.check ? `<div style="margin-top:.15rem;color:#64748b;font-size:.92em"><b>graded by:</b> ${escHtml(info.check)}</div>` : ''}
</div>`;
}).join('');
document.getElementById('ops-reference-body').innerHTML = rows ||
'<div style="color:#64748b">No reasoning operations loaded.</div>';
document.getElementById('ops-reference-modal').style.display = 'flex';
}
function closeOpsReference() {
document.getElementById('ops-reference-modal').style.display = 'none';
}
function _resizeGraph() {
// Expand graph container to fit all nodes
const graphEl = document.getElementById('macro-graph');
if (!graphEl) return;
let maxY = 200;
for (const m of selectedMacros) {
const p = macroPositions[m];
if (p && p.y + 90 > maxY) maxY = p.y + 90;
}
graphEl.style.minHeight = maxY + 'px';
}
function startArrowDrag(e, fromMacro) {
e.preventDefault();
e.stopPropagation();
const graphEl = document.getElementById('macro-graph');
const rect = graphEl.getBoundingClientRect();
_arrowDrag = { from: fromMacro, startX: e.clientX - rect.left, startY: e.clientY - rect.top };
const svg = document.getElementById('macro-arrows');
let tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
tempPath.id = 'temp-arrow';
tempPath.setAttribute('stroke', '#2563eb');
tempPath.setAttribute('stroke-width', '2.5');
tempPath.setAttribute('stroke-dasharray', '6 3');
tempPath.setAttribute('fill', 'none');
tempPath.setAttribute('marker-end', 'url(#arrowhead)');
svg.appendChild(tempPath);
// Highlight potential targets
document.querySelectorAll('.macro-node').forEach(n => {
if (n.dataset.macro !== fromMacro) n.classList.add('arrow-target');
});
function onMove(ev) {
const x = ev.clientX - rect.left;
const y = ev.clientY - rect.top;
const sx = _arrowDrag.startX, sy = _arrowDrag.startY;
const dx = Math.abs(x - sx);
const cp = Math.max(50, dx * 0.4);
tempPath.setAttribute('d', `M ${sx} ${sy} C ${sx+cp} ${sy}, ${x-cp} ${y}, ${x} ${y}`);
}
function onUp(ev) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
tempPath.remove();
document.querySelectorAll('.arrow-target').forEach(n => n.classList.remove('arrow-target'));
// Find which node we dropped on
const x = ev.clientX, y = ev.clientY;
const target = document.elementFromPoint(x, y)?.closest('.macro-node');
if (target && target.dataset.macro && target.dataset.macro !== fromMacro) {
const toMacro = target.dataset.macro;
// Don't add duplicate edges
if (!macroEdges.some(e => e.from === fromMacro && e.to === toMacro)) {
macroEdges.push({from: fromMacro, to: toMacro});
_drawArrows();
}
}
_arrowDrag = null;
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
function _drawArrows() {
const svg = document.getElementById('macro-arrows');
if (!svg) return;
// Ensure arrowhead marker exists (larger, more visible)
if (!svg.querySelector('#arrowhead')) {
svg.innerHTML = `<defs>
<marker id="arrowhead" markerWidth="14" markerHeight="10" refX="12" refY="5" orient="auto" markerUnits="userSpaceOnUse">
<path d="M 0 0 L 14 5 L 0 10 L 3 5 Z" fill="#2563eb"/>
</marker>
<marker id="arrowhead-hover" markerWidth="14" markerHeight="10" refX="12" refY="5" orient="auto" markerUnits="userSpaceOnUse">
<path d="M 0 0 L 14 5 L 0 10 L 3 5 Z" fill="#ef4444"/>
</marker>
</defs>`;
}
// Remove old arrows and delete buttons
svg.querySelectorAll('.edge-path').forEach(el => el.remove());
document.querySelectorAll('.edge-delete-btn').forEach(el => el.remove());
const nodesContainer = document.getElementById('macro-nodes');
for (let i = 0; i < macroEdges.length; i++) {
const edge = macroEdges[i];
const fromPos = macroPositions[edge.from];
const toPos = macroPositions[edge.to];
if (!fromPos || !toPos) continue;
const fromNode = document.querySelector(`.macro-node[data-macro="${edge.from}"]`);
const toNode = document.querySelector(`.macro-node[data-macro="${edge.to}"]`);
const fw = fromNode ? fromNode.offsetWidth : 150;
const fh = fromNode ? fromNode.offsetHeight : 60;
const tw = toNode ? toNode.offsetWidth : 150;
const th = toNode ? toNode.offsetHeight : 60;
const x1 = fromPos.x + fw;
const y1 = fromPos.y + fh / 2;
const x2 = toPos.x;
const y2 = toPos.y + th / 2;
const dx = Math.abs(x2 - x1);
const cpOffset = Math.max(50, dx * 0.4);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.className.baseVal = 'edge-path';
path.setAttribute('d', `M ${x1} ${y1} C ${x1+cpOffset} ${y1}, ${x2-cpOffset} ${y2}, ${x2} ${y2}`);
path.setAttribute('stroke', '#2563eb');
path.setAttribute('stroke-width', '2.5');
path.setAttribute('fill', 'none');
path.setAttribute('marker-end', 'url(#arrowhead)');
svg.appendChild(path);
// HTML delete button at midpoint — easy to click
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
const delBtn = document.createElement('button');
delBtn.className = 'edge-delete-btn';
delBtn.textContent = '×';
delBtn.title = 'Delete this connection';
delBtn.style.cssText = 'position:absolute;left:' + (midX - 10) + 'px;top:' + (midY - 10) + 'px;width:20px;height:20px;border-radius:50%;background:#ef4444;color:#fff;border:none;font-size:14px;line-height:18px;text-align:center;cursor:pointer;z-index:5;opacity:0.7;padding:0;';
const idx = i;
delBtn.addEventListener('click', function(ev) {
ev.stopPropagation();
macroEdges.splice(idx, 1);
_drawArrows();
});
delBtn.addEventListener('mouseenter', function() { this.style.opacity = '1'; path.setAttribute('stroke', '#ef4444'); });
delBtn.addEventListener('mouseleave', function() { this.style.opacity = '0.7'; path.setAttribute('stroke', '#2563eb'); });
nodesContainer.appendChild(delBtn);
}
}
// Keep old name as alias for compatibility
function updateChainDisplay() { _drawArrows(); }
// --- Panel resizer ---
(function() {
const resizer = document.getElementById('panel-resizer');
const panel = document.getElementById('right-panel');
if (!resizer || !panel) return;
let startX, startW;
resizer.addEventListener('mousedown', function(e) {
e.preventDefault();
startX = e.clientX;
startW = panel.offsetWidth;
resizer.classList.add('active');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
// Prevent iframe from stealing mouse events during resize
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = 'none');
function onMove(ev) {
const dx = startX - ev.clientX;
const newW = Math.max(350, Math.min(window.innerWidth * 0.8, startW + dx));
panel.style.width = newW + 'px';
panel.style.flexShrink = '0';
panel.style.flexGrow = '0';
panel.style.flexBasis = newW + 'px';
}
function onUp() {
resizer.classList.remove('active');
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = '');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
renderMacroList();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
})();
function showMacroSearch(q) {
const results = document.getElementById('macro-search-results');
if (!q || q.length < 2) { results.style.display = 'none'; return; }
q = q.toLowerCase();
// Search all macros from macroDescriptions. Duplicates are allowed, so we do
// NOT skip already-added macros — you can add the same macro again. We DO skip
// the instance-id alias entries (e.g. "create_by_form#2") that renderMacroList
// stamps into macroDescriptions, so each base macro appears once.
const matches = [];
for (const [key, desc] of Object.entries(macroDescriptions)) {
if (baseOf(key) !== key) continue; // alias for a duplicate instance — skip
const text = (desc.description || '') + ' ' + key;
if (text.toLowerCase().includes(q)) {
matches.push({key, desc: desc.description || key});
}
}
if (!matches.length) {
results.innerHTML = '<div style="padding:.4rem .5rem;color:#94a3b8">No matching macros</div>';
_positionMacroResults();
return;
}
results.innerHTML = matches.slice(0, 10).map(m =>
`<div style="padding:.4rem .6rem;cursor:pointer;border-bottom:1px solid #f1f5f9"
onmousedown="addMacroFromSearch('${m.key}')"
onmouseenter="this.style.background='#eff6ff'"
onmouseleave="this.style.background=''">
<div style="display:flex;justify-content:space-between;align-items:center;gap:.4rem">
<div style="font-weight:500;color:#1e293b">${escHtml(m.desc)}</div>
${_covBadge(m.key)}
</div>
<div style="font-size:.65rem;color:#94a3b8">${m.key}</div>
</div>`
).join('');
_positionMacroResults();
}
function addMacroFromSearch(macroKey) {
// Always add a NEW instance (duplicates allowed). First occurrence keeps the
// plain name; repeats get a "#N" suffix.
selectedMacros.push(nextInstanceId(macroKey));
renderMacroList();
// Keep the search panel usable: DON'T clear/hide it. Re-focus the input and
// re-run the search so you can add several macros in a row without re-clicking.
// Adding a node can grow the graph and shift the input, so re-show also
// repositions the fixed dropdown under the (possibly moved) input.
// Keep focus where the user is adding from: the browse panel's filter if it's
// open (so you can keep filtering + adding several), else the top search box.
const panel = document.getElementById('macro-add-panel');
if (panel && panel.style.display === 'block') {
const f = document.getElementById('macro-add-filter');
if (f) f.focus();
_positionAddMacroPanel();
return;
}
const input = document.getElementById('macro-search');
if (input) {
const q = input.value;
input.focus();
showMacroSearch(q);
}
}
// How-to-work-with-macros help strip (collapsed state persisted).
function toggleMacroHelp() {
const el = document.getElementById('macro-help');
if (!el) return;
const hide = el.style.display !== 'none';
el.style.display = hide ? 'none' : '';
try { localStorage.setItem('macro_help_hidden', hide ? '1' : '0'); } catch(e) {}
const btn = document.getElementById('macro-help-btn');
if (btn) btn.style.display = hide ? '' : 'none';
}
(function initMacroHelp() {
let hidden = false;
try { hidden = localStorage.getItem('macro_help_hidden') === '1'; } catch(e) {}
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('macro-help');
const btn = document.getElementById('macro-help-btn');
if (el) el.style.display = hidden ? 'none' : '';
if (btn) btn.style.display = hidden ? '' : 'none';
});
})();
// Reasoning axis: set/clear the operation on a macro node.
function setMacroOp(macro, op) {
if (op) macroOps[macro] = op; else delete macroOps[macro];
renderMacroList();
}
// --- Add-macro panel: browse the full registry by family, or propose a new macro ---
function toggleAddMacroPanel() {
const p = document.getElementById('macro-add-panel');
if (!p) return;
if (p.style.display === 'block') { p.style.display = 'none'; return; }
renderAddMacroPanel();
p.style.display = 'block';
_positionAddMacroPanel();
}
// Keep the panel fully on-screen: shift left if it overflows the right edge,
// flip above the button if it overflows the bottom.
function _positionAddMacroPanel() {
const p = document.getElementById('macro-add-panel');
const btn = document.getElementById('macro-add-btn');
if (!p || !btn || p.style.display !== 'block') return;
const r = btn.getBoundingClientRect();
const m = 8, pw = p.offsetWidth, ph = p.offsetHeight;
let left = Math.min(r.left, window.innerWidth - pw - m);
left = Math.max(m, left);
let top = r.bottom + 4;
if (top + ph > window.innerHeight - m) {
const above = r.top - ph - 4;
top = above >= m ? above : Math.max(m, window.innerHeight - ph - m);
}
p.style.left = left + 'px';
p.style.top = top + 'px';
}
window.addEventListener('resize', _positionAddMacroPanel);
window.addEventListener('scroll', _positionAddMacroPanel, true);
function renderAddMacroPanel(filter) {
const p = document.getElementById('macro-add-panel');
filter = filter || '';
// Build the panel SHELL once: a filter input that is NEVER recreated (so it
// keeps focus + cursor as you type) and a FIXED-HEIGHT results area (so the
// panel never resizes as the match count changes — that resizing is what made
// the search bar jump and become un-clickable). Only the chip list re-renders
// on each keystroke, via filterAddMacroList(); the input and the propose-a-
// macro form (whose typed values would otherwise be wiped) stay put.
const inS = "width:100%;font-size:.7rem;padding:.22rem .35rem;border:1px solid #c4b5fd;border-radius:.25rem;margin-top:.15rem";
p.innerHTML = `
<div style="padding:.3rem .5rem;border-bottom:1px solid #e2e8f0;display:flex;gap:.3rem;align-items:center">
<input id="macro-add-filter" placeholder="filter…" autocomplete="off" oninput="filterAddMacroList(this.value)"
style="flex:1;font-size:.7rem;padding:.2rem .35rem;border:1px solid #cbd5e1;border-radius:.25rem">
<span style="font-size:.6rem;color:#94a3b8">${ALL_MACROS.length} macros</span>
</div>
<div id="macro-add-results" style="height:42vh;overflow-y:auto"></div>
<div style="padding:.45rem .55rem;border-top:1px solid #e2e8f0;background:#f8fafc">
<div style="font-size:.58rem;text-transform:uppercase;letter-spacing:.04em;color:#8b5cf6;font-weight:700">Propose a new macro <span style="color:#ef4444">*all fields required</span></div>
<input id="macro-new-name" placeholder="new_macro_name (snake_case)" style="${inS};font-family:ui-monospace,monospace">
<textarea id="macro-new-desc" rows="2" placeholder="Natural-language description — what the interaction is" style="${inS};resize:vertical;font-family:inherit"></textarea>
<div style="display:flex;gap:.3rem">
<input id="macro-new-spanstart" placeholder="span start (when it begins)" style="${inS}">
<input id="macro-new-spanend" placeholder="span end (when it ends)" style="${inS}">
</div>
<div style="display:flex;align-items:center;gap:.4rem;margin-top:.3rem">
<button class="btn btn-sm" onclick="proposeNewMacro()" style="font-size:.65rem;background:#8b5cf6;color:#fff;border:none">Add to task</button>
<span id="macro-new-err" style="font-size:.6rem;color:#ef4444"></span>
</div>
</div>`;
const inp = document.getElementById('macro-add-filter');
inp.value = filter;
filterAddMacroList(filter);
_positionAddMacroPanel(); // clamp once on open; the panel height is now constant
inp.focus();
}
// Re-render ONLY the grouped macro chips into the fixed-height results area. The
// filter input and the propose-a-macro form are left untouched, so focus/cursor
// are never lost and the panel keeps a constant size however many macros match.
function filterAddMacroList(filter) {
const box = document.getElementById('macro-add-results');
if (!box) return;
const f = (filter || '').toLowerCase();
const groups = {};
ALL_MACROS.forEach(m => {
if (f && !(m.key + ' ' + (m.description||'')).toLowerCase().includes(f)) return;
(groups[m.group] = groups[m.group] || []).push(m);
});
let html = '';
Object.keys(groups).forEach(g => {
html += `<div style="padding:.25rem .55rem 0;font-size:.58rem;text-transform:uppercase;letter-spacing:.04em;color:#94a3b8;font-weight:700">${g}</div>
<div style="display:flex;flex-wrap:wrap;gap:.25rem;padding:.2rem .5rem .35rem">`;
html += groups[g].map(m => {
const n = MACRO_COVERAGE[m.key] || 0;
const ring = n === 0 ? 'box-shadow:inset 0 0 0 1.5px #fca5a5;'
: n <= 3 ? 'box-shadow:inset 0 0 0 1.5px #fcd34d;' : '';
return `<span class="macro-chip" title="${escHtml(m.description)} — ${n} annotated task(s)"
style="${ring}display:inline-flex;align-items:center;gap:.25rem"
onmousedown="addMacroFromSearch('${m.key}')">${m.key} ${_covBadge(m.key)}</span>`;
}).join('');
html += `</div>`;
});
if (!html) html = '<div style="padding:.6rem .7rem;color:#94a3b8">No matching macros</div>';
box.innerHTML = html;
}
function proposeNewMacro() {
const err = document.getElementById('macro-new-err');
const val = id => (document.getElementById(id).value || '').trim();
let name = val('macro-new-name').toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
const desc = val('macro-new-desc'), s0 = val('macro-new-spanstart'), s1 = val('macro-new-spanend');
// ALL fields required (family/group is assigned later)
const missing = [];
if (!name) missing.push('name');
if (!desc) missing.push('description');
if (!s0) missing.push('span start');
if (!s1) missing.push('span end');
if (missing.length) { if (err) err.textContent = 'Required: ' + missing.join(', '); return; }
if (ALL_MACROS.some(m => m.key === name) || selectedMacros.includes(name)) {
if (err) err.textContent = '“' + name + '” already exists'; return;
}
selectedMacros.push(nextInstanceId(name));
proposedMacros[name] = true;
proposedMacroDefs[name] = {description: desc, span_start: s0, span_end: s1};
macroDescriptions[name] = {description: desc, span_start: s0, span_end: s1}; // so the node renders
document.getElementById('macro-add-panel').style.display = 'none';
renderMacroList();
}
let proposedMacros = {}; // macros the annotator proposed that aren't in the registry
let proposedMacroDefs = {}; // {name: {description, span_start, span_end}} — required for each proposed macro
document.addEventListener('click', function(e) {
if (!e.target.closest('#macro-add-panel') && !e.target.closest('#macro-add-btn')) {
const p = document.getElementById('macro-add-panel');
if (p) p.style.display = 'none';
}
});
// Position the results dropdown under the search input, clamped to the viewport
// so it can never overflow off-screen (it already caps height at 220px + scroll).
function _positionMacroResults() {
const results = document.getElementById('macro-search-results');
const rect = document.getElementById('macro-search').getBoundingClientRect();
results.style.bottom = 'auto';
results.style.display = 'block';
const margin = 8;
const pw = results.offsetWidth || rect.width;
const ph = results.offsetHeight || 220;
let left = Math.min(rect.left, window.innerWidth - pw - margin);
left = Math.max(margin, left);
let top = rect.bottom + 4;
if (top + ph > window.innerHeight - margin) {
const above = rect.top - ph - 4;
top = above >= margin ? above : Math.max(margin, window.innerHeight - ph - margin);
}
results.style.left = left + 'px';
results.style.top = top + 'px';
}
// Enter = semantic (meaning-based) search via the LLM-backed endpoint.
function macroSearchKey(e) {
if (e.key !== 'Enter') return;
e.preventDefault();
const q = e.target.value.trim();
if (q.length < 2) return;
runSemanticMacroSearch(q);
}
function _renderMacroResults(items, mode) {
const results = document.getElementById('macro-search-results');
if (!items.length) {
results.innerHTML = '<div style="padding:.4rem .5rem;color:#94a3b8">No matching macros</div>';
_positionMacroResults();
return;
}
const badge = mode === 'semantic'
? '<span style="background:#ede9fe;color:#6d28d9;padding:0 .3rem;border-radius:.25rem;font-size:.6rem;font-weight:600">AI</span>'
: '<span style="background:#f1f5f9;color:#64748b;padding:0 .3rem;border-radius:.25rem;font-size:.6rem;font-weight:600">keyword</span>';
const head = `<div style="padding:.25rem .6rem;border-bottom:1px solid #e2e8f0;color:#64748b;font-size:.62rem;position:sticky;top:0;background:#fff">${badge} best matches by meaning</div>`;
results.innerHTML = head + items.map(m => {
const meta = [m.verb, m.modality].filter(Boolean).join(' · ');
return `<div style="padding:.4rem .6rem;cursor:pointer;border-bottom:1px solid #f1f5f9"
onmousedown="addMacroFromSearch('${m.key}')"
onmouseenter="this.style.background='#eff6ff'"
onmouseleave="this.style.background=''">
<div style="display:flex;justify-content:space-between;align-items:center;gap:.4rem">
<div style="font-weight:500;color:#1e293b">${escHtml(m.description || m.key)}</div>
${_covBadge(m.key)}
</div>
<div style="font-size:.65rem;color:#94a3b8">${m.key}${meta ? ' — ' + escHtml(meta) : ''}</div>
</div>`;
}).join('');
_positionMacroResults();
}
function runSemanticMacroSearch(q) {
const results = document.getElementById('macro-search-results');
results.innerHTML = '<div style="padding:.5rem .6rem;color:#6d28d9">🔎 Searching macros by meaning…</div>';
_positionMacroResults();
fetch('/annotate/api/macro_search', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query: q, exclude: selectedMacros, limit: 8})
})
.then(r => r.json())
.then(d => {
// If the LLM (semantic) path is unavailable, the backend returns
// keyword results — tell the user so they know AI search is down, but
// still show the keyword matches (with a notice).
if (d.mode !== 'semantic' || d.error) {
const note = '<div style="padding:.35rem .6rem;color:#b45309;background:#fef3c7;font-size:.62rem;border-bottom:1px solid #fde68a">⚠ AI (semantic) search unavailable — showing keyword matches. If this persists, restart the annotation server (LLM creds not loaded).</div>';
_renderMacroResults(d.results || [], 'keyword');
results.insertAdjacentHTML('afterbegin', note);
return;
}
_renderMacroResults(d.results || [], 'semantic');
})
.catch(() => {
results.innerHTML = '<div style="padding:.5rem .6rem;color:#b91c1c">⚠ AI search request failed — falling back to keyword.</div>';
showMacroSearch(q);
});
}
// Close search results when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.closest('#macro-search') && !e.target.closest('#macro-search-results')) {
const r = document.getElementById('macro-search-results');
if (r) r.style.display = 'none';
}
});
// --- Generate instruction from macro subtasks ---
// Draft a full task (subtasks + instruction) from the sampled macros + real site
// data, so the annotator EDITS a concrete draft instead of writing from scratch.
function draftTask() {
if (!currentPrompt || !currentPrompt.sites || !currentPrompt.macros) return;
// Backend drafts by BASE macro name — send base names (+ op suffix per instance).
const enabledIds = getEnabledMacros();
const macros = enabledIds.map(m => macroOps[m] ? baseOf(m) + '.' + macroOps[m] : baseOf(m));
if (!macros.length) { alert('No macros to draft from.'); return; }
const status = document.getElementById('draft-status');
const btn = document.getElementById('draft-task-btn');
status.textContent = 'Drafting…'; btn.disabled = true;
fetch('/annotate/api/draft_task', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({sites: currentPrompt.sites.map(s => s.id || s), macros})
}).then(r => r.json()).then(d => {
btn.disabled = false;
if (d.error || (!d.instruction && !d.subtasks)) {
status.textContent = '⚠ ' + (d.error || 'draft unavailable (restart server if creds stale)');
return;
}
// prefill each macro's subtask — response keys are BASE names; map each to
// the first still-empty INSTANCE of that base so duplicates each get filled.
let filled = 0;
for (const [mac, text] of Object.entries(d.subtasks || {})) {
if (!text) continue;
const target = getEnabledMacros().find(id => baseOf(id) === baseOf(mac) && !(macroSubtasks[id] || '').trim());
if (target) { macroSubtasks[target] = text; filled++; }
}
if (d.instruction) document.getElementById('instruction').value = d.instruction;
renderMacroList(); // re-render nodes so the prefilled subtasks show
status.textContent = '✓ drafted (' + filled + ' steps) — edit it in your own words';
}).catch(() => { btn.disabled = false; status.textContent = '⚠ draft request failed'; });
}
// --- Reverse annotation: instruction -> macro steps ---
// The annotator writes the task in NL first; the LLM decomposes it into an
// ordered macro chain (node + reasoning op + subtask per step) that replaces
// the canvas — a starting point the annotator then corrects.
function inferMacros() {
const text = document.getElementById('instruction').value.trim();
if (text.length < 10) { alert('Write the task instruction first (top panel), then infer its macros.'); return; }
if (selectedMacros.length && !confirm('Replace the current macro graph with macros inferred from the instruction?')) return;
const status = document.getElementById('infer-status');
const btn = document.getElementById('infer-macros-btn');
status.textContent = 'Inferring…'; btn.disabled = true;
fetch('/annotate/api/infer_macros', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({instruction: text,
sites: (currentPrompt && currentPrompt.sites) ? currentPrompt.sites.map(s => s.id || s) : []})
}).then(r => r.json()).then(d => {
btn.disabled = false;
if (d.error || !(d.steps || []).length) {
status.textContent = '⚠ ' + (d.error || 'no macros inferred');
return;
}
selectedMacros = []; macroSubtasks = {}; macroOps = {}; macroEdges = [];
macroPositions = {}; macroSpans = {};
let prev = null;
for (const s of d.steps) {
const id = nextInstanceId(s.macro);
selectedMacros.push(id);
if (s.subtask) macroSubtasks[id] = s.subtask;
if (s.operation) macroOps[id] = s.operation;
if (prev) macroEdges.push({from: prev, to: id});
prev = id;
}
renderMacroList();
status.textContent = '✓ inferred ' + d.steps.length + ' step' + (d.steps.length === 1 ? '' : 's') +
' — check macros, ops and arrows' +
((d.unknown || []).length ? ' (no registry match: ' + d.unknown.join(', ') + ')' : '');
}).catch(() => { btn.disabled = false; status.textContent = '⚠ infer request failed'; });
}
function generateInstruction() {
const enabledMacros = getEnabledMacros();
const subtasks = [];
// Follow edge order if possible, otherwise use enabledMacros order
const ordered = _topologicalSort(enabledMacros, macroEdges);
for (const m of ordered) {
const text = (macroSubtasks[m] || '').trim();
const desc = (macroDescriptions[m]||{}).description || m;
if (text) subtasks.push(text);
else subtasks.push(desc);
}
if (!subtasks.length) { alert('Add subtask descriptions to the macro nodes first'); return; }
const sites = currentPrompt ? currentPrompt.sites.map(s => s.name || s.id).join(', ') : '';
const prompt = 'Combine these subtasks into one natural instruction, as if a person is asking their assistant:\n\n' +
subtasks.map((s, i) => (i+1) + '. ' + s).join('\n') +
(sites ? '\n\nSites: ' + sites : '') +
'\n\nOutput ONLY the combined instruction. Keep it conversational.';
document.getElementById('refine-status').textContent = 'Generating...';
fetch('/annotate/api/refine', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({instruction: prompt, sites: [], macros: []})
}).then(r => r.json()).then(d => {
if (d.refined) {
document.getElementById('refine-status').textContent = '';
// Shown as a read-only, non-copyable reference — the annotator
// must type the instruction in their own words (keeps phrasing
// diversity; no copy-paste monoculture).
document.getElementById('combined-suggestion-text').textContent = d.refined;
document.getElementById('combined-suggestion-box').style.display = 'block';
} else {
// LLM returned nothing (unavailable / not configured) — say so instead
// of silently doing nothing.
document.getElementById('refine-status').textContent =
'⚠ LLM unavailable (check judge/model creds; restart the server if stale)';
}
}).catch(() => { document.getElementById('refine-status').textContent =
'⚠ LLM request failed'; });
}
function _topologicalSort(macros, edges) {
// Simple topological sort for macro ordering
const inDeg = {};
const adj = {};
for (const m of macros) { inDeg[m] = 0; adj[m] = []; }
for (const e of edges) {
if (e.from in adj && e.to in inDeg) {
adj[e.from].push(e.to);
inDeg[e.to]++;
}
}
const queue = macros.filter(m => inDeg[m] === 0);
const result = [];
while (queue.length) {
const n = queue.shift();
result.push(n);
for (const next of (adj[n] || [])) {
inDeg[next]--;
if (inDeg[next] === 0) queue.push(next);
}
}
// Add any remaining (cycles)
for (const m of macros) { if (!result.includes(m)) result.push(m); }
return result;
}
// --- Track resizer (bottom panel) ---
(function() {
const resizer = document.getElementById('track-resizer');
const track = document.getElementById('bottom-track');
if (!resizer || !track) return;
let startY, startH;
resizer.addEventListener('mousedown', function(e) {
e.preventDefault();
startY = e.clientY;
startH = track.offsetHeight;
resizer.classList.add('active');
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = 'none');
function onMove(ev) {
const dy = startY - ev.clientY;
const newH = Math.max(100, Math.min(window.innerHeight * 0.6, startH + dy));
track.style.height = newH + 'px';
}
function onUp() {
resizer.classList.remove('active');
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = '');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
renderMacroList();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
})();
function toggleGraphExpand() {
const track = document.getElementById('bottom-track');
const btn = document.getElementById('graph-expand-btn');
track.classList.toggle('expanded');
const isExpanded = track.classList.contains('expanded');
btn.innerHTML = isExpanded ? '⛶ Collapse' : '⛶ Fullscreen';
// Sync instruction text between main and track textareas
const mainInst = document.getElementById('instruction');
const trackInst = document.getElementById('track-instruction');
if (isExpanded && mainInst && trackInst) {
trackInst.value = mainInst.value;
} else if (!isExpanded && mainInst && trackInst) {
mainInst.value = trackInst.value;
}
setTimeout(function() { _resizeGraph(); _drawArrows(); }, 50);
}
function toggleSkip(name) {
if (name in macrosSkipped) {
delete macrosSkipped[name];
} else {
macrosSkipped[name] = true;
delete macrosNotApplicable[name]; // can't be both
}
renderMacroList();
}
function toggleNA(name) {
if (name in macrosNotApplicable) {
delete macrosNotApplicable[name];
} else {
// Find which site this macro was sampled from
const sites = currentPrompt ? currentPrompt.sites.map(s => s.id) : [];
macrosNotApplicable[name] = sites.join(',');
delete macrosSkipped[name]; // can't be both
}
renderMacroList();
// If all macros are now NA, offer to report and move on
const enabled = getEnabledMacros();
if (enabled.length === 0 && selectedMacros.length > 0) {
_reportAllNA();
}
}
async function _reportAllNA() {
const annotator = getAnnotatorName() || 'anonymous';
const sites = currentPrompt ? currentPrompt.sites.map(s => s.id) : [];
try {
await fetch('/annotate/api/report', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
sites, macros: [], reason: 'all_na',
details: 'All macros marked N/A',
annotator,
macros_not_applicable: macrosNotApplicable,
})
});
} catch(e) {}
document.getElementById('status').textContent = 'All macros N/A — reported. Loading new prompt...';
setTimeout(() => reshufflePrompt(), 1000);
}
function toggleMacroCheck(name, checked) {
if (!checked) {
const item = document.querySelector(`.macro-item[data-macro="${name}"]`);
if (item) {
item.style.opacity = '0.4';
item.dataset.disabled = 'true';
}
} else {
const item = document.querySelector(`.macro-item[data-macro="${name}"]`);
if (item) {
item.style.opacity = '1';
item.dataset.disabled = '';
}
}
updateChainDisplay();
}
function getEnabledMacros() {
// Check both old (.macro-item) and new (.macro-node) classes
const items = document.querySelectorAll('.macro-node, .macro-item');
const enabled = [];
items.forEach(item => {
const macro = item.dataset.macro;
if (!macro) return;
// In the node graph, all visible nodes are enabled unless skipped/NA
if (macro in macrosNotApplicable || macro in macrosSkipped) return;
enabled.push(macro);
});
return enabled;
}
// updateChainDisplay defined above as alias to _drawArrows
// --- Reshuffle ---
function handleSiteCountChange() {
const count = document.getElementById('site-count-picker')?.value;
const picker = document.getElementById('site-picker');
if (picker) {
// choosing a specific site only makes sense for single-site tasks
picker.style.display = count === '1' ? '' : 'none';
if (count === '1') _populateSitePicker(); // refresh floor counts each time
}
reshufflePrompt();
}
async function _populateSitePicker() {
const picker = document.getElementById('site-picker');
if (!picker) return;
try {
const [sites, floors] = await Promise.all([
fetch('/annotate/api/sites').then(r => r.json()),
fetch('/annotate/api/site_floors').then(r => r.json()),
]);
const current = picker.value;
picker.innerHTML = '<option value="">🎲 Random site</option>';
sites.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
for (const s of sites) {
const f = floors[s.id] || {covered: 0, total: 0};
const opt = document.createElement('option');
opt.value = s.id;
const kind = s.type || s.category;
const kindStr = kind ? ` (${kind})` : '';
opt.textContent = `${s.name || s.id}${kindStr} — ${f.covered}/${f.total}`;
picker.appendChild(opt);
}
picker.value = current;
} catch (e) { /* picker stays with Random option */ }
}
function reshufflePrompt() {
if (rerecordTask) return; // re-record mode is pinned to the saved task
document.getElementById('status').textContent = 'Loading new prompt...';
const siteCount = document.getElementById('site-count-picker')?.value || '';
const chosenSite = siteCount === '1' ? (document.getElementById('site-picker')?.value || '') : '';
const params = chosenSite ? '?site=' + encodeURIComponent(chosenSite)
: siteCount === '1' ? '?single=1'
: siteCount ? '?n_sites=' + siteCount : '';
fetch('/annotate/api/prompt' + params)
.then(r => r.json())
.then(prompt => {
if (!prompt || !prompt.sites || !prompt.macros) {
document.getElementById('status').textContent = 'No prompt available';
return;
}
currentPrompt = prompt;
selectedMacros = _initInstances(prompt);
_spatialWarned = ''; // new prompt — re-show spatial design warnings if sampled
macrosNotApplicable = {};
macrosSkipped = {};
qaAnswers = {};
activeSiteIdx = 0;
// Clear stale data from previous task
trajectory = [];
actionCounter = 0;
macroSpans = {}; macroEdges = []; macroPositions = {}; macroSubtasks = {}; macroOps = {}; proposedMacros = {}; proposedMacroDefs = {};
cachedApiCalls = null;
cachedRequestLog = null;
agentResult = null;
agentRunning = false;
rewindPoint = null;
droppedChecks = {};
document.getElementById('agent-results').innerHTML = '';
const badge = document.getElementById('agent-status-badge');
if (badge) { badge.style.display = 'none'; badge.textContent = ''; }
// Clear server-side request log
fetch('/_admin/log/clear', {method: 'POST'}).catch(() => {});
renderSiteLabels();
renderMacroList();
updateChainDisplay();
renderTagPalette();
document.getElementById('instruction').value = '';
if (prompt.chosen_site && prompt.chain_remaining) {
const cr = prompt.chain_remaining;
const parts = [];
for (let L = 1; L <= 6; L++) {
if (cr[String(L)] > 0) parts.push(cr[String(L)] + '\u00d7 len-' + L);
}
const uncov = prompt.uncovered_macros || 0;
const total = prompt.total_macros || 0;
let status = '';
if (uncov > 0) status += uncov + '/' + total + ' macros uncovered. ';
else status += 'All macros covered. ';
if (parts.length > 0) status += 'Still need: ' + parts.join(', ');
else status += 'All chain targets met!';
document.getElementById('status').textContent = status;
} else {
document.getElementById('status').textContent = 'New prompt loaded';
}
// Set login state BEFORE loading iframe so it renders correctly
const toggle = document.getElementById('auto-login-toggle');
const sessionReady = (toggle && toggle.checked) ? autoLoginSites() : _logoutSites();
sessionReady.then(() => {
if (prompt.sites.length > 0) {
const siteIds = prompt.sites.map(s => s.id).join(',');
document.getElementById('site-iframe').src = '/?open=' + siteIds;
loadApiRef(prompt.sites[0].id);
}
});
})
.catch(e => {
document.getElementById('status').textContent = 'Error: ' + e.message;
});
}
// --- Skip modal ---
function openSkipModal() {
document.getElementById('skip-modal').style.display = 'flex';
}
function closeSkipModal() {
document.getElementById('skip-modal').style.display = 'none';
}
async function confirmSkip() {
const reason = document.getElementById('skip-reason').value;
const details = document.getElementById('skip-details').value.trim();
if (!details) { alert('Please provide details'); return; }
const annotator = getAnnotatorName() || 'anonymous';
const sites = currentPrompt ? currentPrompt.sites.map(s => s.id) : [];
const macros = getEnabledMacros();
try {
await fetch('/annotate/api/report', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
sites, macros, reason, details, annotator,
instruction: document.getElementById('instruction').value,
macros_not_applicable: macrosNotApplicable,
})
});
} catch(e) {}
closeSkipModal();
// Get a new prompt
reshufflePrompt();
}
// --- API Reference ---
let siteDataFiles = [];
function loadApiRef() {} // removed — no-op for compat
// --- Recording via postMessage from recorder.js ---
let actionCounter = 0;
// Action type display config
const ACTION_ICONS = {
click: '\u{1F5B1}', type: '\u2328', select: '\u25BC', check: '\u2611',
scroll: '\u2195', keypress: '\u23CE', navigate: '\u{1F310}',
drag: '\u2922', hover: '\u{1F441}',
tab_switch: '\u{1F4D1}', change: '\u{1F504}',
submit: '\u{1F4E8}',
};
// Poll /_admin/log to inject server-side requests into trajectory
var logPollTimer = null;
var lastLogCount = 0;
function pollServerLog() {
if (!recording) return;
fetch('/_admin/log').then(r => r.json()).then(d => {
const entries = d.entries || [];
if (entries.length <= lastLogCount) return;
// Only process new entries
const newEntries = entries.slice(lastLogCount);
lastLogCount = entries.length;
for (const e of newEntries) {
if (!e.path || !e.path.startsWith('/sites/')) continue;
// Skip static assets
if (/\.(css|js|png|jpg|ico|svg|woff|ttf)(\?|$)/.test(e.path)) continue;
// Check if this request is already in trajectory (from fetch interceptor)
const shortUrl = e.path.replace('/sites/', '');
const isDupe = trajectory.some(t =>
t.type === 'network' && t.url && t.url.includes(shortUrl) &&
Math.abs(new Date(t.timestamp) - new Date(e.timestamp)) < 2000
);
if (isDupe) continue;
trajectory.push({
type: 'network',
method: e.method || 'GET',
url: e.path + (e.query && Object.keys(e.query).length ? '?' + Object.entries(e.query).map(([k,v])=>k+'='+v).join('&') : ''),
status: e.status || 0,
requestBody: e.body || null,
responseBody: e.response || e.response_preview || null,
duration: e.duration_ms || 0,
timestamp: e.timestamp || new Date().toISOString(),
source: 'server', // distinguish from fetch interceptor
});
}
if (newEntries.length) renderTrajectory();
}).catch(() => {});
}
var startingUrl = '';
// --- Real screen capture (tab share, one prompt per sitting) ----------------
let captureStream = null;
let captureVideo = null;
let captureCanvas = null;
let captureDeclined = false;
function _setCaptureBadge(live) {
const b = document.getElementById('capture-badge');
const retry = document.getElementById('capture-retry');
if (!b) return;
b.textContent = live ? 'screen: live' : 'screen: derived';
b.style.color = live ? '#16a34a' : '#94a3b8';
b.style.borderColor = live ? '#86efac' : '#e2e8f0';
if (retry) retry.style.display = (!live && captureDeclined) ? '' : 'none';
}
function ensureCaptureStream() {
if (captureStream && captureStream.getVideoTracks()[0]?.readyState === 'live') {
_setCaptureBadge(true);
return;
}
captureStream = null;
if (captureDeclined || !navigator.mediaDevices?.getDisplayMedia) {
_setCaptureBadge(false);
return;
}
navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'browser' },
preferCurrentTab: true,
selfBrowserSurface: 'include',
surfaceSwitching: 'exclude',
audio: false,
}).then(stream => {
captureStream = stream;
if (!captureVideo) {
captureVideo = document.createElement('video');
captureVideo.muted = true;
captureVideo.playsInline = true;
captureVideo.autoplay = true;
// off-screen but NOT display:none — hidden video can stall frames
captureVideo.style.cssText = 'position:fixed;left:-9999px;top:0;width:320px';
document.body.appendChild(captureVideo);
}
captureVideo.srcObject = stream;
captureVideo.play().catch(() => {});
stream.getVideoTracks()[0].addEventListener('ended', () => {
captureStream = null;
_setCaptureBadge(false);
});
_setCaptureBadge(true);
}).catch(() => {
captureDeclined = true; // don't re-prompt this sitting
_setCaptureBadge(false);
});
}
function retryCapture() {
captureDeclined = false;
ensureCaptureStream();
}
function grabFrame() {
// one real frame, cropped to the site iframe, downscaled, as a JPEG
try {
const v = captureVideo;
if (!captureStream || !v || v.readyState < 2 || !v.videoWidth) return null;
const rect = document.getElementById('site-iframe').getBoundingClientRect();
// per-capture scale: self-corrects for zoom/DPR/resize
const scaleX = v.videoWidth / window.innerWidth;
const scaleY = v.videoHeight / window.innerHeight;
const sx = Math.max(0, rect.left * scaleX);
const sy = Math.max(0, rect.top * scaleY);
const sw = Math.min(rect.width * scaleX, v.videoWidth - sx);
const sh = Math.min(rect.height * scaleY, v.videoHeight - sy);
if (sw < 50 || sh < 50) return null;
const outW = Math.min(1440, Math.round(sw));
const outH = Math.round(sh * outW / sw);
if (!captureCanvas) captureCanvas = document.createElement('canvas');
captureCanvas.width = outW;
captureCanvas.height = outH;
captureCanvas.getContext('2d').drawImage(v, sx, sy, sw, sh, 0, 0, outW, outH);
return captureCanvas.toDataURL('image/jpeg', 0.85);
} catch (e) {
return null; // derivation covers this step
}
}
function startRecording() {
ensureCaptureStream(); // one prompt per sitting; never blocks recording
trajectory = [];
actionCounter = 0;
macroSpans = {};
// Keep macroEdges and macroPositions — graph was designed in step 1
selectedActionIdx = null;
cachedApiCalls = null;
cachedRequestLog = null;
rewindPoint = null;
lastLogCount = 0;
// Clear server-side logs
fetch('/_admin/log/clear', {method: 'POST'}).catch(() => {});
rangeAnchor = null;
cancelRangeSelect();
renderTimeline();
renderMacroSpanSummary();
recording = true;
lastUrl = '';
// Capture starting URL from the iframe — this is where the task begins
try {
const iframe = document.getElementById('site-iframe');
const innerFrame = iframe.contentWindow.document.querySelector('iframe');
if (innerFrame) {
startingUrl = innerFrame.contentWindow.location.href;
} else {
startingUrl = iframe.contentWindow.location.href;
}
} catch(e) {
startingUrl = '';
}
// Start polling server log
if (logPollTimer) clearInterval(logPollTimer);
logPollTimer = setInterval(pollServerLog, 1500);
// Show instruction banner
updateInstructionBanner();
goStep(2);
updateRecUI();
document.getElementById('action-sidebar').classList.add('visible');
}
function toggleRecording() {
recording = !recording;
updateRecUI();
}
function stopAndNext() {
// ask the recorder to flush buffered typing + the pending observation
// BEFORE we stop listening — otherwise a recording that ends right
// after typing loses its final type action and page state
const iframe = document.getElementById('site-iframe');
try { iframe.contentWindow.postMessage({mw: 'flush'}, '*'); } catch (e) {}
setTimeout(() => {
recording = false;
if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
// Final poll to catch any last requests
pollServerLog();
updateRecUI();
goStep(3);
}, 350);
}
function updateRecUI() {
const btn = document.getElementById('rec-btn');
const status = document.getElementById('rec-status');
if (recording) {
btn.className = 'btn btn-recording';
btn.textContent = 'Recording...';
status.style.color = '#ef4444';
status.textContent = 'REC';
} else {
btn.className = 'btn btn-secondary';
btn.textContent = 'Resume';
status.style.color = '#94a3b8';
status.textContent = 'Paused';
}
}
// Listen for postMessage from recorder.js injected in site pages
window.addEventListener('message', function(e) {
if (!recording) return;
var d = e.data;
if (!d || !d.mw) return;
if (d.mw === 'action') {
actionCounter++;
trajectory.push({
type: 'action',
action: d.action,
target: d.target || '',
selector: d.selector || '',
timestamp: d.timestamp || new Date().toISOString(),
x: d.x, y: d.y, button: d.button,
text: d.text,
value: d.value, option_text: d.option_text,
checked: d.checked,
key: d.key,
dx: d.dx, dy: d.dy, scroll_top: d.scroll_top,
from_x: d.from_x, from_y: d.from_y, to_x: d.to_x, to_y: d.to_y,
url: d.url, from_url: d.from_url,
method: d.method, formData: d.formData,
});
renderTrajectory();
} else if (d.mw === 'network') {
// Attach network request to the most recent action
trajectory.push({
type: 'network',
method: d.method || 'GET',
url: d.url || '',
status: d.status || 0,
requestBody: d.requestBody || null,
responseBody: d.responseBody || null,
responseSize: d.responseSize || 0,
duration: d.duration || 0,
error: d.error || '',
timestamp: d.timestamp || new Date().toISOString(),
});
renderTrajectory();
} else if (d.mw === 'observation') {
const entry = {
type: 'observation',
url: d.url || '',
title: d.title || '',
timestamp: d.timestamp || new Date().toISOString(),
// recorder.js sends these with every observation; the verify tab
// and rewind/playback read them ("No snapshot for this step" bug)
snapshot: d.snapshot || '',
// YAML axtree + screenshot are derived server-side at save time;
// axtree_json is the legacy in-page walker (kept for compat)
axtree: d.axtree || '',
axtree_json: d.axtree_json || '',
scroll_top: d.scroll_top || 0,
viewport: d.viewport || null,
};
// real screen capture: one frame per observation, if the tab-share
// stream is live and this observation belongs to an action
if (actionCounter > 0) {
const frame = grabFrame();
if (frame) {
entry.screenshot_data = frame;
entry.screenshot_captured = true;
}
}
trajectory.push(entry);
renderTrajectory();
}
});
// ── Macro tag colors ──
const TAG_COLORS = [
{bg:'#dbeafe',fg:'#1e40af',border:'#93c5fd'}, {bg:'#fce7f3',fg:'#9d174d',border:'#f9a8d4'},
{bg:'#d1fae5',fg:'#065f46',border:'#6ee7b7'}, {bg:'#fef3c7',fg:'#92400e',border:'#fcd34d'},
{bg:'#e0e7ff',fg:'#3730a3',border:'#a5b4fc'}, {bg:'#fecdd3',fg:'#9f1239',border:'#fda4af'},
{bg:'#ccfbf1',fg:'#134e4a',border:'#5eead4'}, {bg:'#fde68a',fg:'#78350f',border:'#fbbf24'},
];
function getMacroColor(m) {
const macros = getEnabledMacros();
const idx = macros.indexOf(m);
return TAG_COLORS[(idx >= 0 ? idx : m.length) % TAG_COLORS.length];
}
function getActionMacros(actIdx) {
const tags = [];
for (const [macro, range] of Object.entries(macroSpans)) {
if (actIdx >= range[0] && actIdx <= range[1]) tags.push(macro);
}
return tags;
}
// ── Range selection for macro tagging (shift-click) ──
// Plain click selects an action (the anchor). Shift-click another action
// selects the whole anchor→action range and shows the macro chips.
let rangeMode = false; // a range is currently selected
let rangeStart = null;
let rangeEnd = null;
let rangeAnchor = null; // last plain-clicked action; shift-click extends from here
function cancelRangeSelect() {
rangeMode = false;
rangeStart = null;
rangeEnd = null;
document.getElementById('range-controls').style.display = 'none';
document.getElementById('range-tip').style.display = 'block';
renderTimeline();
}
function handleTimelineClick(ev, actIdx) {
if (ev && ev.shiftKey) {
// Shift-click — select the range from the anchor to this action
const anchor = rangeAnchor !== null ? rangeAnchor : actIdx;
rangeStart = Math.min(anchor, actIdx);
rangeEnd = Math.max(anchor, actIdx);
rangeMode = true;
selectedActionIdx = null;
document.getElementById('range-controls').style.display = 'block';
document.getElementById('range-tip').style.display = 'none';
document.getElementById('range-hint').textContent =
'Actions ' + rangeStart + '-' + rangeEnd + ' selected. Pick a macro below (shift-click extends, click cancels):';
renderTimeline();
renderRangeMacroChips();
return;
}
// Plain click — cancel any pending range, toggle selection, set anchor
if (rangeMode) cancelRangeSelect();
selectedActionIdx = (selectedActionIdx === actIdx) ? null : actIdx;
rangeAnchor = selectedActionIdx;
renderTimeline();
}
function assignRangeToMacro(macroName) {
if (rangeStart === null || rangeEnd === null) return;
macroSpans[macroName] = [rangeStart, rangeEnd];
cancelRangeSelect();
renderTimeline();
renderMacroSpanSummary();
}
function removeSpan(macroName) {
delete macroSpans[macroName];
renderTimeline();
renderMacroSpanSummary();
}
function clearAllTags() {
macroSpans = {}; macroEdges = []; macroPositions = {}; macroSubtasks = {}; macroOps = {}; proposedMacros = {}; proposedMacroDefs = {};
renderTimeline();
renderMacroSpanSummary();
}
function suggestTagsAI() {
var actions = trajectory.filter(function(e) { return e.type === 'action'; });
var macros = getEnabledMacros();
if (!actions.length || !macros.length) { alert('Record actions and enable macros first'); return; }
var btn = document.getElementById('suggest-tags-btn');
var status = document.getElementById('suggest-tags-status');
btn.disabled = true;
btn.textContent = 'Analyzing...';
status.textContent = '';
// Build compact action list
// Actions are 1-indexed in the UI
var actionList = actions.map(function(a, i) {
var verb = a.action || '';
var target = (a.target || '').slice(0, 50);
var val = a.text || a.value || '';
return (i + 1) + '. ' + verb + ' ' + target + (val ? ' "' + String(val).slice(0, 25) + '"' : '');
});
var instruction = document.getElementById('instruction').value || '';
fetch('/annotate/api/suggest_tags', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
task_id: '_live',
annotator: getAnnotatorName() || 'anonymous',
_inline: true,
instruction: instruction,
macros: macros,
actions: actionList,
})
}).then(function(r) { return r.json(); }).then(function(d) {
btn.disabled = false;
btn.textContent = 'Suggest Tags';
if (d.suggested_spans) {
// Apply suggestions (don't overwrite existing tags)
var applied = 0;
for (var macro in d.suggested_spans) {
if (!macroSpans[macro]) {
macroSpans[macro] = d.suggested_spans[macro];
applied++;
}
}
renderTimeline();
renderMacroSpanSummary();
status.textContent = applied + ' tags suggested' + (applied > 0 ? ' (applied to untagged macros)' : '');
setTimeout(function() { status.textContent = ''; }, 3000);
} else {
status.textContent = d.error || 'Failed';
}
}).catch(function(e) {
btn.disabled = false;
btn.textContent = 'Suggest Tags';
status.textContent = 'Error';
});
}
function renderRangeMacroChips() {
const container = document.getElementById('range-macro-chips');
if (!container) return;
const macros = getEnabledMacros();
const hasRange = rangeStart !== null && rangeEnd !== null;
container.innerHTML = macros.map(m => {
const c = getMacroColor(m);
const alreadyUsed = m in macroSpans;
const chipLabel = m; // show the macro tag, not the NL description
return `<span class="macro-chip" style="background:${c.bg};color:${c.fg};opacity:${hasRange ? 1 : 0.4};${alreadyUsed ? 'text-decoration:line-through;' : ''}"
onclick="${hasRange ? "assignRangeToMacro('" + m + "')" : ''}"
title="${alreadyUsed ? 'Will replace existing span — ' + m : (macroDescriptions[m]||{}).description || m}">${chipLabel}</span>`;
}).join('');
}
// ── Action timeline (sidebar) ──
function actionDescription(t) {
// Returns {verb, target, detail} for human-readable display
const verb = t.action || 'action';
let target = t.target || '';
let detail = '';
if (t.action === 'click') { /* target is enough */ }
else if (t.action === 'type' && t.text) { detail = '"' + (t.text.length > 25 ? t.text.slice(0,22)+'...' : t.text) + '"'; }
else if (t.action === 'select' && t.option_text) { detail = '"' + t.option_text + '"'; }
else if (t.action === 'check') { detail = t.checked ? 'checked' : 'unchecked'; }
else if (t.action === 'keypress' && t.key) { detail = t.key; target = ''; }
else if (t.action === 'navigate' && t.url) { target = t.url; }
else if (t.action === 'scroll') { detail = 'scrollTop ' + (t.scroll_top || 0); target = ''; }
else if (t.action === 'drag') { detail = Math.round(t.from_x)+','+Math.round(t.from_y)+' \u2192 '+Math.round(t.to_x)+','+Math.round(t.to_y); }
else if (t.action === 'tab_switch') { target = t.to_tab || ''; detail = t.from_tab ? 'from ' + t.from_tab : ''; }
else if (t.action === 'focus') { detail = t.value ? 'value: "' + t.value.slice(0,20) + '"' : ''; }
else if (t.action === 'change') { detail = t.value || ''; }
else if (t.action === 'submit') { detail = (t.method || 'POST') + ' ' + (t.url || '').replace(/^.*\/sites\//, '').slice(0,30); }
// Shorten target
if (target.length > 35) target = target.slice(0, 32) + '...';
return {verb, target, detail};
}
function renderTimeline() {
const timeline = document.getElementById('action-timeline');
if (!timeline) return;
const prevScroll = timeline.scrollTop;
let html = '';
let actNum = 0;
let netCount = 0;
for (let i = 0; i < trajectory.length; i++) {
const t = trajectory[i];
if (t.type === 'network') {
netCount++;
// Skip static assets and recorder noise
if (t.url && /\.(css|js|png|jpg|ico|svg|woff|ttf)(\?|$)/.test(t.url)) continue;
if (t.url && (t.url.includes('/_admin') || t.url.includes('/annotate'))) continue;
const methodCls = (t.method || 'get').toLowerCase();
const statusCls = t.status >= 200 && t.status < 400 ? 'ok' : 'err';
const shortUrl = (t.url || '').replace(/^https?:\/\/[^/]+/, '').replace('/sites/', '');
const bodyPreview = t.responseBody
? (typeof t.responseBody === 'object' ? JSON.stringify(t.responseBody).slice(0, 80) : String(t.responseBody).slice(0, 80))
: '';
// Extract error message from response
const isErr = t.status >= 400 || t.status === 0;
let errorMsg = '';
if (isErr && t.responseBody) {
const rb = t.responseBody;
if (typeof rb === 'object') {
errorMsg = rb.error || rb.message || rb.detail || rb.reason || '';
} else if (typeof rb === 'string') {
// Try to find error text in HTML responses
const m = rb.match(/(?:error|alert|warning|denied|unauthorized|forbidden)[^<]*?[>:]([^<]{5,80})/i);
if (m) errorMsg = m[1].trim();
else if (rb.length < 100) errorMsg = rb;
}
}
// Also check for redirect to login (302 to /login)
if (t.status === 302 && t.responseBody && typeof t.responseBody === 'string' && /login/i.test(t.responseBody)) {
errorMsg = errorMsg || 'Redirected to login — not authenticated';
}
html += `<div class="net-entry${isErr ? ' net-err' : ''}">
<span class="net-method ${methodCls}">${t.method || 'GET'}</span>
<span>${escHtml(shortUrl.slice(0, 50))}</span>
<span class="net-status ${statusCls}">${t.status || '?'}</span>
<span class="net-dur">${t.duration || 0}ms</span>
${errorMsg ? '<span class="net-error-msg">\u26A0 ' + escHtml(errorMsg) + '</span>' : ''}
${bodyPreview && !errorMsg ? '<div class="net-body" onclick="this.classList.toggle(\'expanded\')">' + escHtml(bodyPreview) + '</div>' : ''}
</div>`;
continue;
}
if (t.type === 'observation') continue;
if (t.type !== 'action') continue;
actNum++;
const actIdx = actNum;
const icon = ACTION_ICONS[t.action] || '\u25CB';
const {verb, target, detail} = actionDescription(t);
const tags = getActionMacros(actIdx);
const isSelected = selectedActionIdx === actIdx;
const inRange = rangeMode && rangeStart !== null && (
(rangeEnd !== null && actIdx >= rangeStart && actIdx <= rangeEnd) ||
(rangeEnd === null && actIdx === rangeStart)
);
const isRewound = rewindPoint !== null && actIdx > rewindPoint;
let tagHtml = '';
if (tags.length) {
tagHtml = '<div class="act-tags">' + tags.map(m => {
const c = getMacroColor(m);
const tagLabel = (macroDescriptions[m]||{}).description || m;
return `<span class="macro-tag" style="background:${c.bg};color:${c.fg};border:1px solid ${c.border}" title="${m}">${tagLabel}</span>`;
}).join('') + '</div>';
}
html += `<div class="act-entry${isSelected ? ' selected' : ''}${inRange ? ' in-range' : ''}${isRewound ? ' rewound' : ''}" onclick="handleTimelineClick(event, ${actIdx})" onmousedown="if(event.shiftKey)event.preventDefault()">
<span class="act-num">${actIdx}</span>
<span class="act-icon">${icon}</span>
<span class="act-verb">${verb}</span>
<span class="act-target">${escHtml(target)}</span>
${detail ? '<div class="act-detail">' + escHtml(detail) + '</div>' : ''}
${tagHtml}
<button class="rewind-btn" onclick="event.stopPropagation(); rewindTo(${actIdx})" title="Rewind to this step">⏪ rewind here</button>
</div>`;
}
if (rewindPoint !== null) {
html += `<div style="text-align:center;padding:.4rem;font-size:.7rem;color:#2563eb;background:#eff6ff;border-radius:.3rem;margin:.3rem 0">
Rewound to step ${rewindPoint} — recording new actions from here
<button onclick="cancelRewind()" style="margin-left:.5rem;font-size:.65rem;padding:.1rem .3rem;border:1px solid #93c5fd;border-radius:.2rem;background:#fff;cursor:pointer">Cancel rewind</button>
</div>`;
}
timeline.innerHTML = html;
document.getElementById('action-count').textContent = actNum + (netCount ? ' + ' + netCount + ' requests' : '');
const countEl = document.getElementById('rec-count');
if (countEl) countEl.textContent = actNum + ' actions, ' + netCount + ' requests';
// Follow the tail only while actively recording (new actions stream in);
// during tagging/selection re-renders, keep the annotator's scroll position.
if (recording && rewindPoint === null) timeline.scrollTop = timeline.scrollHeight;
else timeline.scrollTop = prevScroll;
}
function rewindTo(actIdx) {
// Find the observation (HTML snapshot) right before this action
let snapshot = null;
let targetUrl = '';
let actCount = 0;
for (let i = 0; i < trajectory.length; i++) {
const t = trajectory[i];
if (t.type === 'observation') {
if (t.snapshot) snapshot = t.snapshot;
if (t.url) targetUrl = t.url;
}
if (t.type === 'action') {
actCount++;
if (actCount === actIdx) break;
}
}
// Truncate trajectory: keep everything up to and including this action
let keepCount = 0;
let cutIdx = trajectory.length;
for (let i = 0; i < trajectory.length; i++) {
if (trajectory[i].type === 'action') {
keepCount++;
if (keepCount > actIdx) {
cutIdx = i;
break;
}
}
}
while (cutIdx < trajectory.length && trajectory[cutIdx].type === 'observation') cutIdx++;
trajectory.splice(cutIdx);
rewindPoint = actIdx;
// Restore the page state
const iframe = document.getElementById('site-iframe');
if (iframe) {
if (snapshot) {
// Restore HTML snapshot into the portal's inner content frame
try {
const innerFrame = iframe.contentDocument.getElementById('site-content');
if (innerFrame && innerFrame.contentDocument) {
innerFrame.contentDocument.open();
innerFrame.contentDocument.write(snapshot);
innerFrame.contentDocument.close();
}
} catch(e) {
// Cross-origin fallback: navigate to the URL instead
if (targetUrl) {
iframe.contentWindow.postMessage({type: 'navigate', url: targetUrl}, '*');
}
}
} else if (targetUrl) {
iframe.contentWindow.postMessage({type: 'navigate', url: targetUrl}, '*');
}
}
// Start recording automatically so new actions append
if (!recording) {
toggleRecording();
}
renderTimeline();
renderRangeMacroChips();
}
function cancelRewind() {
rewindPoint = null;
renderTimeline();
}
function renderMacroSpanSummary() {
const el = document.getElementById('macro-span-summary');
if (!el) return;
const macros = getEnabledMacros();
if (Object.keys(macroSpans).length === 0) {
el.innerHTML = '<div style="font-size:.75rem;color:#94a3b8;margin-bottom:.3rem">No macros tagged yet. Use "Tag Range" in the action sidebar.</div>';
return;
}
let html = '<label style="margin-top:0">Macro Spans</label>';
for (const m of macros) {
if (!(m in macroSpans)) continue;
const c = getMacroColor(m);
const [s, e] = macroSpans[m];
html += `<div style="display:flex;align-items:center;gap:.3rem;margin-bottom:.2rem;padding:.2rem .4rem;background:${c.bg};border:1px solid ${c.border};border-radius:.3rem;font-size:.78rem;">
<span style="color:${c.fg};font-weight:600;flex:1">${m}</span>
<span style="color:${c.fg};font-size:.72rem">actions ${s}\u2013${e}</span>
<button onclick="removeSpan('${m}')" style="background:none;border:none;color:${c.fg};cursor:pointer;font-size:.85rem;opacity:.6" title="Remove">\u00d7</button>
</div>`;
}
// Show untagged macros
const untagged = macros.filter(m => !(m in macroSpans));
if (untagged.length) {
html += `<div style="font-size:.7rem;color:#94a3b8;margin-top:.3rem">Untagged: ${untagged.join(', ')}</div>`;
}
el.innerHTML = html;
}
// Legacy compatibility
function renderTrajectory() { renderTimeline(); renderMacroSpanSummary(); }
function renderTagPalette() { renderRangeMacroChips(); }
function showObsTab(obsId, tab) {
['ax','html'].forEach(t => {
const el = document.getElementById(obsId + '-' + t);
if (el) el.style.display = (t === tab) ? 'block' : 'none';
});
}
function escHtml(s) {
if (s === null || s === undefined) return '';
if (typeof s !== 'string') s = String(s);
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
// --- Verifiers (human-assembled) ---
let verifierConfigs = [];
let siteCollections = {}; // cache: {siteId: [collection_names]}
// ── Check primitives catalog (used by verifier view, kept for future use) ──
const CHECK_CATALOG = [
{
id: 'answer_match', icon: '\u2705', name: 'Answer matches',
hint: 'The agent gave the correct answer',
fields: [
{key: 'expected', label: 'Answer', type: 'answer'},
{key: 'match_mode', label: 'Mode', type: 'select', options: ['exact','fuzzy','includes']},
{key: 'match_type', label: 'Type', type: 'select', options: ['string','number','boolean','date']},
],
},
{
id: 'page_visited', icon: '\u{1F310}', name: 'Page was visited',
hint: 'The agent navigated to a specific page',
fields: [
{key: 'url', label: 'Page', type: 'trajectory_urls'},
],
},
{
id: 'action_performed', icon: '\u{1F5B1}', name: 'Request was made',
hint: 'The server received a specific HTTP request (works with any agent)',
fields: [
{key: 'request', label: 'Request', type: 'request_log'},
],
},
{
id: 'api_succeeded', icon: '\u{1F4E6}', name: 'API call succeeded',
hint: 'A specific API call was made and returned success',
fields: [
{key: 'api_call', label: 'Which call', type: 'trajectory_api_calls'},
],
},
{
id: 'response_contains', icon: '\u{1F50D}', name: 'Response contains',
hint: 'An API response contains specific text',
fields: [
{key: 'api_call', label: 'Which call', type: 'trajectory_api_calls'},
{key: 'text', label: 'Contains', type: 'trajectory_texts'},
],
},
{
id: 'count_equals', icon: '\u{1F522}', name: 'Result count equals',
hint: 'Number of results equals N',
fields: [
{key: 'api_call', label: 'Which call', type: 'trajectory_api_calls'},
{key: 'count', label: 'Count', type: 'number', placeholder: '5'},
],
},
{
id: 'record_exists', icon: '\u2714\uFE0F', name: 'Record exists',
hint: 'A record with matching field value exists in a collection',
fields: [
{key: 'collection', label: 'Collection', type: 'schema_collections'},
{key: 'field', label: 'Field', type: 'schema_fields'},
{key: 'expected', label: 'Value', type: 'text', placeholder: 'expected value'},
],
},
{
id: 'record_absent', icon: '\u274C', name: 'Record absent',
hint: 'No record with matching field value exists',
fields: [
{key: 'collection', label: 'Collection', type: 'schema_collections'},
{key: 'field', label: 'Field', type: 'schema_fields'},
{key: 'expected', label: 'Value', type: 'text', placeholder: 'value that should NOT exist'},
],
},
{
id: 'field_equals', icon: '\u{1F3AF}', name: 'Field equals value',
hint: 'A specific field on a record equals an expected value',
fields: [
{key: 'collection', label: 'Collection', type: 'schema_collections'},
{key: 'record_id', label: 'Record ID', type: 'text', placeholder: '1'},
{key: 'field', label: 'Field', type: 'schema_fields'},
{key: 'expected', label: 'Expected', type: 'text', placeholder: 'expected value'},
],
},
];
// Extract context from trajectory for dropdowns
function getTrajectoryUrls() {
const urls = new Set();
for (const e of trajectory) {
if (e.type === 'action' && e.url && e.url.startsWith('/sites/'))
urls.add(e.url.split('?')[0]);
if (e.type === 'observation' && e.url && e.url.startsWith('/sites/'))
urls.add(e.url.split('?')[0]);
}
return [...urls];
}
function getTrajectoryActions() {
const actions = [];
for (const e of trajectory) {
if (e.type !== 'action') continue;
let label = e.action || '';
// value format: action|||target|||value (value = typed text or selected option)
const actionVal = e.option_text || e.text || e.value || '';
let val = e.action + '|||' + (e.target || '').slice(0, 50) + '|||' + (actionVal || '').slice(0, 80);
if (e.action === 'type' && e.text) label = 'type "' + e.text.slice(0, 25) + '"';
else if (e.action === 'select' && e.option_text) label = 'select "' + e.option_text + '"';
else if (e.action === 'tab_switch') label = 'switch to ' + (e.to_tab || '');
else if (e.target) label += ' ' + e.target.slice(0, 30);
const icon = ACTION_ICONS[e.action] || '';
actions.push({value: val, label: icon + ' ' + label});
}
return actions;
}
let cachedApiCalls = null;
let cachedRequestLog = null;
function getRequestLogEntries() {
if (cachedRequestLog) return cachedRequestLog;
try {
const xhr = new XMLHttpRequest();
xhr.open('GET', '/_admin/log', false);
xhr.send();
if (xhr.status === 200) {
const d = JSON.parse(xhr.responseText);
const entries = d.entries || [];
const reqs = [];
const seen = new Set();
for (const e of entries) {
if (!e.path || !e.path.startsWith('/sites/')) continue;
// Skip static assets
if (e.path.match(/\.(css|js|png|jpg|ico|svg|woff)$/)) continue;
const shortPath = e.path.replace('/sites/', '');
const paramStr = Object.entries(e.query || {}).map(([k,v]) => k+'='+v).join('&');
const bodyStr = e.body ? JSON.stringify(e.body).slice(0, 40) : '';
const key = e.method + ' ' + e.path + (paramStr ? '?' + paramStr : '');
if (seen.has(key)) continue;
seen.add(key);
// Build human-readable label
let label = e.method + ' ' + shortPath;
if (paramStr) label += '?' + paramStr.slice(0, 40);
label += ' → ' + e.status;
if (bodyStr) label += ' body:' + bodyStr.slice(0, 25);
// value format: method|||path|||param_key|||param_value
let mainParam = '';
let mainVal = '';
for (const [k, v] of Object.entries({...(e.query||{}), ...(e.body||{})})) {
if (k !== '_' && v && String(v).length > 0) {
mainParam = k;
mainVal = String(v);
break;
}
}
reqs.push({
value: e.method + '|||' + e.path + '|||' + mainParam + '|||' + mainVal,
label: label,
});
}
cachedRequestLog = reqs;
}
} catch(e) {}
return cachedRequestLog || [];
}
let cachedSchemas = {}; // {siteId: {collection: [{name, type, samples}]}}
async function loadSiteSchemas() {
if (!currentPrompt || !currentPrompt.sites) return;
for (const site of currentPrompt.sites) {
const siteId = typeof site === 'string' ? site : site.id;
if (cachedSchemas[siteId]) continue;
try {
const r = await fetch(`/annotate/api/schema/${siteId}`);
cachedSchemas[siteId] = await r.json();
} catch(e) { cachedSchemas[siteId] = {}; }
}
}
function getSchemaCollections() {
const colls = [];
for (const [siteId, schema] of Object.entries(cachedSchemas)) {
for (const coll of Object.keys(schema)) {
colls.push({value: siteId + '|||' + coll, label: siteId + ' / ' + coll});
}
}
return colls;
}
function getSchemaFields(collKey) {
if (!collKey) return [];
const [siteId, coll] = collKey.split('|||');
const fields = (cachedSchemas[siteId] || {})[coll] || [];
return fields.map(f => ({
value: f.name,
label: f.name + ' (' + f.type + ')' + (f.samples && f.samples.length ? ' e.g. ' + f.samples[0].slice(0, 25) : ''),
}));
}
// Load schemas on page load
loadSiteSchemas();
function _buildApiCallsList(entries) {
const calls = [];
const seenPaths = new Set();
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
if (!e.path || !e.path.startsWith('/sites/')) continue;
const shortPath = e.path.replace('/sites/', '');
const bodySnippet = e.body ? JSON.stringify(e.body).slice(0, 60) : '';
let respSnippet = '';
if (e.response) {
if (Array.isArray(e.response)) respSnippet = `[${e.response.length} items]`;
else if (typeof e.response === 'object') respSnippet = JSON.stringify(e.response).slice(0, 60);
} else if (e.response_preview) {
respSnippet = e.response_preview.slice(0, 50);
}
const label = e.method + ' ' + shortPath + ' \u2192 ' + e.status +
(bodySnippet ? ' sent: ' + bodySnippet.slice(0, 35) : '') +
(respSnippet ? ' got: ' + respSnippet.slice(0, 35) : '');
calls.push({
value: e.method + '|||' + e.path,
label: label,
method: e.method,
path: e.path,
status: e.status,
});
// Also generate admin API equivalents for /api/ paths
if (e.path.includes('/api/')) {
const apiParts = e.path.split('/api/');
const site = apiParts[0].replace('/sites/', '');
const coll = apiParts[1].split('/')[0].split('?')[0];
const adminPath = `/_admin/data/${site}/${coll}`;
if (!seenPaths.has(adminPath)) {
seenPaths.add(adminPath);
calls.push({
value: 'GET|||' + adminPath,
label: '\u{1F50D} admin: ' + site + '/' + coll,
method: 'GET',
path: adminPath,
status: 200,
});
}
}
}
return calls;
}
async function fetchApiCalls() {
if (cachedApiCalls) return cachedApiCalls;
try {
const r = await fetch('/_admin/log');
const d = await r.json();
cachedApiCalls = _buildApiCallsList(d.entries || []);
} catch(err) { cachedApiCalls = []; }
return cachedApiCalls;
}
function getTrajectoryApiCalls() {
if (cachedApiCalls && cachedApiCalls.length) return cachedApiCalls;
try {
const xhr = new XMLHttpRequest();
xhr.open('GET', '/_admin/log', false);
xhr.send();
if (xhr.status === 200) {
const d = JSON.parse(xhr.responseText);
cachedApiCalls = _buildApiCallsList(d.entries || []);
}
} catch(e) {}
return cachedApiCalls || [];
}
function getTrajectoryTexts() {
// Collect all text values the annotator typed, selected, or submitted
const texts = new Set();
for (const e of trajectory) {
if (e.type !== 'action') continue;
if (e.text) texts.add(e.text);
if (e.option_text) texts.add(e.option_text);
if (e.value) texts.add(String(e.value));
}
// Add the final answer
const ans = document.getElementById('final-answer');
if (ans && ans.value.trim()) texts.add(ans.value.trim());
// Add QA answers
for (const v of Object.values(qaAnswers)) { if (v) texts.add(v); }
return [...texts].filter(t => t.length > 0 && t.length < 200);
}
let droppedChecks = {}; // {macro: [{checkId, values, resultEl}, ...]}
let dragCheckId = null;
let collOptionsHtmlCache = '';
function renderPalette() {
const el = document.getElementById('palette-checks');
if (!el) return;
el.innerHTML = CHECK_CATALOG.map(c =>
`<div class="pal-check" draggable="true"
ondragstart="dragCheckId='${c.id}'"
title="${c.hint}\nExample: ${c.example}">
<span class="pal-icon">${c.icon}</span>
<span class="pal-name">${c.name}</span>
<span class="pal-hint">${c.hint}</span>
</div>`
).join('');
}
async function populateVerifierBuilder() {
const container = document.getElementById('verifier-builder');
if (!container) return;
// Fetch API call log
await fetchApiCalls();
// Fetch collections for each site
const sites = currentPrompt ? currentPrompt.sites : [];
for (const s of sites) {
const sid = s.id || s;
if (!siteCollections[sid]) {
try {
const r = await fetch(`/_admin/files/${sid}`);
siteCollections[sid] = await r.json();
} catch(e) { siteCollections[sid] = []; }
}
}
// Cache collection options HTML
const collOpts = [];
for (const s of sites) {
const sid = s.id || s;
for (const coll of (siteCollections[sid] || [])) {
collOpts.push({value: `${sid}/${coll}`, label: `${sid} / ${coll}`});
}
}
collOptionsHtmlCache = '<option value="">pick collection...</option>' +
collOpts.map(o => `<option value="${o.value}">${o.label}</option>`).join('');
// Render palette
renderPalette();
// Build drop zones per macro
const macros = getEnabledMacros();
let html = '';
for (const macro of macros) {
const span = macroSpans[macro];
if (!span || span.length < 2) continue;
const c = getMacroColor(macro);
const macroId = macro.replace(/[^a-zA-Z0-9]/g, '_');
if (!droppedChecks[macro]) droppedChecks[macro] = [];
html += `<div class="vb-card">
<div class="vb-card-header" style="background:${c.bg}">
<span style="font-weight:700;color:${c.fg};font-size:.82rem">${macro}</span>
<span style="font-size:.68rem;color:${c.fg};opacity:.7;margin-left:auto">actions ${span[0]}\u2013${span[1]}</span>
</div>
<div class="vb-card-body${droppedChecks[macro].length ? '' : ' empty-hint'}" id="drop-${macroId}"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="event.preventDefault();this.classList.remove('drag-over');dropCheck('${macro}')">
${renderDroppedChecks(macro)}
</div>
</div>`;
}
// QA macros without spans — auto-add answer check
for (const macro of macros) {
if (!macroSpans[macro] && isQAMacro(macro) && qaAnswers[macro]) {
if (!droppedChecks[macro]) droppedChecks[macro] = [];
if (!droppedChecks[macro].some(c => c.checkId === 'answer_match')) {
droppedChecks[macro].push({checkId: 'answer_match', values: {expected: qaAnswers[macro], match_type: 'string'}});
}
}
}
if (!html) {
html = '<div style="color:#94a3b8;font-size:.82rem;padding:.5rem">No macro spans tagged. Tag actions in Step 2, or QA macros just need an answer.</div>';
}
container.innerHTML = html;
}
function dropCheck(macro) {
if (!dragCheckId) return;
if (!droppedChecks[macro]) droppedChecks[macro] = [];
droppedChecks[macro].push({checkId: dragCheckId, values: {}, result: null});
dragCheckId = null;
refreshDropZone(macro);
}
function removeDroppedCheck(macro, idx) {
droppedChecks[macro].splice(idx, 1);
refreshDropZone(macro);
}
function refreshDropZone(macro) {
const macroId = macro.replace(/[^a-zA-Z0-9]/g, '_');
const el = document.getElementById('drop-' + macroId);
if (!el) return;
el.innerHTML = renderDroppedChecks(macro);
el.classList.toggle('empty-hint', !droppedChecks[macro] || !droppedChecks[macro].length);
}
function refreshDroppedCheck(macro, idx) {
// Re-render just this macro's drop zone (so dependent fields like schema_fields update)
refreshDropZone(macro);
}
function renderDroppedChecks(macro) {
const checks = droppedChecks[macro] || [];
if (!checks.length) return '';
return checks.map((dc, idx) => {
const cat = CHECK_CATALOG.find(c => c.id === dc.checkId);
if (!cat) return '';
const resultHtml = dc.result != null
? `<div class="dc-result ${dc.result ? 'pass' : 'fail'}">${dc.result ? '\u2713 Pass' : '\u2717 Fail'}${dc.resultDetail ? ' \u2014 ' + escHtml(dc.resultDetail) : ''}</div>`
: '';
const fieldsHtml = cat.fields.map(f => {
const val = dc.values[f.key] || '';
const chg = `droppedChecks['${macro}'][${idx}].values['${f.key}']=this.value; refreshDroppedCheck('${macro}', ${idx})`;
const ss = 'font-size:.72rem;padding:.15rem .3rem;border:1px solid #cbd5e1;border-radius:.2rem';
if (f.type === 'collection') {
return `<div style="margin-bottom:.15rem"><span style="font-size:.68rem;color:#64748b">${f.label}:</span>
<select onchange="${chg}" style="${ss};max-width:180px">
${collOptionsHtmlCache.replace(`value="${val}"`, `value="${val}" selected`)}
</select></div>`;
} else if (f.type === 'select') {
return `<div style="margin-bottom:.15rem"><span style="font-size:.68rem;color:#64748b">${f.label}:</span>
<select onchange="${chg}" style="${ss}">
${f.options.map(o => `<option ${o===val?'selected':''}>${o}</option>`).join('')}
</select></div>`;
} else if (f.type === 'trajectory_urls') {
const urls = getTrajectoryUrls();
return `<div style="margin-bottom:.15rem"><span style="font-size:.68rem;color:#64748b">${f.label}:</span>
<select onchange="${chg}" style="${ss};max-width:220px">
<option value="">pick page...</option>
${urls.map(u => `<option value="${u}" ${u===val?'selected':''}>${u.replace('/sites/','')}</option>`).join('')}
</select></div>`;
} else if (f.type === 'request_log') {
const reqs = getRequestLogEntries();
return `<div style="margin-bottom:.15rem"><span style="font-size:.68rem;color:#64748b">${f.label}:</span>
<select onchange="${chg}" style="${ss};max-width:280px">
<option value="">pick request...</option>
${reqs.map(r => `<option value="${escHtml(r.value)}" ${r.value===val?'selected':''}>${escHtml(r.label)}</option>`).join('')}
</select></div>`;
} else if (f.type === 'trajectory_actions') {
const acts = getTrajectoryActions();
return `<div style="margin-bottom:.15rem"><span style="font-size:.68rem;color:#64748b">${f.label}:</span>
<select onchange="${chg}" style="${ss};max-width:220px">
<option value="">pick action...</option>
${acts.map(a => `<option value="${escHtml(a.value)}" ${a.value===val?'selected':''}>${escHtml(a.label)}</option>`).join('')}
</select></div>`;
} else if (f.type === 'trajectory_api_calls') {
const calls = getTrajectoryApiCalls();
return `<div style="margin-bottom:.15rem"><span style="font-size:.68rem;color:#64748b">${f.label}:</span>
<select onchange="${chg}" style="${ss};max-width:240px">
<option value="">pick API call...</option>
${calls.map(c => `<option value="${escHtml(c.value)}" ${c.value===val?'selected':''}>${escHtml(c.label)}</option>`).join('')}
</select></div>`;
} else if (f.type === 'trajectory_texts') {
const texts = getTrajectoryTexts();
return `<div style="margin-bottom:.15rem"><span style="font-size:.68rem;color:#64748b">${f.label}:</span>
<select onchange="${chg}" style="${ss};max-width:200px">
<option value="">pick value...</option>
${texts.map(t => `<option value="${escHtml(t)}" ${t===val?'selected':''}>${escHtml(t.length>40?t.slice(0,37)+'...':t)}</option>`).join('')}
</select></div>`;
} else if (f.type === 'answer') {
// Pre-fill from final answer
const ans = val || document.getElementById('final-answer')?.value || '';
if (!val && ans) dc.values[f.key] = ans;
return `<div style="margin-bottom:.15rem"><span style="font-size:.68rem;color:#64748b">${f.label}:</span>
<input type="text" value="${escHtml(ans)}" onchange="${chg}" style="${ss};width:100%"></div>`;
} else if (f.type === 'schema_collections') {
const colls = getSchemaCollections();
return `<div style="margin-bottom:.15rem"><span style="font-size:.68rem;color:#64748b">${f.label}:</span>
<select onchange="${chg}" style="${ss};max-width:240px">
<option value="">pick collection...</option>
${colls.map(c => `<option value="${escHtml(c.value)}" ${c.value===val?'selected':''}>${escHtml(c.label)}</option>`).join('')}
</select></div>`;
} else if (f.type === 'schema_fields') {
// Get fields from the selected collection (look for sibling 'collection' value)
const collKey = dc.values['collection'] || '';
const fields = getSchemaFields(collKey);
return `<div style="margin-bottom:.15rem"><span style="font-size:.68rem;color:#64748b">${f.label}:</span>
<select onchange="${chg}" style="${ss};max-width:240px">
<option value="">pick field...</option>
${fields.map(fi => `<option value="${escHtml(fi.value)}" ${fi.value===val?'selected':''}>${escHtml(fi.label)}</option>`).join('')}
</select></div>`;
} else if (f.type === 'collection_fields') {
return `<div style="margin-bottom:.15rem"><span style="font-size:.68rem;color:#64748b">${f.label}:</span>
<input type="text" value="${escHtml(val)}" placeholder="field name" onchange="${chg}" style="${ss};width:100%"></div>`;
} else {
return `<div style="margin-bottom:.15rem"><span style="font-size:.68rem;color:#64748b">${f.label}:</span>
<input type="${f.type||'text'}" value="${escHtml(val)}" placeholder="${f.placeholder||''}"
onchange="${chg}" style="${ss};width:100%"></div>`;
}
}).join('');
return `<div class="dropped-check">
<span class="dc-icon">${cat.icon}</span>
<div class="dc-body">
<div class="dc-title">${cat.name}</div>
<div style="font-size:.65rem;color:#94a3b8;margin-bottom:.2rem">${cat.hint}</div>
<div class="dc-fields">${fieldsHtml}</div>
${resultHtml}
</div>
<span class="dc-remove" onclick="removeDroppedCheck('${macro}',${idx})" title="Remove">\u00d7</span>
</div>`;
}).join('');
}
function _apiPathToEndpoint(path) {
// Use the actual API path directly — no conversion needed
return path || '';
}
function collectVerifierConfigs() {
const configs = [];
const macros = getEnabledMacros();
for (const macro of macros) {
const span = macroSpans[macro] || [];
const dcs = droppedChecks[macro] || [];
// Also include QA inline answers
if (isQAMacro(macro) && qaAnswers[macro] && !dcs.some(d => d.checkId === 'answer_match')) {
dcs.unshift({checkId: 'answer_match', values: {expected: qaAnswers[macro], match_type: qaAnswers[macro].match(/^\d+\.?\d*$/) ? 'number' : 'string'}});
}
if (!dcs.length) continue;
const checks = [];
for (const dc of dcs) {
const v = dc.values;
if (dc.checkId === 'answer_match') {
const mode = v.match_mode || 'exact';
checks.push({type: 'answer_match', expected: v.expected || '', match_type: v.match_type || 'string',
match_mode: mode,
description: `Answer ${mode === 'includes' ? 'includes' : mode === 'fuzzy' ? '\u2248' : '='} "${v.expected}"`});
} else if (dc.checkId === 'page_visited') {
checks.push({type: 'grounding', required_urls: [v.url || ''],
description: `Visited ${v.url}`});
} else if (dc.checkId === 'action_performed') {
const parts = (v.request || '').split('|||');
const method = parts[0] || 'GET';
const path = parts[1] || '';
const paramKey = parts[2] || '';
const paramVal = parts[3] || '';
const shortPath = path.replace('/sites/', '');
const desc_str = `${method} ${shortPath}` + (paramKey ? ` (${paramKey}=${paramVal.slice(0,15)})` : '');
checks.push({type: 'action_performed', method, path, param_key: paramKey, param_value: paramVal,
description: desc_str});
} else if (dc.checkId === 'api_succeeded') {
if (v.api_call) {
const [method, path] = v.api_call.split('|||');
checks.push({type: 'state_query', endpoint: path, method: method || 'GET',
params: {}, check: 'not_empty',
description: `${method} ${path} succeeded`});
}
} else if (dc.checkId === 'response_contains') {
if (v.api_call) {
const [method, path] = v.api_call.split('|||');
checks.push({type: 'state_query', endpoint: path, method: method || 'GET',
params: {}, check: 'contains', expected: v.text || '',
description: `Response contains "${v.text}"`});
}
} else if (dc.checkId === 'count_equals') {
if (v.api_call) {
const [method, path] = v.api_call.split('|||');
checks.push({type: 'count_equals', endpoint: path, method: method || 'GET',
params: {}, expected: parseInt(v.count) || 0,
description: `Count = ${v.count}`});
}
} else if (dc.checkId === 'record_exists') {
if (v.collection && v.field) {
const [siteId, coll] = v.collection.split('|||');
const ep = `/_admin/data/${siteId}/${coll}`;
checks.push({type: 'record_exists', endpoint: ep,
params: {[v.field]: v.expected || ''},
description: `${coll}.${v.field} = "${v.expected}" exists`});
}
} else if (dc.checkId === 'record_absent') {
if (v.collection && v.field) {
const [siteId, coll] = v.collection.split('|||');
const ep = `/_admin/data/${siteId}/${coll}`;
checks.push({type: 'record_absent', endpoint: ep,
params: {[v.field]: v.expected || ''},
description: `${coll}.${v.field} = "${v.expected}" absent`});
}
} else if (dc.checkId === 'field_equals') {
if (v.collection && v.field) {
const [siteId, coll] = v.collection.split('|||');
const ep = `/_admin/data/${siteId}/${coll}`;
const params = v.record_id ? {id: v.record_id} : {};
checks.push({type: 'field_equals', endpoint: ep, params: params,
field: v.field, expected: v.expected || '',
description: `${coll}[${v.record_id || '?'}].${v.field} = "${v.expected}"`});
}
}
}
if (checks.length) configs.push({macro, span, checks});
}
return configs;
}
async function testVerifiers() {
verifierConfigs = collectVerifierConfigs();
if (!verifierConfigs.length) {
document.getElementById('verifier-status').textContent = 'No checks configured \u2014 drag checks from the palette into macros';
return;
}
const status = document.getElementById('verifier-status');
status.textContent = 'Testing...';
status.style.color = '#64748b';
document.getElementById('task-verdict').innerHTML = '';
try {
const r = await fetch('/annotate/api/run_verifiers', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
configs: verifierConfigs,
trajectory: trajectory,
expected_answer: document.getElementById('final-answer').value,
})
});
const d = await r.json();
// Update per-check results inline
for (const res of (d.results || [])) {
const dcs = droppedChecks[res.macro] || [];
for (let i = 0; i < dcs.length && i < (res.checks||[]).length; i++) {
dcs[i].result = res.checks[i].pass;
dcs[i].resultDetail = res.checks[i].reason || res.checks[i].description || '';
}
refreshDropZone(res.macro);
// Update macro card header with pass/fail
const macroId = res.macro.replace(/[^a-zA-Z0-9]/g, '_');
const header = document.querySelector(`#drop-${macroId}`)?.parentElement?.querySelector('.vb-card-header');
if (header) {
let badge = header.querySelector('.macro-verdict');
if (!badge) {
badge = document.createElement('span');
badge.className = 'macro-verdict';
badge.style.cssText = 'font-size:.7rem;padding:.1rem .3rem;border-radius:.2rem;margin-left:.3rem';
header.appendChild(badge);
}
badge.textContent = res.pass ? '\u2705 PASS' : '\u274C FAIL';
badge.style.background = res.pass ? '#d1fae5' : '#fecdd3';
badge.style.color = res.pass ? '#065f46' : '#9f1239';
}
}
// Task-level verdict
const allMacrosPass = (d.results || []).every(r => r.pass);
const totalChecks = (d.results || []).reduce((sum, r) => sum + (r.checks||[]).length, 0);
const passedChecks = (d.results || []).reduce((sum, r) => sum + (r.checks||[]).filter(c=>c.pass).length, 0);
status.textContent = allMacrosPass ? 'Task: PASS' : 'Task: FAIL';
status.style.color = allMacrosPass ? '#16a34a' : '#ef4444';
document.getElementById('task-verdict').innerHTML = `
<div style="padding:.5rem .6rem;border-radius:.4rem;background:${allMacrosPass?'#f0fdf4':'#fef2f2'};border:1px solid ${allMacrosPass?'#bbf7d0':'#fecaca'};font-size:.82rem">
<div style="font-weight:700;color:${allMacrosPass?'#16a34a':'#dc2626'};margin-bottom:.2rem">
${allMacrosPass ? '\u2705 Task PASSED' : '\u274C Task FAILED'} \u2014 ${passedChecks}/${totalChecks} checks passed across ${(d.results||[]).length} macro(s)
</div>
${(d.results||[]).map(r => {
const c = getMacroColor(r.macro);
return `<div style="font-size:.75rem;color:${r.pass?'#16a34a':'#dc2626'}">
${r.pass?'\u2713':'\u2717'} <span style="color:${c.fg};font-weight:600">${r.macro}</span>: ${(r.checks||[]).filter(c=>c.pass).length}/${(r.checks||[]).length} checks
</div>`;
}).join('')}
</div>`;
// Show validate button after testing
document.getElementById('agent-btn').style.display = '';
document.getElementById('agent-results').innerHTML = '';
} catch(e) {
status.textContent = 'Error: ' + e.message;
status.style.color = '#ef4444';
}
}
// --- Perturbation validation ---
async function runAgentValidation() {
const configs = collectVerifierConfigs();
const status = document.getElementById('verifier-status');
const btn = document.getElementById('agent-btn');
if (agentRunning) {
status.textContent = 'Agent still running in background...';
status.style.color = '#64748b';
return;
}
// Clear previous agent results
agentResult = null;
document.getElementById('agent-results').innerHTML = '';
status.textContent = 'Running agent on this task...';
status.style.color = '#64748b';
btn.disabled = true;
try {
const r = await fetch('/annotate/api/agent_validate', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
configs: configs,
instruction: document.getElementById('instruction').value,
sites: currentPrompt ? currentPrompt.sites : [],
})
});
const d = await r.json();
agentResult = d;
renderAgentResults(d);
} catch(e) {
status.textContent = 'Agent error: ' + e.message;
} finally {
btn.disabled = false;
}
}
function renderAgentResults(d) {
const el = document.getElementById('agent-results');
const status = document.getElementById('verifier-status');
if (d.error) {
status.textContent = 'Agent error: ' + d.error;
status.style.color = '#ef4444';
return;
}
const agentPassed = d.agent_passed;
status.textContent = agentPassed ? 'Agent also passed — task is solvable' : 'Agent failed — check verifier or task difficulty';
status.style.color = agentPassed ? '#16a34a' : '#d97706';
let html = `<div style="border:1px solid ${agentPassed?'#bbf7d0':'#fcd34d'};border-radius:.4rem;overflow:hidden;margin-top:.3rem">
<div style="background:${agentPassed?'#f0fdf4':'#fffbeb'};padding:.4rem .6rem;font-size:.78rem;font-weight:600;color:${agentPassed?'#065f46':'#92400e'}">
Agent Validation: ${agentPassed ? 'Agent solved the task independently' : 'Agent could not solve the task'}
</div>
<div style="padding:.4rem .6rem;font-size:.75rem">
<div style="margin-bottom:.3rem"><b>Agent answer:</b> <code style="background:#f1f5f9;padding:.1rem .3rem;border-radius:.2rem">${escHtml(d.agent_answer || '(none)')}</code></div>
<div style="margin-bottom:.3rem;color:#64748b"><b>Browser steps:</b> ${d.agent_steps || 0} <b>Server requests:</b> ${d.request_count || 0}</div>
${(d.agent_errors||[]).length ? '<div style="margin-bottom:.3rem;color:#ef4444;font-size:.72rem"><b>Errors:</b> ' + d.agent_errors.map(e => escHtml(e)).join(', ') + '</div>' : ''}
</div>`;
// Per-check results
html += '<div style="padding:.2rem .6rem .4rem">';
for (const res of (d.agent_results || [])) {
const c = getMacroColor(res.macro);
const macroLabel = (macroDescriptions[res.macro]||{}).description || res.macro;
html += `<div style="margin-bottom:.3rem">
<span style="font-weight:600;color:${res.pass?'#16a34a':'#ef4444'}">${res.pass?'\u2713':'\u2717'}</span>
<span style="font-weight:500">${escHtml(macroLabel)}</span>`;
for (const ch of (res.checks || [])) {
html += `<div style="font-size:.7rem;color:${ch.pass?'#16a34a':'#ef4444'};padding-left:1.2rem">
${ch.pass?'\u2713':'\u2717'} ${escHtml(ch.description || '')} — ${escHtml(ch.reason || '')}
</div>`;
}
html += '</div>';
}
html += '</div>';
// Agent reasoning (collapsed)
if (d.agent_response) {
html += `<details style="padding:.3rem .6rem;border-top:1px solid #e2e8f0">
<summary style="font-size:.72rem;color:#64748b;cursor:pointer">Agent reasoning</summary>
<pre style="font-size:.65rem;color:#475569;white-space:pre-wrap;max-height:200px;overflow-y:auto;margin-top:.2rem">${escHtml(d.agent_response)}</pre>
</details>`;
}
html += '</div>';
el.innerHTML = html;
}
// (old toggleVerifier/fetchUserState/fetchInteractionLog/testAllVerifiers removed)
// Verifiers are now built by Claude via /api/build_verifiers
// --- Submit ---
async function submitTask() {
const instruction = document.getElementById('instruction').value;
const annotator = getAnnotatorName();
if (!currentPrompt || !currentPrompt.sites.length || !instruction || !annotator) {
alert('Fill in instruction and your name');
return;
}
if (trajectory.length === 0) { alert('Record a trajectory first (Step 2)'); return; }
const enabledMacros = getEnabledMacros();
// Every proposed (not-yet-registered) macro must carry a complete definition:
// natural-language description + span start + span end.
const badProposed = enabledMacros.filter(m => proposedMacros[baseOf(m)]).filter(m => {
const d = proposedMacroDefs[baseOf(m)] || {};
return !(d.description && d.description.trim()) || !(d.span_start && d.span_start.trim()) || !(d.span_end && d.span_end.trim());
});
if (badProposed.length) {
alert('Each new (proposed) macro needs a description, span start, and span end.\n\nIncomplete:\n• ' +
badProposed.join('\n• ') + '\n\nRe-add it via “+ Add macro → Propose a new macro”.');
return;
}
// Check that all enabled macros have been tagged in the trajectory
// QA macros with an answer don't need a span
const untagged = enabledMacros.filter(m => !(m in macroSpans) && !(isQAMacro(m) && qaAnswers[m]));
if (untagged.length > 0) {
const names = untagged.map(m => (macroDescriptions[m]||{}).description || m);
alert('Tag all macros before submitting.\n\nUntagged:\n• ' + names.join('\n• ') +
'\n\nGo to Step 2, select action ranges, and assign them to macros.');
return;
}
// Multi-macro tasks must have a fully connected dependency graph:
// every enabled macro needs at least one edge, and the graph must be
// one connected component (no orphan macros, no disjoint islands).
if (enabledMacros.length >= 2) {
const edges = macroEdges.filter(e => enabledMacros.includes(e.from) && enabledMacros.includes(e.to));
const linked = new Set();
edges.forEach(e => { linked.add(e.from); linked.add(e.to); });
const orphans = enabledMacros.filter(m => !linked.has(m));
if (orphans.length > 0) {
const names = orphans.map(m => (macroDescriptions[m]||{}).description || m);
alert('Define dependencies between all macros before submitting.\n\nNot connected:\n• ' +
names.join('\n• ') + '\n\nGo to Step 1 and drag arrows between the macro nodes.');
return;
}
// connectivity check (undirected)
const adj = {};
edges.forEach(e => {
(adj[e.from] = adj[e.from] || []).push(e.to);
(adj[e.to] = adj[e.to] || []).push(e.from);
});
const seen = new Set([enabledMacros[0]]);
const stack = [enabledMacros[0]];
while (stack.length) {
for (const n of (adj[stack.pop()] || [])) {
if (!seen.has(n)) { seen.add(n); stack.push(n); }
}
}
if (seen.size < enabledMacros.length) {
alert('The macro dependency graph is split into disconnected groups.\n' +
'Connect all macros into one flow in Step 1 before submitting.');
return;
}
}
// Fetch server logs to save alongside trajectory
let serverLog = [], beaconLog = [];
try {
const logR = await fetch('/_admin/log'); if (logR.ok) serverLog = (await logR.json()).entries || [];
const beacR = await fetch('/_admin/beacon'); if (beacR.ok) beaconLog = (await beacR.json()).entries || [];
} catch(e) {}
try {
const r = await fetch('/annotate/api/tasks', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
sites: currentPrompt.sites,
site: currentPrompt.sites[0].id,
instruction,
expected_answer: document.getElementById('final-answer').value,
// Backend `macros` stays a LIST of BASE names (duplicates allowed);
// per-instance state below is keyed by instance id. macro_instances
// is sent ONLY when duplicates exist, so non-duplicate tasks are
// byte-identical to the pre-duplicate schema.
macros: enabledMacros.map(baseOf),
macro_instances: _hasDupMacros() ? enabledMacros : undefined,
macro_spans: macroSpans,
macro_operations: macroOps,
macro_edges: macroEdges,
macro_positions: macroPositions,
macro_subtasks: macroSubtasks,
macros_not_applicable: macrosNotApplicable,
qa_answers: qaAnswers,
proposed_macros: Object.keys(proposedMacros).filter(m => selectedMacros.includes(m)),
proposed_macro_defs: proposedMacroDefs,
answer_type: document.getElementById('answer-type').value,
alternatives: document.getElementById('alternatives').value,
expected_outcome: (document.getElementById('expected-outcome') || {}).value || '',
starting_url: startingUrl,
requires_login: document.getElementById('auto-login-toggle').checked,
trajectory,
server_log: serverLog,
beacon_log: beaconLog,
agent_result: agentResult || null,
annotator,
replace_task_id: rerecordTask ? rerecordTask.task_id : undefined,
replace_annotator: rerecordTask ? rerecordTask.annotator : undefined,
})
});
const d = await r.json();
if (d.task_id && rerecordTask) {
document.getElementById('status').innerHTML =
`<span style="color:#16a34a;font-weight:600">Replaced: ${d.task_id} — returning to review...</span>`;
setTimeout(() => {
location.href = '/annotate/verify?task_id=' + encodeURIComponent(d.task_id)
+ '&annotator=' + encodeURIComponent(rerecordTask.annotator);
}, 1000);
return;
}
if (d.task_id) {
// Refresh floor counts in the site picker (a slot was just filled)
if (document.getElementById('site-count-picker')?.value === '1') _populateSitePicker();
document.getElementById('status').innerHTML = `<span style="color:#16a34a;font-weight:600">Saved: ${d.task_id} \u2014 loading next task...</span>`;
// Reset and load new prompt
setTimeout(() => {
trajectory = [];
actionCounter = 0;
macroSpans = {}; macroEdges = []; macroPositions = {}; macroSubtasks = {}; macroOps = {}; proposedMacros = {}; proposedMacroDefs = {};
macrosNotApplicable = {};
macrosSkipped = {};
qaAnswers = {};
droppedChecks = {};
cachedApiCalls = null;
cachedRequestLog = null;
agentResult = null;
agentRunning = false;
rewindPoint = null;
document.getElementById('instruction').value = '';
document.getElementById('final-answer').value = '';
document.getElementById('alternatives').value = '';
const outcomeEl = document.getElementById('expected-outcome');
if (outcomeEl) outcomeEl.value = '';
document.getElementById('instruction-banner').style.display = 'none';
document.getElementById('action-sidebar').classList.remove('visible');
document.getElementById('task-verdict').innerHTML = '';
document.getElementById('agent-results').innerHTML = '';
document.getElementById('agent-btn').style.display = 'none';
const badge2 = document.getElementById('agent-status-badge');
if (badge2) { badge2.style.display = 'none'; badge2.textContent = ''; }
// Clear server request log for clean slate
fetch('/_admin/log/clear', {method: 'POST'}).catch(() => {});
reshufflePrompt();
goStep(1);
}, 1000);
} else {
document.getElementById('status').innerHTML = `<span style="color:#ef4444">${d.error}</span>`;
}
} catch(e) { document.getElementById('status').innerHTML = `<span style="color:#ef4444">${e.message}</span>`; }
}
</script>
</body>
</html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Annotate</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; background: #f8fafc; color: #1e293b; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
.topbar { background: #fff; border-bottom: 1px solid #e2e8f0; padding: .5rem 1rem; display: flex; align-items: center; gap: .8rem; flex-shrink: 0; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.topbar h1 { font-size: .95rem; color: #0f172a; font-weight: 700; }
.topbar select, .topbar input { background: #fff; border: 1px solid #cbd5e1; color: #1e293b; padding: .3rem .5rem; border-radius: .3rem; font-size: .8rem; }
.topbar a { color: #2563eb; text-decoration: none; font-size: .8rem; }
.site-pill { display: inline-block; padding: .2rem .5rem; border-radius: 1rem; font-size: .75rem; font-weight: 600; cursor: pointer; border: 2px solid transparent; transition: all .15s; }
.site-pill.active { border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,.2); }
.split { display: flex; flex: 1; overflow: hidden; }
.action-sidebar { width: 360px; background: #fff; border-right: 1px solid #e2e8f0; display: none; flex-direction: column; overflow: hidden; flex-shrink: 0; }
.action-sidebar.visible { display: flex; }
.action-sidebar-header { padding: .5rem .6rem; border-bottom: 1px solid #e2e8f0; background: #f8fafc; display: flex; align-items: center; gap: .4rem; }
.action-sidebar-header h3 { font-size: .82rem; font-weight: 700; color: #0f172a; }
.action-sidebar-header .rec-dot { width: 8px; height: 8px; border-radius: 50%; background: #ef4444; animation: pulse 1s infinite; }
.action-sidebar-body { flex: 1; overflow-y: auto; padding: .3rem; }
.action-sidebar-footer { border-top: 1px solid #e2e8f0; padding: .4rem .6rem; background: #f8fafc; }
/* Action timeline entries */
.act-entry { padding: .4rem .6rem; margin-bottom: .25rem; border-radius: .35rem; border: 1.5px solid transparent; cursor: pointer; font-size: .82rem; transition: all .1s; position: relative; user-select: none; }
.act-entry:hover { background: #f1f5f9; }
.act-entry.selected { border-color: #2563eb; background: #eff6ff; }
.act-entry.in-range { background: #dbeafe; border-color: #93c5fd; }
.act-entry.rewound { opacity: .35; }
.net-entry { padding: .2rem .6rem .2rem 2rem; font-size: .72rem; color: #64748b; font-family: monospace; border-left: 2px solid #e2e8f0; margin-left: 1rem; margin-bottom: .15rem; }
.net-entry.net-err { border-left-color: #ef4444; background: #fef2f2; }
.net-entry .net-method { font-weight: 700; margin-right: .3rem; }
.net-entry .net-method.get { color: #16a34a; } .net-entry .net-method.post { color: #2563eb; }
.net-entry .net-method.put { color: #d97706; } .net-entry .net-method.delete { color: #ef4444; }
.net-entry .net-status { margin: 0 .3rem; font-weight: 600; }
.net-entry .net-status.ok { color: #16a34a; } .net-entry .net-status.err { color: #ef4444; }
.net-entry .net-dur { color: #94a3b8; margin-left: .3rem; }
.net-entry .net-body { color: #94a3b8; font-size: .6rem; max-height: 1.2em; overflow: hidden; cursor: pointer; }
.net-entry .net-body.expanded { max-height: none; white-space: pre-wrap; }
.net-entry .net-error-msg { color: #ef4444; font-weight: 600; font-size: .7rem; font-family: sans-serif; display: block; padding: .1rem 0; }
.act-entry .rewind-btn { display: none; position: absolute; right: 4px; top: 4px; background: #2563eb; color: #fff; border: none; border-radius: .25rem; font-size: .55rem; padding: .1rem .3rem; cursor: pointer; }
.act-entry:hover .rewind-btn { display: block; }
.act-entry .rewind-btn:hover { background: #1d4ed8; }
.act-entry .act-num { display: inline-block; width: 20px; color: #94a3b8; font-size: .7rem; font-weight: 600; }
.act-entry .act-icon { margin-right: .2rem; }
.act-entry .act-verb { font-weight: 600; color: #1e293b; }
.act-entry .act-target { color: #475569; }
.act-entry .act-detail { color: #64748b; font-size: .72rem; font-style: italic; }
.act-entry .act-tags { display: flex; flex-wrap: wrap; gap: .15rem; margin-top: .2rem; }
.act-entry .macro-tag { font-size: .55rem; padding: .05rem .25rem; border-radius: .6rem; font-weight: 500; cursor: pointer; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: middle; }
/* Range selection hint */
.range-hint { font-size: .72rem; color: #2563eb; padding: .3rem .5rem; background: #eff6ff; border-radius: .3rem; margin-bottom: .3rem; text-align: center; }
.main-layout { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
.top-row { display: flex; flex: 1; min-height: 0; }
.left-panel { flex: 1 1 0; min-width: 300px; display: flex; flex-direction: column; overflow: hidden; }
.left-panel iframe { flex: 1; border: none; background: #fff; }
.right-panel { width: 380px; flex: 0 0 380px; background: #fff; border-left: 1px solid #e2e8f0; display: flex; flex-direction: column; overflow: auto; }
.panel-resizer { width: 6px; background: #e2e8f0; cursor: col-resize; flex-shrink: 0; transition: background .15s; position: relative; z-index: 50; }
.panel-resizer::after { content: ''; position: absolute; top: 0; bottom: 0; left: -4px; right: -4px; z-index: 51; }
.panel-resizer:hover, .panel-resizer.active { background: #2563eb; }
.bottom-track { height: 220px; min-height: 120px; border-top: 1px solid #e2e8f0; background: #f8fafc; display: flex; flex-direction: column; overflow: hidden; flex-shrink: 0; }
.track-resizer { height: 5px; background: #e2e8f0; cursor: row-resize; flex-shrink: 0; transition: background .15s; }
.track-resizer:hover, .track-resizer.active { background: #2563eb; }
.steps-bar { display: flex; border-bottom: 1px solid #e2e8f0; flex-shrink: 0; background: #f8fafc; }
.steps-bar .step { flex: 1; padding: .5rem .2rem; text-align: center; font-size: .7rem; color: #94a3b8; cursor: pointer; border-bottom: 2px solid transparent; font-weight: 500; }
.steps-bar .step.active { color: #2563eb; border-bottom-color: #2563eb; }
.steps-bar .step.done { color: #16a34a; }
.steps-bar .step .num { display: inline-block; width: 18px; height: 18px; line-height: 18px; border-radius: 50%; border: 1.5px solid currentColor; font-size: .65rem; margin-right: .2rem; }
.steps-bar .step.done .num { background: #16a34a; border-color: #16a34a; color: #fff; }
.steps-bar .step.active .num { background: #2563eb; border-color: #2563eb; color: #fff; }
.step-content { flex: 1; overflow-y: auto; padding: .8rem; display: flex; flex-direction: column; }
.step-panel { display: none; }
.step-panel.active { display: flex; flex-direction: column; flex: 1; }
.step-panel h3 { font-size: .95rem; margin-bottom: .4rem; color: #0f172a; font-weight: 700; }
.step-panel .hint { font-size: .8rem; color: #64748b; margin-bottom: .6rem; line-height: 1.4; }
label { display: block; font-size: .78rem; color: #475569; margin: .6rem 0 .2rem; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
textarea, input[type="text"], input[type="number"], select { width: 100%; padding: .45rem .5rem; background: #fff; border: 1px solid #cbd5e1; color: #0f172a; border-radius: .4rem; font-size: .85rem; font-family: inherit; }
textarea:focus, input:focus, select:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,.15); }
textarea { min-height: 60px; resize: vertical; white-space: pre-wrap; word-wrap: break-word; }
/* Instruction textarea — extra prominent */
textarea#instruction {
font-size: 1rem;
line-height: 1.5;
min-height: 100px;
border: 2px solid #2563eb;
background: #eff6ff;
color: #0f172a;
padding: .6rem .7rem;
font-weight: 500;