Files
javis_bot/bot/scripts/stream-test/chrome-control.mjs
javis-bot 3d1e56f60f feat: controlBrowser tool — human-operable on-screen Chrome
Adds a general browser-control tool (navigate to any site, list/open/close/
switch tabs, close popups, click, type, scroll, screenshot) for the Go-Live
Chrome, on top of the existing CDP + xdotool human-input layer (visible
cursor, char-by-char typing). Closes the gap where "open Naver" had no tool
and the model confabulated success. Also adds a system-prompt rule against
claiming actions no tool actually performed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 02:22:36 +09:00

194 lines
7.0 KiB
JavaScript

// Human-operable Chrome control for the on-screen (Go-Live) browser.
//
// node chrome-control.mjs '<json-command>'
//
// Connects to the on-screen Chrome over CDP (so every action is visible on the
// broadcast) and performs ONE command, printing a JSON result on stdout for the
// Python `controlBrowser` tool to wrap. READ actions use the CDP/DOM API;
// ACTION actions (navigate/click/type/scroll) drive real input via xdotool
// (human.mjs) so the cursor visibly moves and text is typed one char at a time,
// exactly as a person would — never synthetic DOM events.
//
// Commands (json): { "action": "<name>", ...params }
// status -> broadcast/browser readiness + tab list
// listTabs -> [{index,url,title,active}]
// navigate {url, human?} -> open url in the active tab
// newTab {url?} -> open a new tab (optionally to url)
// closeTab {index} -> close tab by index
// activateTab{index} -> bring tab to front
// closePopups -> close blank/popup tabs, keep the active one
// click {selector, human?} -> click an element (human cursor by default)
// type {text, selector?, human?}-> type text char-by-char (human by default)
// scroll {dir, notches?} -> wheel scroll (dir: "down"|"up")
// pressKey {key} -> press a single key (xdotool keysym)
// screenshot {path} -> save a PNG of the active tab
import { chromium } from 'playwright';
import {
humanClick,
humanType,
pressKey,
humanScroll,
navigateOmnibox,
} from './human.mjs';
const CDP = process.env.CDP_PORT || '9222';
const CDP_HOST = process.env.CDP_HOST || '127.0.0.1';
const out = (o) => process.stdout.write(JSON.stringify(o));
let cmd;
try {
cmd = JSON.parse(process.argv[2] || '{}');
} catch (e) {
out({ ok: false, error: `bad command json: ${e?.message || e}` });
process.exit(1);
}
const action = String(cmd.action || '').trim();
if (!action) {
out({ ok: false, error: 'no action' });
process.exit(1);
}
const tabInfo = async (pages, active) => {
const list = [];
for (let i = 0; i < pages.length; i++) {
const p = pages[i];
list.push({
index: i,
url: p.url(),
title: await p.title().catch(() => ''),
active: p === active,
});
}
return list;
};
let b;
try {
b = await chromium.connectOverCDP(`http://${CDP_HOST}:${CDP}`);
const ctx = b.contexts()[0];
if (!ctx) throw new Error('no browser context (is Chrome running?)');
const pages = ctx.pages();
// The "active" tab: the most recently fronted page. Playwright has no direct
// accessor, so we treat the first visible page as active and let activateTab
// change focus explicitly.
let page = pages.find((p) => p.url() && p.url() !== 'about:blank') || pages[0] || (await ctx.newPage());
page.setDefaultTimeout(20000);
switch (action) {
case 'status': {
out({ ok: true, browserOpen: true, tabCount: pages.length, tabs: await tabInfo(pages, page) });
break;
}
case 'listTabs': {
out({ ok: true, tabs: await tabInfo(pages, page) });
break;
}
case 'navigate': {
const url = String(cmd.url || '').trim();
if (!url) throw new Error('navigate: no url');
const target = /^https?:\/\//i.test(url) ? url : `https://${url}`;
await page.bringToFront().catch(() => {});
if (cmd.human === false) {
await page.goto(target, { waitUntil: 'domcontentloaded' });
} else {
// Type the URL into the omnibox char-by-char with a real cursor.
await navigateOmnibox(target);
await page.waitForLoadState('domcontentloaded').catch(() => {});
}
out({ ok: true, url: page.url(), title: await page.title().catch(() => '') });
break;
}
case 'newTab': {
const np = await ctx.newPage();
await np.bringToFront().catch(() => {});
if (cmd.url) {
const target = /^https?:\/\//i.test(cmd.url) ? cmd.url : `https://${cmd.url}`;
await np.goto(target, { waitUntil: 'domcontentloaded' }).catch(() => {});
}
out({ ok: true, index: ctx.pages().length - 1, url: np.url() });
break;
}
case 'closeTab': {
const idx = Number(cmd.index);
if (!Number.isInteger(idx) || idx < 0 || idx >= pages.length) throw new Error('closeTab: bad index');
await pages[idx].close();
out({ ok: true, closed: idx, remaining: ctx.pages().length });
break;
}
case 'activateTab': {
const idx = Number(cmd.index);
if (!Number.isInteger(idx) || idx < 0 || idx >= pages.length) throw new Error('activateTab: bad index');
await pages[idx].bringToFront();
out({ ok: true, active: idx, url: pages[idx].url() });
break;
}
case 'closePopups': {
// Close throwaway/popup tabs (about:blank or anything that isn't the
// active content tab), keeping the active one.
let closed = 0;
for (const p of pages) {
if (p === page) continue;
const u = p.url();
if (!u || u === 'about:blank' || cmd.all) {
await p.close().catch(() => {});
closed++;
}
}
out({ ok: true, closed, remaining: ctx.pages().length });
break;
}
case 'click': {
const selector = String(cmd.selector || '').trim();
if (!selector) throw new Error('click: no selector');
await page.bringToFront().catch(() => {});
const locator = page.locator(selector).first();
if (cmd.human === false) await locator.click();
else await humanClick(page, locator);
out({ ok: true });
break;
}
case 'type': {
const text = String(cmd.text ?? '');
await page.bringToFront().catch(() => {});
if (cmd.selector) {
const locator = page.locator(String(cmd.selector)).first();
if (cmd.human === false) { await locator.fill(text); out({ ok: true }); break; }
await humanClick(page, locator); // focus the field with a real click first
}
await humanType(text);
out({ ok: true });
break;
}
case 'scroll': {
const dir = String(cmd.dir || 'down').toLowerCase() === 'up' ? -1 : 1;
await humanScroll(page, dir, Number(cmd.notches) || 5);
out({ ok: true });
break;
}
case 'pressKey': {
const key = String(cmd.key || '').trim();
if (!key) throw new Error('pressKey: no key');
await pressKey(key);
out({ ok: true });
break;
}
case 'screenshot': {
const path = String(cmd.path || '').trim();
if (!path) throw new Error('screenshot: no path');
await page.bringToFront().catch(() => {});
await page.screenshot({ path });
out({ ok: true, path });
break;
}
default:
out({ ok: false, error: `unknown action: ${action}` });
await b.close();
process.exit(1);
}
await b.close();
} catch (e) {
try { await b?.close(); } catch { /* ignore */ }
out({ ok: false, error: String(e?.message || e) });
process.exit(1);
}