Skip to content

Commit 4386299

Browse files
authored
Merge pull request #168 from justrach/feat/advanced-agent-features
feat(server): action caching, set-of-marks, hybrid snapshot, smart diff, recording export
2 parents 026681d + c60edbb commit 4386299

1 file changed

Lines changed: 241 additions & 2 deletions

File tree

src/server/router.zig

Lines changed: 241 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,20 @@ fn route(request: *std.http.Server.Request, arena: std.mem.Allocator, bridge: *B
356356
handleEvalHandle(request, arena, bridge);
357357
} else if (std.mem.eql(u8, clean_path, "/diff/url")) {
358358
handleDiffUrl(request, arena, bridge);
359+
} else if (std.mem.eql(u8, clean_path, "/cache/set")) {
360+
handleCacheSet(request, arena, bridge);
361+
} else if (std.mem.eql(u8, clean_path, "/cache/get")) {
362+
handleCacheGet(request, arena, bridge);
363+
} else if (std.mem.eql(u8, clean_path, "/cache/clear")) {
364+
handleCacheClear(request, arena, bridge);
365+
} else if (std.mem.eql(u8, clean_path, "/cache/list")) {
366+
handleCacheList(request, arena, bridge);
367+
} else if (std.mem.eql(u8, clean_path, "/screenshot/som")) {
368+
handleScreenshotSom(request, arena, bridge);
369+
} else if (std.mem.eql(u8, clean_path, "/snapshot/changes")) {
370+
handleSnapshotChanges(request, arena, bridge);
371+
} else if (std.mem.eql(u8, clean_path, "/recording/export")) {
372+
handleRecordingExport(request, arena, bridge);
359373
} else {
360374
resp.sendError(request, 404, "Not Found");
361375
}
@@ -936,7 +950,35 @@ fn handleSnapshot(request: *std.http.Server.Request, arena: std.mem.Allocator, b
936950
ref_cache.node_count = snapshot.len;
937951
}
938952

939-
sendSnapshotResponse(request, arena, snapshot, opts);
953+
// Hybrid snapshot: if include_screenshot=true, wrap snapshot text with a screenshot
954+
const include_screenshot = if (getQueryParam(target, "include_screenshot")) |v| std.mem.eql(u8, v, "true") else false;
955+
if (include_screenshot) {
956+
const a11y_mod = @import("../snapshot/a11y.zig");
957+
const snap_text = a11y_mod.formatCompact(snapshot, arena) catch {
958+
sendSnapshotResponse(request, arena, snapshot, opts);
959+
return;
960+
};
961+
const screenshot_response = client.send(arena, protocol.Methods.page_capture_screenshot, "{\"format\":\"png\",\"quality\":80}") catch {
962+
sendSnapshotResponse(request, arena, snapshot, opts);
963+
return;
964+
};
965+
const screenshot_data = extractSimpleJsonString(screenshot_response, 0, "\"data\"") orelse "";
966+
const escaped_snap = jsonEscapeAlloc(arena, snap_text) orelse {
967+
sendSnapshotResponse(request, arena, snapshot, opts);
968+
return;
969+
};
970+
const escaped_ss = jsonEscapeAlloc(arena, screenshot_data) orelse {
971+
sendSnapshotResponse(request, arena, snapshot, opts);
972+
return;
973+
};
974+
const body = std.fmt.allocPrint(arena, "{{\"snapshot\":\"{s}\",\"screenshot\":\"{s}\"}}", .{ escaped_snap, escaped_ss }) catch {
975+
sendSnapshotResponse(request, arena, snapshot, opts);
976+
return;
977+
};
978+
resp.sendJson(request, body);
979+
} else {
980+
sendSnapshotResponse(request, arena, snapshot, opts);
981+
}
940982
}
941983

