donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
463 lines (394 loc) • 13.4 kB
JavaScript
(() => {
/**
* Wait until document.body is available, then call `init()`.
*/
function waitForBody(init) {
if (document.body) {
init();
} else {
document.addEventListener('DOMContentLoaded', init, { once: true });
}
}
/**
* Build an SVG icon via DOM APIs (to avoid TrustedHTML issues).
*/
function buildSvgIcon(opts) {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
for (const [attr, val] of Object.entries(opts.svgAttrs || {})) {
svg.setAttribute(attr, val);
}
// Add child elements (circle, polygon, line, etc.)
for (const childSpec of opts.children || []) {
const child = document.createElementNS(ns, childSpec.tag);
for (const [attr, val] of Object.entries(childSpec.attrs || {})) {
child.setAttribute(attr, val);
}
svg.appendChild(child);
}
return svg;
}
/**
* Main setup logic for the control panel.
*/
function createControlPanel() {
// Only create if not already in DOM.
const existingHost = document.getElementById('donobu-control-panel');
if (existingHost) return existingHost;
// Host element
const hostEl = document.createElement('div');
hostEl.id = 'donobu-control-panel';
document.body.appendChild(hostEl);
// Shadow root
const shadow = hostEl.attachShadow({ mode: 'open' });
// Global styles
const styleEl = document.createElement('style');
styleEl.textContent = `
:host {
all: initial;
position: fixed;
top: 150px;
right: 20px;
z-index: 2147483647;
width: 300px;
}
#panel-content,
#panel-content * {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: Helvetica, Arial, sans-serif;
font-size: 14px;
color: #fff !important;
}
#donobu-control-panel-title-text {
font-weight: 600 !important;
margin-left: 10px;
}
#panel-content input[type="text"] {
border: 1px solid #ddd !important;
background-color: #fff !important;
color: #000 !important;
}
#donobu-play-icon,
#donobu-pause-icon,
#donobu-end-icon {
fill: none !important;
stroke: currentColor !important;
stroke-width: 2 !important;
stroke-linecap: round !important;
stroke-linejoin: round !important;
}
#donobu-control-panel-display-area {
color: #fff !important;
margin-top: 10px;
}
#panel-content {
background-color: rgba(6,8,7,0.9);
border: 3px solid rgba(251,188,5, 0.9);
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.8);
transition: height 0.3s ease;
cursor: move;
width: 100%;
overflow: hidden;
}
#donobu-control-panel-expandable-content {
display: none;
padding: 15px;
max-height: 300px;
overflow-y: auto;
}
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
font-weight: 600;
background-color: rgba(6,8,7,0.9);
border-bottom: 1px solid rgba(251,188,5, 0.5);
}
.icon-btn {
margin-right: 8px;
display: flex;
cursor: pointer;
opacity: 0.5;
}
#donobu-instructions-input {
width: 100%;
margin-bottom: 10px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.icon-container {
display: flex;
align-items: center;
margin-right: 5px;
}
`;
shadow.appendChild(styleEl);
// Main panel container
const panelContent = document.createElement('div');
panelContent.id = 'panel-content';
// Navbar
const navbar = document.createElement('div');
navbar.className = 'navbar';
// Title container
const titleContainer = document.createElement('div');
Object.assign(titleContainer.style, {
display: 'flex',
alignItems: 'center',
});
// Icon container
const iconContainer = document.createElement('div');
iconContainer.className = 'icon-container';
// Build the actual SVG DOM nodes:
const playIcon = buildSvgIcon({
svgAttrs: {
id: 'donobu-play-icon',
width: '24',
height: '24',
viewBox: '0 0 24 24',
},
children: [
{ tag: 'circle', attrs: { cx: '12', cy: '12', r: '10' } },
{ tag: 'polygon', attrs: { points: '10 8 16 12 10 16 10 8' } },
],
});
const pauseIcon = buildSvgIcon({
svgAttrs: {
id: 'donobu-pause-icon',
width: '24',
height: '24',
viewBox: '0 0 24 24',
},
children: [
{ tag: 'circle', attrs: { cx: '12', cy: '12', r: '10' } },
{ tag: 'line', attrs: { x1: '10', y1: '15', x2: '10', y2: '9' } },
{ tag: 'line', attrs: { x1: '14', y1: '15', x2: '14', y2: '9' } },
],
});
const endIcon = buildSvgIcon({
svgAttrs: {
id: 'donobu-end-icon',
width: '24',
height: '24',
viewBox: '0 0 24 24',
},
children: [
{ tag: 'circle', attrs: { cx: '12', cy: '12', r: '10' } },
{ tag: 'rect', attrs: { x: '9', y: '9', width: '6', height: '6' } },
],
});
// Helper to create an icon button
function createIconBtn(id, svgEl) {
const btn = document.createElement('span');
btn.id = id;
btn.className = 'icon-btn';
btn.appendChild(svgEl);
return btn;
}
// Create the 3 icon buttons
const playButton = createIconBtn('donobu-play', playIcon);
const pauseButton = createIconBtn('donobu-pause', pauseIcon);
const endButton = createIconBtn('donobu-end', endIcon);
iconContainer.appendChild(playButton);
iconContainer.appendChild(pauseButton);
iconContainer.appendChild(endButton);
// Title text
const titleText = document.createElement('span');
titleText.id = 'donobu-control-panel-title-text';
titleText.textContent = 'Loading...';
// Assemble navbar
titleContainer.appendChild(iconContainer);
titleContainer.appendChild(titleText);
navbar.appendChild(titleContainer);
// Expandable content
const expandableContent = document.createElement('div');
expandableContent.id = 'donobu-control-panel-expandable-content';
const instructionsInput = document.createElement('input');
instructionsInput.type = 'text';
instructionsInput.id = 'donobu-instructions-input';
instructionsInput.placeholder = 'Additional instructions...';
const displayArea = document.createElement('div');
displayArea.id = 'donobu-control-panel-display-area';
displayArea.textContent = 'Popup: Monitoring...';
expandableContent.appendChild(instructionsInput);
expandableContent.appendChild(displayArea);
// Attach everything
panelContent.appendChild(navbar);
panelContent.appendChild(expandableContent);
shadow.appendChild(panelContent);
// Track panel flow state for the UI
hostEl.donobuFlowState = hostEl.donobuFlowState || 'LOADING';
// We also track "transitioning" states internally
let isTransitioningToPause = false;
let isTransitioningToEnd = false;
function setButtonState(btn, active) {
btn.style.opacity = active ? '1' : '0.5';
btn.style.cursor = active ? 'pointer' : 'default';
}
function updateButtonStates() {
if (!document.contains(hostEl)) {
// If forcibly removed, re-append (optional).
document.body.appendChild(hostEl);
}
const currentState = hostEl.donobuFlowState;
if (isTransitioningToEnd) {
titleText.textContent = 'Ending...';
setButtonState(playButton, false);
setButtonState(pauseButton, false);
setButtonState(endButton, false);
return;
}
switch (currentState) {
case 'PAUSED':
titleText.textContent = 'Paused';
setButtonState(playButton, true);
setButtonState(pauseButton, false);
setButtonState(endButton, true);
isTransitioningToPause = false;
break;
case 'QUERYING_LLM_FOR_NEXT_ACTION':
titleText.textContent = 'Thinking...';
setButtonState(playButton, false);
setButtonState(pauseButton, true);
setButtonState(endButton, true);
break;
case 'WAITING_ON_USER_FOR_NEXT_ACTION':
titleText.textContent = 'Waiting on user...';
setButtonState(playButton, false);
setButtonState(pauseButton, false);
setButtonState(endButton, true);
break;
case 'RUNNING_ACTION':
// Do not set the titleText.textContent for this case since the
// backend overwrites this value when running an action.
setButtonState(playButton, false);
setButtonState(pauseButton, true);
setButtonState(endButton, true);
break;
case 'LOADING':
titleText.textContent = 'Loading...';
setButtonState(playButton, false);
setButtonState(pauseButton, true);
setButtonState(endButton, true);
break;
case 'SUCCESS':
titleText.textContent = 'Success!';
setButtonState(playButton, false);
setButtonState(pauseButton, false);
setButtonState(endButton, false);
break;
default:
// "Running" or any other custom state
if (isTransitioningToPause) {
titleText.textContent = 'Pausing...';
setButtonState(playButton, false);
setButtonState(pauseButton, false);
setButtonState(endButton, true);
} else {
titleText.textContent = currentState;
setButtonState(playButton, false);
setButtonState(pauseButton, true);
setButtonState(endButton, true);
}
break;
}
}
// Interval for state updates
const stateInterval = setInterval(updateButtonStates, 500);
// --- Button event listeners ---
// PLAY button: signals user wants to resume from paused
playButton.addEventListener('click', () => {
if (hostEl.donobuFlowState === 'PAUSED' && !isTransitioningToEnd) {
// UI states
hostEl.donobuFlowState = 'RUNNING';
isTransitioningToPause = false;
// Expose next state so external script can pick it up
window.donobuNextState = 'RESUMING';
updateButtonStates();
}
});
// PAUSE button: signals user wants to pause
pauseButton.addEventListener('click', () => {
if (hostEl.donobuFlowState !== 'PAUSED' && !isTransitioningToEnd) {
hostEl.donobuFlowState = 'PAUSED';
isTransitioningToPause = true;
// Expose next state
window.donobuNextState = 'PAUSED';
updateButtonStates();
}
});
// END button: signals user wants to stop / end the flow
endButton.addEventListener('click', () => {
if (!isTransitioningToEnd) {
hostEl.donobuFlowState = 'SUCCESS';
isTransitioningToEnd = true;
// Expose next state
window.donobuNextState = 'SUCCESS';
updateButtonStates();
}
});
// --- Drag-and-drop logic ---
let isDragging = false;
let offsetX = 0;
let offsetY = 0;
function onDocumentMouseMove(e) {
if (isDragging) {
hostEl.style.left = `${e.clientX - offsetX}px`;
hostEl.style.top = `${e.clientY - offsetY}px`;
hostEl.style.right = 'auto';
}
}
function onDocumentMouseUp() {
isDragging = false;
panelContent.style.cursor = 'move';
}
// Start dragging if user clicks navbar area
shadow.addEventListener('mousedown', (e) => {
const target = e.target;
const isNavbar =
target.classList?.contains('navbar') ||
target.id === 'donobu-control-panel-title-text' ||
target.parentElement?.classList?.contains('navbar');
if (isNavbar) {
const rect = hostEl.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
isDragging = true;
panelContent.style.cursor = 'grabbing';
e.preventDefault();
}
});
document.addEventListener('mousemove', onDocumentMouseMove);
document.addEventListener('mouseup', onDocumentMouseUp);
// Watch for attempts to remove the panel
const observer = new MutationObserver(() => {
if (!document.contains(hostEl)) {
document.body.appendChild(hostEl);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
// Clean up on page unload
window.addEventListener('unload', () => {
clearInterval(stateInterval);
document.removeEventListener('mousemove', onDocumentMouseMove);
document.removeEventListener('mouseup', onDocumentMouseUp);
observer.disconnect();
});
return hostEl;
}
// Ensure body is ready before creation
waitForBody(() => {
try {
createControlPanel();
} catch (err) {
console.error('Control panel initialization error:', err);
}
});
})();