942984
fn sendSnapshotResponse(request: *std.http.Server.Request, arena: std.mem.Allocator, snapshot: []const @import("../snapshot/a11y.zig").A11yNode, opts: @import("../snapshot/a11y.zig").SnapshotOpts) void {
@@ -6756,6 +6798,200 @@ fn handleDiffUrl(request: *std.http.Server.Request, arena: std.mem.Allocator, br
67566798
}
67576799

67586800

6801+
// --- Action Cache ---
6802+
6803+
fn handleCacheSet(request: *std.http.Server.Request, arena: std.mem.Allocator, bridge: *Bridge) void {
6804+
const target = request.head.target;
6805+
const tab_id = requireEffectiveTabId(arena, request, bridge) orelse return;
6806+
const key = getQueryParam(target, "key") orelse { resp.sendError(request, 400, "Missing key parameter"); return; };
6807+
const ref = getQueryParam(target, "ref") orelse "";
6808+
const action = getQueryParam(target, "action") orelse "";
6809+
const client = bridge.getCdpClient(tab_id) orelse { resp.sendError(request, 404, "Tab not found"); return; };
6810+
rememberCurrentTab(request, bridge, tab_id);
6811+
6812+
const escaped_key = jsonEscapeAlloc(arena, key) orelse { resp.sendError(request, 500, "Internal Server Error"); return; };
6813+
const escaped_ref = jsonEscapeAlloc(arena, ref) orelse { resp.sendError(request, 500, "Internal Server Error"); return; };
6814+
const escaped_action = jsonEscapeAlloc(arena, action) orelse { resp.sendError(request, 500, "Internal Server Error"); return; };
6815+
6816+
const js = std.fmt.allocPrint(arena,
6817+
\\(function() {{
6818+
\\ if (!window.__kuri_action_cache) window.__kuri_action_cache = {{}};
6819+
\\ window.__kuri_action_cache["{s}"] = {{ref:"{s}",action:"{s}",url:location.href,timestamp:Date.now()}};
6820+
\\ return JSON.stringify({{ok:true,key:"{s}"}});
6821+
\\}})()
6822+
, .{ escaped_key, escaped_ref, escaped_action, escaped_key }) catch { resp.sendError(request, 500, "Internal Server Error"); return; };
6823+
6824+
const val = evalValueString(arena, client, js) orelse { resp.sendError(request, 502, "CDP command failed"); return; };
6825+
resp.sendJson(request, val);
6826+
}
6827+
6828+
fn handleCacheGet(request: *std.http.Server.Request, arena: std.mem.Allocator, bridge: *Bridge) void {
6829+
const target = request.head.target;
6830+
const tab_id = requireEffectiveTabId(arena, request, bridge) orelse return;
6831+
const key = getQueryParam(target, "key") orelse { resp.sendError(request, 400, "Missing key parameter"); return; };
6832+
const client = bridge.getCdpClient(tab_id) orelse { resp.sendError(request, 404, "Tab not found"); return; };
6833+
rememberCurrentTab(request, bridge, tab_id);
6834+
6835+
const escaped_key = jsonEscapeAlloc(arena, key) orelse { resp.sendError(request, 500, "Internal Server Error"); return; };
6836+
6837+
const js = std.fmt.allocPrint(arena,
6838+
\\(function() {{
6839+
\\ var c = (window.__kuri_action_cache || {{}})["{s}"];
6840+
\\ if (!c) return JSON.stringify({{found:false}});
6841+
\\ c.stale = (c.url !== location.href);
6842+
\\ return JSON.stringify(c);
6843+
\\}})()
6844+
, .{escaped_key}) catch { resp.sendError(request, 500, "Internal Server Error"); return; };
6845+
6846+
const val = evalValueString(arena, client, js) orelse { resp.sendError(request, 502, "CDP command failed"); return; };
6847+
resp.sendJson(request, val);
6848+
}
6849+
6850+
fn handleCacheClear(request: *std.http.Server.Request, arena: std.mem.Allocator, bridge: *Bridge) void {
6851+
const tab_id = requireEffectiveTabId(arena, request, bridge) orelse return;
6852+
const client = bridge.getCdpClient(tab_id) orelse { resp.sendError(request, 404, "Tab not found"); return; };
6853+
rememberCurrentTab(request, bridge, tab_id);
6854+
6855+
const js = "(function() { window.__kuri_action_cache = {}; return JSON.stringify({ok:true,action:\"cache_cleared\"}); })()";
6856+
const val = evalValueString(arena, client, js) orelse { resp.sendError(request, 502, "CDP command failed"); return; };
6857+
resp.sendJson(request, val);
6858+
}
6859+
6860+
fn handleCacheList(request: *std.http.Server.Request, arena: std.mem.Allocator, bridge: *Bridge) void {
6861+
const tab_id = requireEffectiveTabId(arena, request, bridge) orelse return;
6862+
const client = bridge.getCdpClient(tab_id) orelse { resp.sendError(request, 404, "Tab not found"); return; };
6863+
rememberCurrentTab(request, bridge, tab_id);
6864+
6865+
const js = "(function() { return JSON.stringify({entries: window.__kuri_action_cache || {}, count: Object.keys(window.__kuri_action_cache || {}).length}); })()";
6866+
const val = evalValueString(arena, client, js) orelse { resp.sendError(request, 502, "CDP command failed"); return; };
6867+
resp.sendJson(request, val);
6868+
}
6869+
6870+
// --- Set-of-Marks Screenshot ---
6871+
6872+
fn handleScreenshotSom(request: *std.http.Server.Request, arena: std.mem.Allocator, bridge: *Bridge) void {
6873+
const tab_id = requireEffectiveTabId(arena, request, bridge) orelse return;
6874+
const client = bridge.getCdpClient(tab_id) orelse { resp.sendError(request, 404, "Tab not found"); return; };
6875+
rememberCurrentTab(request, bridge, tab_id);
6876+
6877+
// Step 1: Inject SoM overlay and get element map
6878+
const inject_js =
6879+
\\(function() {
6880+
\\ var overlay = document.createElement('div');
6881+
\\ overlay.id = '__kuri_som';
6882+
\\ overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:999999';
6883+
\\ var elements = document.querySelectorAll('a,button,input,select,textarea,[role]');
6884+
\\ var idx = 0;
6885+
\\ var map = [];
6886+
\\ elements.forEach(function(el) {
6887+
\\ var r = el.getBoundingClientRect();
6888+
\\ if (r.width === 0 || r.height === 0) return;
6889+
\\ var s = getComputedStyle(el);
6890+
\\ if (s.display === 'none' || s.visibility === 'hidden') return;
6891+
\\ var box = document.createElement('div');
6892+
\\ box.style.cssText = 'position:fixed;left:'+r.x+'px;top:'+r.y+'px;width:'+r.width+'px;height:'+r.height+'px;border:2px solid red;background:rgba(255,0,0,0.1);pointer-events:none';
6893+
\\ var label = document.createElement('span');
6894+
\\ label.textContent = idx;
6895+
\\ label.style.cssText = 'position:absolute;top:-14px;left:0;background:red;color:white;font:bold 10px sans-serif;padding:1px 3px;border-radius:2px';
6896+
\\ box.appendChild(label);
6897+
\\ overlay.appendChild(box);
6898+
\\ map.push({idx:idx,tag:el.tagName.toLowerCase(),text:(el.textContent||'').trim().substring(0,40),x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)});
6899+
\\ idx++;
6900+
\\ });
6901+
\\ document.body.appendChild(overlay);
6902+
\\ return JSON.stringify({count:idx,elements:map});
6903+
\\})()
6904+
;
6905+
const elements_json = evalValueString(arena, client, inject_js) orelse { resp.sendError(request, 502, "Failed to inject SoM overlay"); return; };
6906+
6907+
// Step 2: Take screenshot
6908+
const screenshot_response = client.send(arena, protocol.Methods.page_capture_screenshot, "{\"format\":\"png\",\"quality\":80}") catch {
6909+
// Clean up overlay before returning error
6910+
_ = evalValueString(arena, client, "document.getElementById('__kuri_som')?.remove()");
6911+
resp.sendError(request, 502, "Screenshot capture failed");
6912+
return;
6913+
};
6914+
const screenshot_data = extractSimpleJsonString(screenshot_response, 0, "\"data\"") orelse "";
6915+
6916+
// Step 3: Remove overlay
6917+
_ = evalValueString(arena, client, "document.getElementById('__kuri_som')?.remove()");
6918+
6919+
// Step 4: Build combined response
6920+
const escaped_ss = jsonEscapeAlloc(arena, screenshot_data) orelse { resp.sendError(request, 500, "Internal Server Error"); return; };
6921+
const body = std.fmt.allocPrint(arena, "{{\"screenshot\":\"{s}\",\"elements\":{s}}}", .{ escaped_ss, elements_json }) catch { resp.sendError(request, 500, "Internal Server Error"); return; };
6922+
resp.sendJson(request, body);
6923+
}
6924+
6925+
// --- Smart Diff (Snapshot Changes) ---
6926+
6927+
fn handleSnapshotChanges(request: *std.http.Server.Request, arena: std.mem.Allocator, bridge: *Bridge) void {
6928+
const tab_id = requireEffectiveTabId(arena, request, bridge) orelse return;
6929+
const client = bridge.getCdpClient(tab_id) orelse { resp.sendError(request, 404, "Tab not found"); return; };
6930+
rememberCurrentTab(request, bridge, tab_id);
6931+
6932+
// Step 1: Get previous snapshot text from JS-side storage
6933+
const prev_text = evalValueString(arena, client, "(function() { return window.__kuri_prev_snapshot || ''; })()") orelse "";
6934+
6935+
// Step 2: Take new snapshot via a11y tree
6936+
const raw_response = client.send(arena, protocol.Methods.accessibility_get_full_tree, null) catch { resp.sendError(request, 502, "CDP command failed"); return; };
6937+
const a11y = @import("../snapshot/a11y.zig");
6938+
const nodes = parseA11yNodes(arena, raw_response) catch { resp.sendError(request, 500, "Failed to parse a11y tree"); return; };
6939+
const snapshot = a11y.buildSnapshot(nodes, .{ .compact = true }, arena) catch { resp.sendError(request, 500, "Failed to build snapshot"); return; };
6940+
const new_text = a11y.formatCompact(snapshot, arena) catch { resp.sendError(request, 500, "Failed to format snapshot"); return; };
6941+
6942+
// Step 3: Store new snapshot as previous
6943+
const store_escaped = jsonEscapeAlloc(arena, new_text) orelse { resp.sendError(request, 500, "Internal Server Error"); return; };
6944+
const store_js = std.fmt.allocPrint(arena,
6945+
\\(function() {{ window.__kuri_prev_snapshot = "{s}"; return "ok"; }})()
6946+
, .{store_escaped}) catch { resp.sendError(request, 500, "Internal Server Error"); return; };
6947+
_ = evalValueString(arena, client, store_js);
6948+
6949+
// Step 4: Diff line by line using JS to avoid Zig ArrayList API issues
6950+
// We do a simple JS-based diff since both texts are available
6951+
const escaped_prev = jsonEscapeAlloc(arena, prev_text) orelse { resp.sendError(request, 500, "Internal Server Error"); return; };
6952+
const escaped_new = jsonEscapeAlloc(arena, new_text) orelse { resp.sendError(request, 500, "Internal Server Error"); return; };
6953+
const diff_js = std.fmt.allocPrint(arena,
6954+
\\(function() {{
6955+
\\ var prev = "{s}".split("\n").filter(function(l) {{ return l.length > 0; }});
6956+
\\ var curr = "{s}".split("\n").filter(function(l) {{ return l.length > 0; }});
6957+
\\ var prevSet = new Set(prev);
6958+
\\ var currSet = new Set(curr);
6959+
\\ var added = curr.filter(function(l) {{ return !prevSet.has(l); }});
6960+
\\ var removed = prev.filter(function(l) {{ return !currSet.has(l); }});
6961+
\\ var unchanged = curr.filter(function(l) {{ return prevSet.has(l); }}).length;
6962+
\\ return JSON.stringify({{added:added,removed:removed,unchanged_count:unchanged}});
6963+
\\}})()
6964+
, .{ escaped_prev, escaped_new }) catch { resp.sendError(request, 500, "Internal Server Error"); return; };
6965+
const diff_result = evalValueString(arena, client, diff_js) orelse { resp.sendError(request, 502, "Diff computation failed"); return; };
6966+
resp.sendJson(request, diff_result);
6967+
}
6968+
6969+
// --- Recording Export ---
6970+
6971+
fn handleRecordingExport(request: *std.http.Server.Request, arena: std.mem.Allocator, bridge: *Bridge) void {
6972+
const tab_id = requireEffectiveTabId(arena, request, bridge) orelse return;
6973+
const client = bridge.getCdpClient(tab_id) orelse { resp.sendError(request, 404, "Tab not found"); return; };
6974+
rememberCurrentTab(request, bridge, tab_id);
6975+
6976+
// Read the recording data from JS
6977+
const js =
6978+
\\(function() {
6979+
\\ var rec = window.__kuri_recording || [];
6980+
\\ var commands = [];
6981+
\\ rec.forEach(function(e) {
6982+
\\ if (e.type === 'click') {
6983+
\\ commands.push({path:'/action',action:'click',ref:e.target});
6984+
\\ } else if (e.type === 'input' || e.type === 'change') {
6985+
\\ commands.push({path:'/action',action:'fill',ref:e.target,value:e.value||''});
6986+
\\ }
6987+
\\ });
6988+
\\ return JSON.stringify({format:'batch',commands:commands,count:commands.length});
6989+
\\})()
6990+
;
6991+
const val = evalValueString(arena, client, js) orelse { resp.sendError(request, 502, "CDP command failed"); return; };
6992+
resp.sendJson(request, val);
6993+
}
6994+
67596995

67606996
test "screenshot routes match" {
67616997
for ([_][]const u8{ "/screenshot/annotated", "/screenshot/diff", "/screencast/start", "/screencast/stop" }) |p| {
@@ -7034,8 +7270,11 @@ test "total endpoint count" {
70347270
"/react/inspect", "/react/renders", "/react/suspense", "/recording/start",
70357271
"/recording/stop", "/request/detail", "/wait/download", "/initscript/remove",
70367272
"/evalhandle", "/diff/url",
7273+
// Advanced features
7274+
"/cache/set", "/cache/get", "/cache/clear", "/cache/list",
7275+
"/screenshot/som", "/snapshot/changes", "/recording/export",
70377276
};
7038-
try std.testing.expectEqual(@as(usize, 135), routes.len);
7277+
try std.testing.expectEqual(@as(usize, 142), routes.len);
70397278
}
70407279

70417280
test "buildGetExpression title" {

0 commit comments

Comments
 (0)