// ==UserScript==
// @name ChatGPT Automation Pro
// @namespace http://tampermonkey.net/
// @version 2.5
// @description Advanced ChatGPT automation with dynamic templating
// @author Henry Russell
// @match https://chatgpt.com/*
// @match https://chat.openai.com/*
// @grant unsafeWindow
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @connect *
// @run-at document-end
// @inject-into auto
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const CONFIG = {
DEBUG_MODE: false,
RESPONSE_TIMEOUT: 3000000,
DEFAULT_VISIBLE: false,
RUN_LOCK_TTL_MS: 15000,
RUN_LOCK_RENEW_MS: 5000,
BATCH_WAIT_TIME: 2000,
AUTO_REMOVE_PROCESSED: false,
AUTO_SCROLL_LOGS: true,
};
// --- Eval Capability Detection (Chrome MV3/Tampermonkey CSP) ---
// Some environments (e.g., Chrome + Tampermonkey on CSP-heavy sites like chatgpt.com) block
// dynamic code evaluation (`eval`/`Function`). Detect once and adapt behavior.
const ENV = (() => {
let canEval = true;
try {
// Using the Function constructor directly to test, same mechanism used by custom code runner
// eslint-disable-next-line no-new-func
new Function('return 1')();
} catch (e) {
canEval = false;
}
return {
CAN_EVAL: canEval,
IS_CHROME: typeof navigator !== 'undefined' && /Chrome\//.test(navigator.userAgent),
IS_FIREFOX: typeof navigator !== 'undefined' && /Firefox\//.test(navigator.userAgent),
};
})();
const state = {
isLooping: false,
dynamicElements: [],
lastResponseElement: null,
responseObserver: null,
isMinimized: false,
isDarkMode: false,
uiVisible: CONFIG.DEFAULT_VISIBLE,
headerObserverStarted: false,
autoScrollLogs: CONFIG.AUTO_SCROLL_LOGS,
batchWaitTime: CONFIG.BATCH_WAIT_TIME,
autoRemoveProcessed: CONFIG.AUTO_REMOVE_PROCESSED,
isProcessing: false,
currentBatchIndex: 0,
processedCount: 0,
chainDefinition: null,
runLockId: null,
runLockTimer: null,
};
const STORAGE_KEYS = {
messageInput: 'messageInput',
templateInput: 'templateInput',
dynamicElementsInput: 'dynamicElementsInput',
customCodeInput: 'customCodeInput',
loop: 'looping',
autoRemove: 'autoRemoveProcessed',
autoScroll: 'autoScrollLogs',
waitTime: 'batchWaitTime',
stepWaitTime: 'stepWaitTime',
activeTab: 'activeTab',
uiState: 'uiState',
chainDef: 'chain.definition',
presetsTemplates: 'presets.templates',
presetsChains: 'presets.chains',
presetsResponseJS: 'presets.responseJS',
presetsSteps: 'presets.steps',
logHistory: 'log.history',
logVisible: 'log.visible',
runLockKey: 'chatgptAutomation.runLock',
configDebug: 'config.debugMode',
configTimeout: 'config.responseTimeout',
configDefaultVisible: 'config.defaultVisible',
};
let ui = {
mainContainer: null,
statusIndicator: null,
logContainer: null,
progressBar: null,
progressBarSub: null,
resizeHandle: null,
miniProgress: null,
miniFill: null,
miniLabel: null,
miniSubProgress: null,
miniSubFill: null,
miniSubLabel: null,
};
// Small helpers to keep calls consistent
const saveUIState = (immediate = false) => uiState.save(immediate);
const utils = {
log: (message, type = 'info') => {
const now = new Date();
const datePart = now.toLocaleDateString(undefined, { day: '2-digit', month: 'short' });
const timePart = now.toLocaleTimeString();
const timestamp = `${datePart} ${timePart}`;
const logMessage = `[${timestamp}] ${message}`;
if (CONFIG.DEBUG_MODE) console.log(logMessage);
if (ui.logContainer) {
const logEntry = document.createElement('div');
logEntry.className = `log-entry log-${type}`;
logEntry.textContent = logMessage;
ui.logContainer.appendChild(logEntry);
if (state.autoScrollLogs) {
ui.logContainer.scrollTop = ui.logContainer.scrollHeight;
}
const entries = Array.from(ui.logContainer.querySelectorAll('.log-entry'));
while (entries.length > 200) {
const first = entries.shift();
if (first?.parentNode) first.parentNode.removeChild(first);
}
}
try {
let history = GM_getValue(STORAGE_KEYS.logHistory, []);
if (!Array.isArray(history)) history = [];
history.push({ t: Date.now(), type, msg: logMessage });
if (history.length > 300) history = history.slice(-300);
GM_setValue(STORAGE_KEYS.logHistory, history);
} catch {}
},
clip: (s, n = 300) => {
try {
const str = String(s ?? '');
return str.length > n ? str.slice(0, n) + '…' : str;
} catch {
return '';
}
},
detectDarkMode: () => {
const html = document.documentElement;
const body = document.body;
return [
html.classList.contains('dark'),
body.classList.contains('dark'),
html.getAttribute('data-theme') === 'dark',
body.getAttribute('data-theme') === 'dark',
getComputedStyle(body).backgroundColor.includes('rgb(0, 0, 0)') ||
getComputedStyle(body).backgroundColor.includes('rgb(17, 24, 39)') ||
getComputedStyle(body).backgroundColor.includes('rgb(31, 41, 55)'),
].some(Boolean);
},
saveToStorage: (key, value) => {
try {
GM_setValue(key, value);
} catch {}
},
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
getByPath: (obj, path) => {
try {
return path.split('.').reduce((acc, part) => acc?.[part], obj);
} catch {
return undefined;
}
},
queryFirst: (selectors) => {
for (const s of selectors) {
const el = document.querySelector(s);
if (el) return el;
}
return null;
},
loadFromStorage: (key, def) => {
try {
return GM_getValue(key, def);
} catch {
return def;
}
},
};
const http = {
request: (opts) =>
new Promise((resolve, reject) => {
try {
const {
method = 'GET',
url,
headers = {},
data,
responseType = 'text',
timeout = 30000,
} = opts || {};
if (!url) throw new Error('Missing url');
GM_xmlhttpRequest({
method,
url,
headers,
data,
responseType,
timeout,
anonymous: false,
onload: (res) => resolve(res),
onerror: (err) => {
try {
const msg = err?.error || err?.message || 'Network error';
reject(new Error(msg));
} catch {
reject(new Error('Network error'));
}
},
ontimeout: () => reject(new Error('Request timeout')),
});
} catch (e) {
reject(e);
}
}),
postForm: (url, formObj, extraHeaders = {}) => {
const body = Object.entries(formObj || {})
.map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(String(v)))
.join('&');
return http.request({
method: 'POST',
url,
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
...extraHeaders,
},
data: body,
});
},
postMultipart: (url, formObj, extraHeaders = {}) => {
return http.postForm(url, formObj, extraHeaders);
},
};
const processors = {
executeCustomCode: async (code, responseText, templateData = null) => {
if (!code || code.trim() === '') return;
try {
// Resolve context
let item = templateData?.elementData ?? null;
let index = templateData?.index ?? null;
let total = templateData?.total ?? null;
const stepsCtx = templateData?.steps ?? {};
const lastResponse = templateData?.lastResponse ?? responseText;
// Fallback: single dynamic element → provide as item when not in template mode
if (!item) {
try {
const dynInput = document.getElementById('dynamic-elements-input');
const val = dynInput && typeof dynInput.value === 'string' ? dynInput.value.trim() : '';
if (val) {
const arr = await processors.parseDynamicElements(val);
if (Array.isArray(arr) && arr.length === 1) {
item = arr[0];
if (index == null) index = 1;
if (total == null) total = 1;
utils.log('Context fallback: using single dynamic element for custom code');
}
}
} catch {}
}
if (CONFIG.DEBUG_MODE) {
utils.log(
`Custom code context: item=${item ? JSON.stringify(item).slice(0, 100) : 'null'}, index=${index}, total=${total}`
);
}
if (ENV.CAN_EVAL) {
// Use sandbox-safe Function constructor; await Promise if returned
const Fn = function () {}.constructor; // constructor of a sandboxed function
const fn = new Fn(
'response',
'log',
'console',
'item',
'index',
'total',
'http',
'steps',
'lastResponse',
'GM_getValue',
'GM_setValue',
'GM_xmlhttpRequest',
'unsafeWindow',
'utils',
code
);
const result = fn(
responseText,
(msg, type = 'info') => utils.log(msg, type),
console,
item,
index,
total,
http,
stepsCtx,
lastResponse,
GM_getValue,
GM_setValue,
GM_xmlhttpRequest,
unsafeWindow,
utils
);
await Promise.resolve(result);
utils.log('Custom code executed successfully');
return result;
} else {
// Safe fallback: no dynamic code evaluation due to CSP (Chrome MV3/Tampermonkey).
// Attempt to auto-parse JSON and return a useful object.
let data = responseText;
if (typeof data === 'string') {
const trimmed = data.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
data = JSON.parse(trimmed);
} catch {}
}
}
utils.log('Custom JS is disabled here (CSP blocks unsafe-eval). Returned parsed response instead.', 'warn');
return data;
}
} catch (error) {
utils.log(`Custom code execution error: ${error.message}`, 'error');
throw error;
}
},
processDynamicTemplate: (template, dynamicData) => {
if (!template) return '';
const regex = /\{\{\s*([\w$.]+)\s*\}\}|\{\s*([\w$.]+)\s*\}/g;
return template.replace(regex, (_, g1, g2) => {
const keyPath = g1 || g2;
let value = utils.getByPath(dynamicData, keyPath);
if (value === undefined) return '';
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
});
},
parseDynamicElements: async (input) => {
const raw = (input || '').trim();
if (!raw) return [];
// Primary: JSON arrays/objects
if (raw.startsWith('[') || raw.startsWith('{')) {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [parsed];
} catch (e) {
utils.log(`Invalid JSON: ${e.message}`, 'error');
return [];
}
}
// Chrome-safe fallback when expressions are provided (no eval):
// Support simple CSV / newline separated values and numeric ranges like 1..5
if (!ENV.CAN_EVAL) {
const lines = raw.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
const parts = lines.length > 1 ? lines : raw.split(',').map((s) => s.trim()).filter(Boolean);
const out = [];
for (const p of parts) {
const m = p.match(/^(\d+)\.\.(\d+)$/);
if (m) {
const a = parseInt(m[1], 10), b = parseInt(m[2], 10);
const step = a <= b ? 1 : -1;
for (let x = a; step > 0 ? x <= b : x >= b; x += step) out.push(x);
} else if (/^\d+(?:\.\d+)?$/.test(p)) {
out.push(Number(p));
} else if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
out.push(p.slice(1, -1));
} else {
// treat as literal string
out.push(p);
}
}
return out;
}
// Legacy (Firefox): evaluate expression (may return array/object/stringified JSON)
try {
// eslint-disable-next-line no-new-func
const v = new Function('return ( ' + raw + ' )')();
const res = typeof v === 'function' ? v() : v;
if (Array.isArray(res)) return res;
if (res && typeof res === 'object') return [res];
if (typeof res === 'string') {
try {
const parsed = JSON.parse(res);
if (Array.isArray(parsed)) return parsed;
if (parsed && typeof parsed === 'object') return [parsed];
} catch {}
}
return [res];
} catch (e) {
utils.log(`Dynamic element expression not supported here: ${e.message}`, 'warn');
return [];
}
},
};
const uiState = {
saveTimeout: null,
save: (immediate = false) => {
if (!ui.mainContainer) return;
const doSave = () => {
const stateData = {
left: ui.mainContainer.style.left,
top: ui.mainContainer.style.top,
right: ui.mainContainer.style.right,
minimized: state.isMinimized,
visible: state.uiVisible,
};
utils.saveToStorage(STORAGE_KEYS.uiState, JSON.stringify(stateData));
};
if (immediate) {
clearTimeout(uiState.saveTimeout);
doSave();
} else {
clearTimeout(uiState.saveTimeout);
uiState.saveTimeout = setTimeout(doSave, 100);
}
},
load: () => {
try {
const saved = GM_getValue(STORAGE_KEYS.uiState, null);
return saved ? JSON.parse(saved) : {};
} catch {
return {};
}
},
};
const chatGPT = {
getChatInput: () => {
const selectors = [
'#prompt-textarea',
'div[contenteditable="true"]',
'textarea[placeholder*="Message"]',
'div.ProseMirror',
];
const el = utils.queryFirst(selectors);
return el && el.isContentEditable !== false ? el : null;
},
getSendButton: () => {
const selectors = [
'#composer-submit-button',
'button[data-testid="send-button"]',
'button[aria-label*="Send"]',
'button[aria-label*="submit"]',
];
const btn = utils.queryFirst(selectors);
return btn && !btn.disabled ? btn : null;
},
typeMessage: async (message) => {
const input = chatGPT.getChatInput();
if (!input) throw new Error('Chat input not found');
if (input.tagName === 'DIV') {
input.innerHTML = '';
input.focus();
const paragraph = document.createElement('p');
paragraph.textContent = message;
input.appendChild(paragraph);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
} else {
input.value = message;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
await utils.sleep(100);
utils.log(`Message typed: "${utils.clip(message, 50)}"`);
},
sendMessage: async () => {
const sendButton = chatGPT.getSendButton();
if (!sendButton) throw new Error('Send button not available');
sendButton.click();
utils.log('Message sent');
await utils.sleep(500);
},
ask: async (message) => {
await chatGPT.typeMessage(message);
await utils.sleep(300);
await chatGPT.sendMessage();
updateStatus('waiting');
const el = await chatGPT.waitForResponse();
return { el, text: chatGPT.extractResponseText(el) };
},
// ask with expectation option: { expect: 'image' | 'text' }
askWith: async (message, options = { expect: 'text' }) => {
await chatGPT.typeMessage(message);
await utils.sleep(300);
await chatGPT.sendMessage();
updateStatus('waiting');
const el = await chatGPT.waitForResponse();
if (options.expect === 'image') {
// Allow brief time for images to attach
await utils.sleep(500);
let images = chatGPT.extractResponseImages(el);
if (!images || images.length === 0) {
// Retry scan for late-loading images
await utils.sleep(800);
images = chatGPT.extractResponseImages(el);
}
return { el, images };
}
return { el, text: chatGPT.extractResponseText(el) };
},
waitForResponse: async () => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (state.responseObserver) state.responseObserver.disconnect();
reject(new Error('Response timeout'));
}, CONFIG.RESPONSE_TIMEOUT);
const checkForNewResponse = () => {
const assistantMessages = document.querySelectorAll(
'[data-message-author-role="assistant"]'
);
const latestMessage = assistantMessages[assistantMessages.length - 1];
if (latestMessage && latestMessage !== state.lastResponseElement) {
const isGenerating =
document.querySelector('[data-testid="stop-button"]') ||
document.querySelector('.result-thinking') ||
latestMessage.querySelector('.typing-indicator');
if (!isGenerating) {
clearTimeout(timeout);
if (state.responseObserver) state.responseObserver.disconnect();
// Prefer the full assistant turn container (article) which holds images/content
const container =
latestMessage.closest('article[data-turn="assistant"]') || latestMessage;
state.lastResponseElement = container;
resolve(container);
}
}
};
checkForNewResponse();
state.responseObserver = new MutationObserver(checkForNewResponse);
state.responseObserver.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
});
});
},
extractResponseText: (responseElement) => {
if (!responseElement) return '';
const contentSelectors = ['.markdown', '.prose', '[data-message-id]', '.whitespace-pre-wrap'];
for (const selector of contentSelectors) {
const contentElement = responseElement.querySelector(selector);
if (contentElement) return contentElement.textContent.trim();
}
return responseElement.textContent.trim();
},
// Extract image URLs from an assistant response element
extractResponseImages: (responseElement) => {
if (!responseElement) return [];
const urls = new Set();
try {
// Search within the assistant article scope
const scope =
responseElement.closest && responseElement.closest('article[data-turn="assistant"]')
? responseElement.closest('article[data-turn="assistant"]')
: responseElement;
// Get all generated images, excluding blurred ones
scope.querySelectorAll('div[id^="image-"] img[alt="Generated image"]').forEach((img) => {
const src = img.getAttribute('src');
// Skip blurred backdrop images (they have blur-2xl or scale-110 in their parent)
const isBlurred = img.closest('.blur-2xl') || img.closest('.scale-110');
if (src && !isBlurred) {
utils.log('🖼️ Found image: ' + src);
urls.add(src);
}
});
} catch (e) {
utils.log('❌ Error in extractResponseImages: ' + e.message, 'error');
}
return Array.from(urls);
},
};
// UI Creation
const createUI = () => {
state.isDarkMode = utils.detectDarkMode();
// Main container
ui.mainContainer = document.createElement('div');
ui.mainContainer.id = 'chatgpt-automation-ui';
ui.mainContainer.className = state.isDarkMode ? 'dark-mode' : 'light-mode';
ui.mainContainer.innerHTML = /*html*/ `<div class="automation-header" id="automation-header">
<h3>ChatGPT Automation Pro</h3>
<div class="header-controls">
<div
class="mini-progress"
id="mini-progress"
style="display: none"
title="Batch progress"
>
<div class="mini-bar"><div class="mini-fill" id="mini-fill"></div></div>
<div class="mini-label" id="mini-label">0/0</div>
</div>
<div
class="mini-progress"
id="mini-sub-progress"
style="display: none"
title="Inner batch progress"
>
<div class="mini-bar">
<div class="mini-fill" id="mini-sub-fill"></div>
</div>
<div class="mini-label" id="mini-sub-label">0/0</div>
</div>
<div class="status-indicator" id="status-indicator">
<span class="status-dot"></span>
<span class="status-text">Ready</span>
</div>
<button
class="header-btn"
id="header-log-toggle"
title="Show/Hide Log"
aria-label="Show/Hide Log"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 5h16v2H4V5zm0 6h16v2H4v-2zm0 6h10v2H4v-2z" />
</svg>
</button>
<button
class="header-btn"
id="minimize-btn"
title="Minimize"
aria-label="Minimize"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 12h12v2H6z" />
</svg>
</button>
<button class="header-btn" id="close-btn" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path
d="M18.3 5.71L12 12.01L5.7 5.71L4.29 7.12L10.59 13.42L4.29 19.72L5.7 21.13L12 14.83L18.3 21.13L19.71 19.72L13.41 13.42L19.71 7.12L18.3 5.71Z"
/>
</svg>
</button>
</div>
</div>
<div class="automation-content" id="automation-content">
<div class="progress-container" id="progress-container" style="display: none">
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<div class="progress-text">0/0</div>
<div
class="progress-bar sub"
id="progress-container-sub"
style="display: none; margin-top: 6px"
>
<div class="progress-fill"></div>
</div>
<div class="progress-text sub" id="progress-text-sub" style="display: none">
0/0
</div>
</div>
<div class="automation-form">
<div class="tab-container">
<button class="tab-btn active" data-tab="composer">Composer</button>
<button class="tab-btn" data-tab="settings">Settings</button>
</div>
<div class="tab-content active" id="composer-tab">
<div class="form-group">
<label>Composer Canvas:</label>
<div class="composer-presets">
<div class="preset-row">
<input
type="text"
id="composer-preset-name-input"
class="settings-input"
placeholder="Preset name"
style="flex: 1"
/>
<select
id="composer-preset-select"
class="settings-input"
style="flex: 2"
>
<option value="">Select preset...</option>
</select>
<button
class="btn btn-secondary"
id="save-composer-preset-btn"
title="Save current configuration"
>
<svg width="14" height="14" viewBox="0 0 448 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/></svg>
</button>
<button
class="btn btn-primary"
id="load-composer-preset-btn"
title="Load selected preset"
>
<svg width="14" height="14" viewBox="0 0 576 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M56 225.6L32.4 296.2 32.4 96c0-35.3 28.7-64 64-64l138.7 0c13.8 0 27.3 4.5 38.4 12.8l38.4 28.8c5.5 4.2 12.3 6.4 19.2 6.4l117.3 0c35.3 0 64 28.7 64 64l0 16-365.4 0c-41.3 0-78 26.4-91.1 65.6zM477.8 448L99 448c-32.8 0-55.9-32.1-45.5-63.2l48-144C108 221.2 126.4 208 147 208l378.8 0c32.8 0 55.9 32.1 45.5 63.2l-48 144c-6.5 19.6-24.9 32.8-45.5 32.8z"/></svg>
</button>
<button
class="btn btn-danger"
id="delete-composer-preset-btn"
title="Delete selected preset"
>
<svg width="14" height="14" viewBox="0 0 448 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/></svg>
</button>
</div>
</div>
<div id="chain-canvas" class="chain-canvas">
<div class="chain-toolbar">
<button class="btn btn-secondary" id="add-step-btn">
Add Step
</button>
<button class="btn btn-secondary" id="validate-chain-btn">
Validate Chain
</button>
<button class="btn btn-primary" id="run-chain-btn">
Run Chain
</button>
<button
class="btn btn-danger"
id="stop-run-btn"
style="display: none"
>
Stop
</button>
</div>
<div id="chain-cards" class="chain-cards"></div>
</div>
<div class="help-text">
Visual editor for multi-step automation chains. Steps connect in
sequence; supports templates and custom JavaScript execution.
</div>
</div>
<div class="form-group">
<label for="dynamic-elements-input"
>Dynamic Elements (List, JSON, or function)</label
>
<div class="code-editor">
<div class="overlay-field">
<textarea
id="dynamic-elements-input"
rows="4"
placeholder='["item1", "item2", "item3"] or () => ["generated", "items"]'
></textarea>
<button
class="tool-btn overlay"
id="format-dyn-elements-btn"
title="Format JSON"
>
{ }
</button>
<button
class="tool-btn overlay"
id="apply-dyn-elements-btn"
style="right: 36px;"
title="Apply dynamic elements to runtime"
>
▶
</button>
</div>
</div>
</div>
<div class="form-group">
<label for="chain-json-input">Chain JSON (advanced):</label>
<div class="code-editor">
<textarea
id="chain-json-input"
rows="6"
placeholder='{
"entryId": "step-1",
"steps": [
{
"id": "step-1",
"type": "prompt",
"title": "Create message",
"template": "Hello {item}",
"next": "step-2"
},
{
"id": "step-2",
"type": "js",
"title": "Process response",
"code": "utils.log(\"Processing: \" + steps[\"step-1\"].response);"
}
]
}'
></textarea>
<div class="editor-tools">
<button
class="tool-btn"
id="format-chain-json-btn"
title="Format JSON"
>
{ }
</button>
</div>
</div>
</div>
</div>
<div class="tab-content" id="settings-tab">
<div class="form-group">
<label>Debug mode:</label>
<label class="checkbox-label">
<input type="checkbox" id="debug-mode-checkbox" />
<span class="checkmark"></span>
Enable debug logging
</label>
</div>
<div class="form-group">
<label>Batch settings:</label>
<div class="batch-controls">
<div class="batch-settings">
<label class="checkbox-label">
<input type="checkbox" id="loop-checkbox" />
<span class="checkmark"></span>
Process all items in batch
</label>
<label class="checkbox-label">
<input type="checkbox" id="auto-remove-checkbox" checked />
<span class="checkmark"></span>
Remove processed items from queue
</label>
<div class="wait-time-control">
<label for="wait-time-input">Wait between items (ms):</label>
<input
type="number"
id="wait-time-input"
min="100"
max="30000"
value="2000"
step="100"
/>
</div>
<div class="wait-time-control">
<label for="step-wait-input">Wait between steps (ms):</label>
<input
type="number"
id="step-wait-input"
min="0"
max="30000"
value="0"
step="100"
/>
</div>
</div>
<div class="batch-actions">
<button
id="stop-batch-btn"
class="btn btn-danger"
style="display: none"
>
Stop Batch
</button>
</div>
</div>
</div>
<div class="form-group">
<label for="response-timeout-input">Response timeout (ms):</label>
<input
type="number"
id="response-timeout-input"
min="10000"
max="6000000"
step="1000"
class="settings-input timeout"
/>
</div>
<div class="form-group">
<label>Panel size limits (px):</label>
<div class="size-inputs-grid"></div>
</div>
<div class="form-group">
<label>Visibility:</label>
<label class="checkbox-label">
<input type="checkbox" id="default-visible-checkbox" />
<span class="checkmark"></span>
Show panel by default
</label>
<div class="help-text">
Controls default visibility on page load. You can still toggle from
the header button.
</div>
</div>
</div>
</div>
<div class="automation-log" id="log-container">
<div class="log-header">
<span>Activity Log</span>
<div class="log-header-controls">
<button class="tool-btn" id="stop-mini-btn" title="Stop" style="display: none">
<svg width="14" height="14" viewBox="0 0 448 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M64 32C77.3 32 88 42.7 88 56v400c0 13.3-10.7 24-24 24H40c-21.2 0-39.2-17.9-39.2-39.2V71.2C.8 50 18.8 32 40 32h24z"/></svg>
</button>
<button
class="tool-btn"
id="toggle-auto-scroll-btn"
title="Toggle Auto-scroll"
>
<svg width="14" height="14" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M64 256c0 106 86 192 192 192s192-86 192-192S362 64 256 64 64 150 64 256zm160-48h224v32H224v-32z"/></svg>
</button>
<button class="tool-btn" id="clear-log-btn" title="Clear Log">
<svg width="14" height="14" viewBox="0 0 448 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/></svg>
</button>
</div>
</div>
<div class="log-content"></div>
</div>
</div>
<div class="resize-handle" id="resize-handle"></div>
<!-- Modal for editing a chain step -->
<div
id="chain-step-modal"
class="chain-modal"
aria-hidden="true"
style="display: none"
>
<div class="chain-modal-backdrop"></div>
<div
class="chain-modal-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="chain-step-title"
>
<div class="chain-modal-header">
<h4 id="chain-step-title">Edit Step</h4>
<div class="step-modal-presets">
<select
id="step-preset-select"
class="settings-input"
style="min-width: 120px"
>
<option value="">Select preset...</option>
</select>
<button
class="tool-btn"
id="save-step-preset-btn"
title="Save as preset"
>
<svg width="14" height="14" viewBox="0 0 448 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/></svg>
</button>
<button
class="tool-btn"
id="delete-step-preset-btn"
title="Delete preset"
>
<svg width="14" height="14" viewBox="0 0 448 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/></svg>
</button>
</div>
<button class="header-btn" id="close-step-modal-btn" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path
d="M18.3 5.71L12 12.01L5.7 5.71L4.29 7.12L10.59 13.42L4.29 19.72L5.7 21.13L12 14.83L18.3 21.13L19.71 19.72L13.41 13.42L19.71 7.12L18.3 5.71Z"
/>
</svg>
</button>
</div>
<div class="chain-modal-body">
<div class="form-group">
<label for="step-id-input">ID</label>
<input id="step-id-input" class="settings-input" placeholder="step-1" />
</div>
<div class="form-group">
<label for="step-title-input">Title</label>
<input
id="step-title-input"
class="settings-input"
placeholder="Describe the step"
/>
</div>
<div class="form-group">
<label for="step-type-select">Type</label>
<select id="step-type-select" class="settings-input">
<option value="prompt">Prompt</option>
<option value="template">Template (Batch)</option>
<option value="js">JavaScript</option>
<option value="http">HTTP Request</option>
</select>
</div>
<div class="form-group" data-field="prompt">
<label for="step-response-type">Response Type</label>
<select id="step-response-type" class="settings-input">
<option value="text">Text</option>
<option value="image">Image</option>
</select>
<label class="checkbox-label" style="margin-top: 6px; display: block">
<input type="checkbox" id="step-newchat-checkbox" />
<span class="checkmark"></span>
Open in new chat before this step
</label>
</div>
<div class="form-group" data-field="prompt">
<label for="step-prompt-template">Message Template</label>
<textarea
id="step-prompt-template"
rows="4"
class="settings-input"
placeholder="Send a message to ChatGPT. Use {steps.stepId.response} to access previous step data."
></textarea>
<div class="help-text">
Access previous step data: {steps.stepId.response} for prompts,
{steps.stepId.data} for HTTP, {steps.stepId.status} for HTTP status
</div>
</div>
<div class="form-group" data-field="template">
<label for="step-template-input">Message Template</label>
<textarea
id="step-template-input"
rows="4"
class="settings-input"
placeholder="Template with placeholders like {{item}}, {{index}}, {{total}} or {steps.stepId.data}..."
></textarea>
<label for="step-template-elements" style="margin-top: 8px"
>Dynamic Elements (JSON/function). Supports {placeholders}.</label
>
<div class="overlay-field">
<textarea
id="step-template-elements"
rows="3"
class="settings-input"
placeholder='["item1", "item2", "item3"] or () => ["generated", "items"]'
></textarea>
<button
class="tool-btn overlay"
id="format-step-elements-btn"
title="Format JSON"
>
{ }
</button>
</div>
<label class="checkbox-label" style="margin-top: 6px; display: block">
<input type="checkbox" id="step-use-dynamicelements-checkbox" />
<span class="checkmark"></span>
Use chain.dynamicElements as elements
</label>
<div class="help-text">
Batch processing: {{item}} for current item, {steps.stepId.response}
for previous step data
</div>
</div>
<div class="form-group" data-field="http">
<label>HTTP Request</label>
<input
id="step-http-url"
class="settings-input"
placeholder="https://api.example.com/data or {steps.stepId.data.apiUrl}"
/>
<div style="display: flex; gap: 8px; margin-top: 6px">
<select id="step-http-method" class="settings-input">
<option>GET</option>
<option>POST</option>
<option>PUT</option>
<option>DELETE</option>
</select>
<div class="overlay-field">
<input
id="step-http-headers"
class="settings-input"
placeholder='{"Authorization": "Bearer {steps.authStep.data.token}"}'
/>
<button
class="tool-btn overlay"
id="format-http-headers-btn"
title="Format JSON"
>
{ }
</button>
</div>
</div>
<div class="overlay-field">
<textarea
id="step-http-body"
rows="3"
class="settings-input"
placeholder="Request body: {steps.stepId.response} or JSON data"
></textarea>
<button
class="tool-btn overlay"
id="format-http-body-btn"
title="Format JSON"
>
{ }
</button>
</div>
<div class="help-text">
Access response with {steps.thisStepId.data} or
{steps.thisStepId.status}. Use previous step data in URL/headers/body.
</div>
</div>
<div class="form-group" data-field="js">
<label for="step-js-code">JavaScript Code</label>
<textarea
id="step-js-code"
rows="6"
class="settings-input"
placeholder="// Access previous steps with steps.stepId.data or steps.stepId.response
// Available: response, log, console, item, index, total, http, steps, lastResponse
// Example: utils.log('API response:', steps.httpStep.data);"
></textarea>
<div class="help-text">
Access step data with <code>steps.stepId.data</code> or
<code>steps.stepId.response</code>. Use <code>http</code> for API
calls, <code>utils.log()</code> for output.
</div>
</div>
<div class="form-group">
<label for="step-next-select">Next step</label>
<select id="step-next-select" class="settings-input"></select>
</div>
</div>
<div class="chain-modal-footer">
<button class="btn btn-secondary" id="delete-step-btn">Delete</button>
<button class="btn btn-primary" id="save-step-btn">Save</button>
</div>
</div>
</div>
`;
// Add styles with ChatGPT-inspired design (guard against duplicates)
let style = document.getElementById('chatgpt-automation-style');
if (!style) {
style = document.createElement('style');
style.id = 'chatgpt-automation-style';
style.textContent = /*css*/ `/* Base styles that adapt to ChatGPT's theme (scoped) */
#chatgpt-automation-ui {
position: fixed;
top: 20px;
right: 20px;
height: auto;
width: auto;
background: var(--main-surface-primary, #ffffff);
border: 1px solid var(--border-medium, rgba(0, 0, 0, 0.1));
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
font-family: var(
--font-family,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif
);
z-index: 10000;
resize: both;
overflow: hidden;
backdrop-filter: blur(10px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
/* Theme-driven accent colors sourced from ChatGPT theme tokens */
--accent: var(--theme-entity-accent, #6366f1);
--accent-strong: color-mix(in oklab, var(--accent) 85%, #000);
}
#chatgpt-automation-ui.dark-mode {
background: var(--main-surface-primary, #2d2d30);
border-color: var(--border-medium, rgba(255, 255, 255, 0.1));
color: var(--text-primary, #ffffff);
}
#chatgpt-automation-ui.minimized {
resize: both;
height: 46px;
width: 600px;
}
#chatgpt-automation-ui.minimized.log-open {
height: 300px;
}
/* Hide main form when minimized */
#chatgpt-automation-ui.minimized .automation-form {
display: none;
}
/* Keep progress container visible state-controlled; mini bars appear in header */
/* Base content layout mirrors minimized behavior */
#chatgpt-automation-ui .automation-content {
display: flex;
flex-direction: column;
min-height: 0;
height: calc(100% - 60px); /* Header height offset */
}
#chatgpt-automation-ui .automation-header {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
color: white;
padding: 12px 16px;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
cursor: move;
user-select: none;
}
#chatgpt-automation-ui .automation-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
flex: 1;
}
#chatgpt-automation-ui .header-controls {
display: flex;
align-items: center;
gap: 12px;
}
#chatgpt-automation-ui .mini-progress {
display: flex;
align-items: center;
gap: 6px;
min-width: 80px;
}
#chatgpt-automation-ui .mini-progress .mini-bar {
width: 60px;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
overflow: hidden;
}
#chatgpt-automation-ui .mini-progress .mini-fill {
height: 100%;
background: var(--accent);
width: 0%;
transition: width 0.3s ease;
}
#chatgpt-automation-ui .mini-progress .mini-label {
font-size: 10px;
opacity: 0.85;
}
#chatgpt-automation-ui .status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
opacity: 0.9;
}
#chatgpt-automation-ui .status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #10b981;
animation: pulse-idle 2s infinite;
}
#chatgpt-automation-ui .header-btn {
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 4px;
padding: 4px;
color: white;
cursor: pointer;
transition: background 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
#chatgpt-automation-ui .header-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
#chatgpt-automation-ui .automation-content {
display: flex;
flex-direction: column;
overflow: hidden;
/* Allow children to shrink/scroll correctly inside flex */
min-height: 0;
-webkit-overflow-scrolling: touch;
}
#chatgpt-automation-ui .progress-container {
padding: 12px 16px;
border-bottom: 1px solid var(--border-light, rgba(0, 0, 0, 0.06));
background: var(--surface-secondary, #f8fafc);
}
/* Hide main progress container when minimized (header mini bars take over) */
#chatgpt-automation-ui.minimized .progress-container {
display: none !important;
}
#chatgpt-automation-ui.dark-mode .progress-container {
background: var(--surface-secondary, #1e1e20);
border-color: var(--border-light, rgba(255, 255, 255, 0.06));
}
#chatgpt-automation-ui .progress-bar {
width: 100%;
height: 4px;
background: var(--border-light, rgba(0, 0, 0, 0.1));
border-radius: 2px;
overflow: hidden;
margin-bottom: 4px;
}
#chatgpt-automation-ui .progress-bar.sub {
background: var(--border-light, rgba(0, 0, 0, 0.1));
}
#chatgpt-automation-ui .progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.3s ease;
}
#chatgpt-automation-ui .progress-text {
font-size: 11px;
color: var(--text-secondary, #6b7280);
text-align: center;
}
#chatgpt-automation-ui .progress-text.sub {
opacity: 0.8;
}
#chatgpt-automation-ui .automation-form {
padding: 16px;
/* Keep natural height so logs fill remaining space */
flex: 0 0 auto;
overflow: auto;
}
#chatgpt-automation-ui .tab-container {
display: flex;
border-bottom: 1px solid var(--border-light, rgba(0, 0, 0, 0.06));
margin-bottom: 16px;
}
#chatgpt-automation-ui.dark-mode .tab-container {
border-color: var(--border-light, rgba(255, 255, 255, 0.06));
}
#chatgpt-automation-ui .tab-btn {
background: none;
border: none;
padding: 8px 16px;
cursor: pointer;
color: var(--text-secondary, #6b7280);
font-size: 13px;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
#chatgpt-automation-ui .tab-btn.active {
color: var(--accent);
border-color: var(--accent);
}
#chatgpt-automation-ui .tab-content {
display: none;
}
#chatgpt-automation-ui .tab-content.active {
display: block;
}
#chatgpt-automation-ui .form-group {
margin-bottom: 16px;
}
#chatgpt-automation-ui .form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: var(--text-primary, #374151);
font-size: 13px;
}
#chatgpt-automation-ui.dark-mode .form-group label {
color: var(--text-primary, #f3f4f6);
}
#chatgpt-automation-ui .form-group textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-medium, rgba(0, 0, 0, 0.1));
border-radius: 8px;
font-size: 13px;
resize: vertical;
font-family: "SF Mono", "Monaco", "Menlo", "Ubuntu Mono", monospace;
box-sizing: border-box;
background: var(--input-background, #ffffff);
color: var(--text-primary, #374151);
transition: border-color 0.2s, box-shadow 0.2s;
}
#chatgpt-automation-ui.dark-mode .form-group textarea {
background: var(--input-background, #1e1e20);
color: var(--text-primary, #f3f4f6);
border-color: var(--border-medium, rgba(255, 255, 255, 0.1));
}
#chatgpt-automation-ui .form-group textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 25%, transparent);
}
#chatgpt-automation-ui .code-editor {
position: relative;
}
#chatgpt-automation-ui .editor-tools {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
#chatgpt-automation-ui .code-editor:hover .editor-tools {
opacity: 1;
}
#chatgpt-automation-ui .tool-btn {
background: var(--surface-secondary, rgba(0, 0, 0, 0.05));
border: none;
border-radius: 4px;
padding: 4px 6px;
font-size: 10px;
cursor: pointer;
color: var(--text-secondary, #6b7280);
transition: background 0.2s;
}
#chatgpt-automation-ui .tool-btn:hover {
background: var(--surface-secondary, rgba(0, 0, 0, 0.1));
}
#chatgpt-automation-ui .help-text {
font-size: 11px;
color: var(--text-secondary, #6b7280);
margin-top: 4px;
font-style: italic;
}
#chatgpt-automation-ui .batch-controls {
margin-top: 12px;
padding: 12px;
background: var(--surface-secondary, #f8fafc);
border-radius: 6px;
}
#chatgpt-automation-ui.dark-mode .batch-controls {
background: var(--surface-secondary, #1e1e20);
}
#chatgpt-automation-ui .batch-settings {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
#chatgpt-automation-ui .batch-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
#chatgpt-automation-ui .wait-time-control {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
#chatgpt-automation-ui .wait-time-control label {
font-size: 12px;
margin: 0;
white-space: nowrap;
color: var(--text-primary, #374151);
}
#chatgpt-automation-ui.dark-mode .wait-time-control label {
color: var(--text-primary, #f3f4f6);
}
#chatgpt-automation-ui .wait-time-control input[type="number"] {
width: 80px;
padding: 4px 8px;
border: 1px solid var(--border-medium, rgba(0, 0, 0, 0.1));
border-radius: 4px;
font-size: 12px;
background: var(--input-background, #ffffff);
color: var(--text-primary, #374151);
}
#chatgpt-automation-ui.dark-mode .wait-time-control input[type="number"] {
background: var(--input-background, #1e1e20);
color: var(--text-primary, #f3f4f6);
border-color: var(--border-medium, rgba(255, 255, 255, 0.1));
}
#chatgpt-automation-ui .wait-time-control input[type="number"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 25%, transparent);
}
/* Settings input styles */
#chatgpt-automation-ui .settings-input {
padding: 6px 8px;
border: 1px solid var(--border-medium, rgba(0, 0, 0, 0.1));
border-radius: 6px;
font-size: 13px;
background: var(--input-background, #ffffff);
color: var(--text-primary, #374151);
}
#chatgpt-automation-ui.dark-mode .settings-input {
background: var(--input-background, #1e1e20);
color: var(--text-primary, #f3f4f6);
border-color: var(--border-medium, rgba(255, 255, 255, 0.1));
}
#chatgpt-automation-ui .settings-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 25%, transparent);
}
#chatgpt-automation-ui .settings-input.timeout {
width: 140px;
}
/* Overlay format button on hover */
#chatgpt-automation-ui .overlay-field {
position: relative;
}
#chatgpt-automation-ui .overlay-field .overlay {
position: absolute;
right: 6px;
top: 6px;
opacity: 0;
transition: opacity 0.15s ease;
}
#chatgpt-automation-ui .overlay-field:hover .overlay {
opacity: 1;
}
#chatgpt-automation-ui .size-inputs-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
#chatgpt-automation-ui .size-input-group {
display: flex;
flex-direction: column;
gap: 4px;
}
#chatgpt-automation-ui .size-input-group label {
font-size: 12px;
margin: 0;
color: var(--text-primary, #374151);
}
#chatgpt-automation-ui.dark-mode .size-input-group label {
color: var(--text-primary, #f3f4f6);
}
#chatgpt-automation-ui .settings-input.size {
width: 120px;
}
#chatgpt-automation-ui .checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
font-size: 13px;
color: var(--text-primary, #374151);
}
#chatgpt-automation-ui.dark-mode .checkbox-label {
color: var(--text-primary, #f3f4f6);
}
#chatgpt-automation-ui .checkbox-label input[type="checkbox"] {
margin-right: 8px;
}
#chatgpt-automation-ui .form-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 16px;
}
#chatgpt-automation-ui .btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
position: relative;
}
#chatgpt-automation-ui .btn-primary {
background: var(--accent);
color: white;
}
#chatgpt-automation-ui .btn-primary:hover {
background: var(--accent-strong);
}
#chatgpt-automation-ui .btn-secondary {
background: var(--surface-secondary, #f3f4f6);
color: var(--text-primary, #374151);
border: 1px solid var(--border-light, rgba(0, 0, 0, 0.06));
}
#chatgpt-automation-ui.dark-mode .btn-secondary {
background: var(--surface-secondary, #1e1e20);
color: var(--text-primary, #f3f4f6);
border-color: var(--border-light, rgba(255, 255, 255, 0.06));
}
#chatgpt-automation-ui .btn-secondary:hover {
background: var(--surface-secondary, #e5e7eb);
}
#chatgpt-automation-ui.dark-mode .btn-secondary:hover {
background: var(--surface-secondary, #2a2a2d);
}
#chatgpt-automation-ui .btn-danger {
background: #ef4444;
color: white;
}
#chatgpt-automation-ui .btn-danger:hover {
background: #dc2626;
}
#chatgpt-automation-ui .btn-warning {
background: #f59e0b;
color: white;
}
#chatgpt-automation-ui .btn-warning:hover {
background: #d97706;
}
#chatgpt-automation-ui .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#chatgpt-automation-ui .spinner {
width: 12px;
height: 12px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
#chatgpt-automation-ui .automation-log {
border-top: 1px solid var(--border-light, rgba(0, 0, 0, 0.06));
/* Base height when expanded; can be resized */
height: 150px;
flex: 1 1 auto;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
#chatgpt-automation-ui.dark-mode .automation-log {
border-color: var(--border-light, rgba(255, 255, 255, 0.06));
}
#chatgpt-automation-ui .log-header {
padding: 12px 16px;
background: var(--surface-secondary, #f8fafc);
font-weight: 500;
font-size: 13px;
color: var(--text-primary, #374151);
display: flex;
justify-content: space-between;
align-items: center;
}
#chatgpt-automation-ui.dark-mode .log-header {
background: var(--surface-secondary, #1e1e20);
color: var(--text-primary, #f3f4f6);
}
#chatgpt-automation-ui .log-header-controls {
display: flex;
gap: 4px;
}
#chatgpt-automation-ui .log-content {
padding: 16px;
overflow-y: auto;
scroll-behavior: smooth;
flex: 1 1 auto;
min-height: 0;
}
#chatgpt-automation-ui #step-next-select {
width: 100%;
}
#chatgpt-automation-ui .log-entry {
padding: 6px 0;
font-size: 11px;
font-family: "SF Mono", "Monaco", "Menlo", "Ubuntu Mono", monospace;
border-bottom: 1px solid var(--border-light, rgba(0, 0, 0, 0.03));
line-height: 1.4;
}
#chatgpt-automation-ui .log-entry:last-child {
border-bottom: none;
margin-bottom: 6px; /* extra space below last entry */
}
#chatgpt-automation-ui .log-info {
color: var(--text-primary, #374151);
}
#chatgpt-automation-ui.dark-mode .log-info {
color: var(--text-primary, #d1d5db);
}
#chatgpt-automation-ui .log-warning {
color: #f59e0b;
}
#chatgpt-automation-ui .log-error {
color: #ef4444;
}
#chatgpt-automation-ui .resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 20px;
height: 20px;
cursor: nw-resize;
background: linear-gradient(
-45deg,
transparent 0%,
transparent 40%,
var(--border-medium, rgba(0, 0, 0, 0.1)) 40%,
var(--border-medium, rgba(0, 0, 0, 0.1)) 60%,
transparent 60%,
transparent 100%
);
}
/* Chain canvas styles */
#chatgpt-automation-ui .chain-canvas {
border: 1px dashed var(--border-light, rgba(0, 0, 0, 0.1));
border-radius: 8px;
padding: 8px;
min-height: 120px;
}
#chatgpt-automation-ui .chain-toolbar {
display: flex;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
#chatgpt-automation-ui .chain-cards {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: flex-start;
min-height: 80px;
}
/* Empty chain cards container */
#chatgpt-automation-ui .chain-cards:empty {
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-secondary, #f8fafc);
border: 2px dashed var(--border-medium, rgba(0, 0, 0, 0.15));
border-radius: 12px;
padding: 32px 16px;
text-align: center;
color: var(--text-secondary, #6b7280);
font-size: 14px;
transition: all 0.2s ease;
}
#chatgpt-automation-ui.dark-mode .chain-cards:empty {
background: var(--surface-secondary, #1e1e20);
border-color: var(--border-medium, rgba(255, 255, 255, 0.15));
color: var(--text-secondary, #9ca3af);
}
#chatgpt-automation-ui .chain-cards:empty::before {
content: "🔗 No steps yet. Click 'Add Step' to start building your automation chain.";
font-weight: 500;
}
#chatgpt-automation-ui .chain-card {
background: var(--surface-secondary, #f8fafc);
border: 1px solid var(--border-light, rgba(0, 0, 0, 0.06));
border-radius: 8px;
padding: 8px;
min-width: 140px;
max-width: 200px;
position: relative;
transition: all 0.2s ease;
}
#chatgpt-automation-ui .chain-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
#chatgpt-automation-ui.dark-mode .chain-card {
background: var(--surface-secondary, #1e1e20);
border-color: var(--border-light, rgba(255, 255, 255, 0.06));
}
#chatgpt-automation-ui.dark-mode .chain-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
#chatgpt-automation-ui .chain-card .title {
font-weight: 600;
font-size: 12px;
margin-bottom: 4px;
}
#chatgpt-automation-ui .chain-card .meta {
font-size: 11px;
opacity: 0.8;
margin-bottom: 6px;
}
#chatgpt-automation-ui .chain-card .actions {
display: flex;
gap: 6px;
}
/* Composer presets */
#chatgpt-automation-ui .composer-presets {
margin-bottom: 12px;
padding: 8px;
background: var(--surface-secondary, #f8fafc);
border-radius: 8px;
border: 1px solid var(--border-light, rgba(0, 0, 0, 0.06));
}
#chatgpt-automation-ui.dark-mode .composer-presets {
background: var(--surface-secondary, #1e1e20);
border-color: var(--border-light, rgba(255, 255, 255, 0.06));
}
#chatgpt-automation-ui .composer-presets .preset-row {
display: flex;
gap: 8px;
align-items: center;
}
/* Modal */
#chatgpt-automation-ui .chain-modal {
position: fixed;
inset: 0;
z-index: 10001;
}
#chatgpt-automation-ui .chain-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.3);
}
#chatgpt-automation-ui .chain-modal-dialog {
position: relative;
background: var(--main-surface-primary, #fff);
width: 520px;
max-width: calc(100% - 32px);
margin: 40px auto;
border-radius: 10px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
#chatgpt-automation-ui.dark-mode .chain-modal-dialog {
background: var(--main-surface-primary, #2d2d30);
}
#chatgpt-automation-ui .chain-modal-dialog .header-btn {
/* Ensure the close button inside the modal inherits modal text color
and uses a transparent background so it's visible in light mode */
background: transparent;
color: inherit;
padding: 6px;
border: none;
}
#chatgpt-automation-ui .chain-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border-light, rgba(0, 0, 0, 0.06));
gap: 12px;
}
#chatgpt-automation-ui .step-modal-presets {
display: flex;
gap: 8px;
align-items: center;
}
#chatgpt-automation-ui .chain-modal-body {
padding: 12px 16px;
max-height: 60vh;
overflow: auto;
}
#chatgpt-automation-ui .chain-modal-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
padding: 12px 16px;
border-top: 1px solid var(--border-light, rgba(0, 0, 0, 0.06));
}
#chatgpt-automation-ui .presets-grid .preset-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
/* Responsive design */
@media (max-width: 768px) {
#chatgpt-automation-ui {
width: 320px;
right: 10px;
top: 10px;
}
}
/* Animation keyframes */
@keyframes pulse-idle {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes pulse-processing {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.2);
}
}
#chatgpt-automation-ui .status-processing .status-dot {
background: #f59e0b;
animation: pulse-processing 1s infinite;
}
#chatgpt-automation-ui .status-waiting .status-dot {
background: #3b82f6;
animation: pulse-processing 1.5s infinite;
}
#chatgpt-automation-ui .status-complete .status-dot {
background: #10b981;
animation: none;
}
#chatgpt-automation-ui .status-error .status-dot {
background: #ef4444;
animation: pulse-processing 0.5s infinite;
}
`;
document.head.appendChild(style);
}
document.body.appendChild(ui.mainContainer);
// Get UI elements
ui.statusIndicator = document.getElementById('status-indicator');
ui.logContainer = document.querySelector('.log-content');
ui.progressBar = document.getElementById('progress-container');
ui.progressBarSub = document.getElementById('progress-container-sub');
ui.miniProgress = document.getElementById('mini-progress');
ui.miniFill = document.getElementById('mini-fill');
ui.miniLabel = document.getElementById('mini-label');
ui.miniSubProgress = document.getElementById('mini-sub-progress');
ui.miniSubFill = document.getElementById('mini-sub-fill');
ui.miniSubLabel = document.getElementById('mini-sub-label');
ui.resizeHandle = document.getElementById('resize-handle');
// Restore saved inputs, toggles and config
try {
// Chain JSON restored later with parsing
// Checkboxes and switches
const loopEl = document.getElementById('loop-checkbox');
const autoRemoveEl = document.getElementById('auto-remove-checkbox');
if (loopEl) {
loopEl.checked = !!GM_getValue(STORAGE_KEYS.loop, true);
state.isLooping = loopEl.checked;
}
if (autoRemoveEl) {
autoRemoveEl.checked = GM_getValue(STORAGE_KEYS.autoRemove, true);
state.autoRemoveProcessed = autoRemoveEl.checked;
}
// Auto-scroll state (button only, no checkbox)
state.autoScrollLogs = GM_getValue(STORAGE_KEYS.autoScroll, true);
// Wait time
const waitInput = document.getElementById('wait-time-input');
const savedWait = parseInt(GM_getValue(STORAGE_KEYS.waitTime, state.batchWaitTime));
if (!Number.isNaN(savedWait)) {
state.batchWaitTime = savedWait;
if (waitInput) waitInput.value = String(savedWait);
}
// Per-step wait time
const stepWaitInput = document.getElementById('step-wait-input');
const savedStepWait = parseInt(GM_getValue(STORAGE_KEYS.stepWaitTime, 0));
if (stepWaitInput && !Number.isNaN(savedStepWait)) {
stepWaitInput.value = String(savedStepWait);
}
// Active tab
const savedTab = GM_getValue(STORAGE_KEYS.activeTab, 'composer');
const tabBtn = document.querySelector(`.tab-btn[data-tab="${savedTab}"]`);
if (tabBtn) {
tabBtn.click();
} else {
// Fallback to composer if saved tab doesn't exist
const composerBtn = document.querySelector(`.tab-btn[data-tab="composer"]`);
if (composerBtn) composerBtn.click();
}
// Config - apply saved values and reflect in UI
const dbgVal = !!GM_getValue(STORAGE_KEYS.configDebug, CONFIG.DEBUG_MODE);
CONFIG.DEBUG_MODE = dbgVal;
const dbgEl = document.getElementById('debug-mode-checkbox');
if (dbgEl) dbgEl.checked = dbgVal;
const toVal = parseInt(GM_getValue(STORAGE_KEYS.configTimeout, CONFIG.RESPONSE_TIMEOUT));
if (!Number.isNaN(toVal)) CONFIG.RESPONSE_TIMEOUT = toVal;
const toEl = document.getElementById('response-timeout-input');
if (toEl) toEl.value = String(CONFIG.RESPONSE_TIMEOUT);
const defVis = !!GM_getValue(STORAGE_KEYS.configDefaultVisible, CONFIG.DEFAULT_VISIBLE);
CONFIG.DEFAULT_VISIBLE = defVis;
const dvEl = document.getElementById('default-visible-checkbox');
if (dvEl) dvEl.checked = defVis;
// Chain definition
const savedChain = GM_getValue(STORAGE_KEYS.chainDef, '');
const chainInput = document.getElementById('chain-json-input');
if (savedChain && chainInput) {
chainInput.value =
typeof savedChain === 'string' ? savedChain : JSON.stringify(savedChain, null, 2);
try {
state.chainDefinition = JSON.parse(chainInput.value);
} catch {
state.chainDefinition = null;
}
}
} catch {}
// Load saved state
const savedState = uiState.load();
if (savedState.left) {
ui.mainContainer.style.left = savedState.left;
ui.mainContainer.style.right = 'auto';
}
if (savedState.top) {
ui.mainContainer.style.top = savedState.top;
}
if (savedState.minimized) {
state.isMinimized = true;
ui.mainContainer.classList.add('minimized');
}
// Respect explicit persisted visibility over default
if (typeof savedState.visible === 'boolean') {
state.uiVisible = savedState.visible;
} else {
state.uiVisible = !!CONFIG.DEFAULT_VISIBLE;
}
ui.mainContainer.style.display = state.uiVisible ? 'block' : 'none';
// Restore persisted log history
try {
const hist = GM_getValue(STORAGE_KEYS.logHistory, []);
if (Array.isArray(hist) && hist.length && ui.logContainer) {
hist.slice(-200).forEach((h) => {
const div = document.createElement('div');
div.className = `log-entry log-${h.type || 'info'}`;
div.textContent = h.msg;
ui.logContainer.appendChild(div);
});
ui.logContainer.scrollTop = ui.logContainer.scrollHeight;
}
} catch {}
// Bind events
bindEvents();
// Initialize auto-scroll button state
const autoScrollBtn = document.getElementById('toggle-auto-scroll-btn');
if (autoScrollBtn && typeof state.autoScrollLogs === 'boolean') {
autoScrollBtn.style.opacity = state.autoScrollLogs ? '1' : '0.5';
autoScrollBtn.title = state.autoScrollLogs ? 'Auto-scroll: ON' : 'Auto-scroll: OFF';
}
// Watch for theme changes
const observer = new MutationObserver(() => {
const newDarkMode = utils.detectDarkMode();
if (newDarkMode !== state.isDarkMode) {
state.isDarkMode = newDarkMode;
ui.mainContainer.className = state.isDarkMode ? 'dark-mode' : 'light-mode';
if (state.isMinimized) ui.mainContainer.classList.add('minimized');
}
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme'],
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class', 'data-theme'],
});
// Add persistent header launcher
mountHeaderLauncher();
startHeaderObserver();
utils.log('UI initialized successfully');
};
// Header launcher utilities
const createLauncherButton = () => {
const btn = document.createElement('button');
btn.id = 'chatgpt-automation-launcher';
btn.type = 'button';
btn.title = 'Open Automation';
btn.setAttribute('aria-label', 'Open Automation');
btn.className = 'btn relative btn-ghost text-token-text-primary';
btn.innerHTML = `<div class="flex w-full items-center justify-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="20" height="20" fill="currentColor" class="icon"><path d="M273 151.1L288 171.8L303 151.1C328 116.5 368.2 96 410.9 96C484.4 96 544 155.6 544 229.1L544 231.7C544 249.3 540.6 267.3 534.5 285.4C512.7 276.8 488.9 272 464 272C358 272 272 358 272 464C272 492.5 278.2 519.6 289.4 544C288.9 544 288.5 544 288 544C272.5 544 257.2 539.4 244.9 529.9C171.9 474.2 32 343.9 32 231.7L32 229.1C32 155.6 91.6 96 165.1 96C207.8 96 248 116.5 273 151.1zM320 464C320 384.5 384.5 320 464 320C543.5 320 608 384.5 608 464C608 543.5 543.5 608 464 608C384.5 608 320 543.5 320 464zM497.4 387C491.6 382.8 483.6 383 478 387.5L398 451.5C392.7 455.7 390.6 462.9 392.9 469.3C395.2 475.7 401.2 480 408 480L440.9 480L425 522.4C422.5 529.1 424.8 536.7 430.6 541C436.4 545.3 444.4 545 450 540.5L530 476.5C535.3 472.3 537.4 465.1 535.1 458.7C532.8 452.3 526.8 448 520 448L487.1 448L503 405.6C505.5 398.9 503.2 391.3 497.4 387z"/></svg><span class="max-md:hidden">Automation</span></div>`;
btn.addEventListener('click', () => {
// If UI was removed by a re-render, recreate it
let panel = document.getElementById('chatgpt-automation-ui');
if (!panel) {
createUI();
panel = document.getElementById('chatgpt-automation-ui');
}
if (!panel) return;
const show = panel.style.display === 'none';
panel.style.display = show ? 'block' : 'none';
ui.mainContainer = panel;
state.uiVisible = show;
saveUIState();
});
return btn;
};
const mountHeaderLauncher = () => {
const header = document.getElementById('page-header');
if (!header) return false;
let target = header.querySelector('#conversation-header-actions');
if (!target) target = header;
if (!target.querySelector('#chatgpt-automation-launcher')) {
const btn = createLauncherButton();
target.appendChild(btn);
}
// Also ensure the UI exists if it should be visible
const savedState = uiState.load();
const shouldShow =
savedState.visible === true || (savedState.visible == null && CONFIG.DEFAULT_VISIBLE);
if (shouldShow && !document.getElementById('chatgpt-automation-ui')) {
createUI();
}
return true;
};
const startHeaderObserver = () => {
if (state.headerObserverStarted) return;
state.headerObserverStarted = true;
const ensure = () => {
try {
// Recreate launcher if missing
mountHeaderLauncher();
// Ensure UI matches persisted visibility
const savedState = uiState.load();
const panel = document.getElementById('chatgpt-automation-ui');
const shouldShow =
savedState.visible === true || (savedState.visible == null && CONFIG.DEFAULT_VISIBLE);
if (panel) {
panel.style.display = shouldShow ? 'block' : 'none';
} else if (shouldShow) {
createUI();
}
} catch (e) {
/* noop */
}
};
ensure();
const obs = new MutationObserver(() => ensure());
obs.observe(document.body, { childList: true, subtree: true });
};
const updateStatus = (status) => {
if (!ui.statusIndicator) return;
const statusTexts = {
idle: 'Ready',
processing: 'Typing...',
waiting: 'Waiting for response...',
complete: 'Complete',
error: 'Error',
};
ui.statusIndicator.className = `status-indicator status-${status}`;
const textEl = ui.statusIndicator.querySelector('.status-text');
if (textEl) textEl.textContent = statusTexts[status] || 'Unknown';
};
const updateProgress = (done, total) => {
// Use header mini progress as single source of truth. Keep in-panel progress hidden.
if (!ui.miniProgress || !ui.miniFill || !ui.miniLabel) return;
const show = total > 0;
// Hide the in-panel progress container entirely (not used)
try {
if (ui.progressBar) ui.progressBar.style.display = 'none';
} catch {}
ui.miniProgress.style.display = show ? 'flex' : 'none';
if (!show) {
ui.miniFill.style.width = '0%';
ui.miniLabel.textContent = '0/0';
return;
}
const pct = total ? Math.round((done / total) * 100) : 0;
ui.miniFill.style.width = pct + '%';
ui.miniLabel.textContent = `${done}/${total}`;
};
const updateSubProgress = (done, total) => {
// Use header mini sub-progress as single source of truth. Keep in-panel sub progress hidden.
if (!ui.miniSubProgress || !ui.miniSubFill || !ui.miniSubLabel) return;
const show = total > 0;
try {
if (ui.progressBarSub) ui.progressBarSub.style.display = 'none';
const subText = document.getElementById('progress-text-sub');
if (subText) subText.style.display = 'none';
} catch {}
ui.miniSubProgress.style.display = show ? 'flex' : 'none';
if (!show) {
ui.miniSubFill.style.width = '0%';
ui.miniSubLabel.textContent = '0/0';
return;
}
const pct = total ? Math.round((done / total) * 100) : 0;
ui.miniSubFill.style.width = pct + '%';
ui.miniSubLabel.textContent = `${done}/${total}`;
};
// Unified progress helper that clamps values and drives header mini bars
const refreshBatchProgress = (doneLike, totalLike) => {
const total = Math.max(0, Number(totalLike || 0));
const done = Math.max(0, Math.min(Number(doneLike || 0), total));
updateProgress(done, total);
return { done, total };
};
// Safely remove N items from the head of dynamicElements, keep JSON/textarea in sync
const removeHeadItems = (count = 1) => {
if (!Array.isArray(state.dynamicElements) || count <= 0) return;
try {
state.dynamicElements.splice(0, count);
} catch {}
try {
if (!state.chainDefinition) {
const txt = document.getElementById('chain-json-input')?.value || '{}';
state.chainDefinition = JSON.parse(txt);
}
state.chainDefinition.dynamicElements = state.dynamicElements;
const chainInput = document.getElementById('chain-json-input');
if (chainInput) chainInput.value = JSON.stringify(state.chainDefinition, null, 2);
} catch {}
try {
const dynEl = document.getElementById('dynamic-elements-input');
if (dynEl) dynEl.value = JSON.stringify(state.dynamicElements, null, 2);
} catch {}
};
// Allow canceling long runs
const stopBatchProcessing = () => {
state.cancelRequested = true;
utils.log('Stop requested');
};
// Function to start a new chat (language independent, uses data-testid)
const startNewChat = async () => {
utils.log('Starting new chat...');
const btn = document.querySelector('a[data-testid="create-new-chat-button"]');
if (btn) {
utils.log('Using new chat button...');
btn.click();
await utils.sleep(1000);
return true;
}
const homeLink = document.querySelector('a[href="/"]');
if (homeLink && (homeLink.textContent || '').trim() !== '') {
utils.log('Using home link...');
homeLink.click();
await utils.sleep(1000);
return true;
}
utils.log('Failed to start a new chat', 'warning');
return false;
};
const bindEvents = () => {
// Tab switching
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const tabName = btn.dataset.tab;
// Update active tab button
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
// Update active tab content
document
.querySelectorAll('.tab-content')
.forEach((content) => content.classList.remove('active'));
document.getElementById(`${tabName}-tab`).classList.add('active');
// Persist active tab
utils.saveToStorage(STORAGE_KEYS.activeTab, tabName);
});
});
// Stop batch button
document.getElementById('stop-batch-btn').addEventListener('click', () => {
stopBatchProcessing();
document.getElementById('stop-batch-btn').style.display = 'none';
});
// Auto-remove processed items checkbox
document.getElementById('auto-remove-checkbox').addEventListener('change', (e) => {
state.autoRemoveProcessed = e.target.checked;
utils.log(
`Auto-remove processed items: ${state.autoRemoveProcessed ? 'enabled' : 'disabled'}`
);
utils.saveToStorage(STORAGE_KEYS.autoRemove, state.autoRemoveProcessed);
});
// Wait time input
document.getElementById('wait-time-input').addEventListener('change', (e) => {
const value = parseInt(e.target.value);
if (value >= 0 && value <= 30000) {
state.batchWaitTime = value;
utils.log(`Wait time between items set to ${value}ms`);
utils.saveToStorage(STORAGE_KEYS.waitTime, state.batchWaitTime);
} else {
e.target.value = state.batchWaitTime;
utils.log('Invalid wait time, keeping current value', 'warning');
}
});
// Per-step wait time input
const stepWaitEl = document.getElementById('step-wait-input');
if (stepWaitEl) {
stepWaitEl.addEventListener('change', (e) => {
const value = parseInt(e.target.value);
if (value >= 0 && value <= 30000) {
utils.saveToStorage(STORAGE_KEYS.stepWaitTime, value);
utils.log(`Wait time between steps set to ${value}ms`);
} else {
const saved = parseInt(GM_getValue(STORAGE_KEYS.stepWaitTime, 0));
e.target.value = String(!Number.isNaN(saved) ? saved : 0);
utils.log('Invalid per-step wait time, keeping current value', 'warning');
}
});
}
const toggleLogVisibility = () => {
const logWrap = document.getElementById('log-container');
if (!logWrap) return;
const currentlyHidden = logWrap.style.display === 'none';
const willShow = currentlyHidden;
logWrap.style.display = willShow ? 'flex' : 'none';
// Toggle class on main container so CSS can adapt minimized height
if (ui.mainContainer) {
ui.mainContainer.classList.toggle('log-open', willShow);
}
utils.saveToStorage(STORAGE_KEYS.logVisible, willShow);
};
document.getElementById('header-log-toggle').addEventListener('click', toggleLogVisibility);
// Clear log button
document.getElementById('clear-log-btn').addEventListener('click', () => {
if (ui.logContainer) ui.logContainer.innerHTML = '';
utils.saveToStorage(STORAGE_KEYS.logHistory, []);
utils.log('Log cleared');
});
// Stop button in minimized header
document.getElementById('stop-mini-btn').addEventListener('click', () => {
stopBatchProcessing();
const stopRunBtn = document.getElementById('stop-run-btn');
if (stopRunBtn) stopRunBtn.style.display = 'none';
const stopBtn = document.getElementById('stop-batch-btn');
if (stopBtn) stopBtn.style.display = 'none';
const stopMini = document.getElementById('stop-mini-btn');
if (stopMini) stopMini.style.display = 'none';
});
// Toggle auto-scroll button
document.getElementById('toggle-auto-scroll-btn').addEventListener('click', () => {
state.autoScrollLogs = !state.autoScrollLogs;
const btn = document.getElementById('toggle-auto-scroll-btn');
btn.style.opacity = state.autoScrollLogs ? '1' : '0.5';
btn.title = state.autoScrollLogs ? 'Auto-scroll: ON' : 'Auto-scroll: OFF';
utils.log(`Auto-scroll logs: ${state.autoScrollLogs ? 'enabled' : 'disabled'}`);
if (state.autoScrollLogs && ui.logContainer)
ui.logContainer.scrollTop = ui.logContainer.scrollHeight;
utils.saveToStorage(STORAGE_KEYS.autoScroll, state.autoScrollLogs);
});
document.getElementById('minimize-btn').addEventListener('click', () => {
state.isMinimized = !state.isMinimized;
if (state.isMinimized) {
// Save previous explicit height if present
ui.mainContainer.classList.add('minimized');
} else {
ui.mainContainer.classList.remove('minimized');
ui.mainContainer.style.height = '';
// after finishing resize and saving state
isResizing = false;
// allow CSS/auto layout to reclaim sizing by removing inline size overrides
if (ui.mainContainer) {
ui.mainContainer.style.removeProperty('width');
ui.mainContainer.style.removeProperty('height');
}
saveUIState(true);
}
saveUIState(true); // Immediate save for user action
});
// Close button
document.getElementById('close-btn').addEventListener('click', () => {
ui.mainContainer.style.display = 'none';
state.uiVisible = false;
saveUIState(true); // Immediate save for user action
utils.log('UI closed');
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Ctrl/Cmd + Enter to send
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
const sendBtn = document.getElementById('send-btn');
if (sendBtn) sendBtn.click();
e.preventDefault();
}
// Escape to minimize
if (e.key === 'Escape') {
document.getElementById('minimize-btn').click();
e.preventDefault();
}
});
// Dragging functionality
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
// Resizing functionality
let isResizing = false;
let resizeStartX, resizeStartY, resizeStartWidth, resizeStartHeight;
const header = document.getElementById('automation-header');
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.header-btn')) return; // Don't drag when clicking buttons
isDragging = true;
const rect = ui.mainContainer.getBoundingClientRect();
dragOffset.x = e.clientX - rect.left;
dragOffset.y = e.clientY - rect.top;
header.style.userSelect = 'none';
e.preventDefault();
});
ui.resizeHandle.addEventListener('mousedown', (e) => {
isResizing = true;
resizeStartX = e.clientX;
resizeStartY = e.clientY;
resizeStartWidth = ui.mainContainer.offsetWidth;
resizeStartHeight = ui.mainContainer.offsetHeight;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
const x = e.clientX - dragOffset.x;
const y = e.clientY - dragOffset.y;
ui.mainContainer.style.left = `${Math.max(0, Math.min(x, window.innerWidth - ui.mainContainer.offsetWidth))}px`;
ui.mainContainer.style.top = `${Math.max(0, Math.min(y, window.innerHeight - ui.mainContainer.offsetHeight))}px`;
ui.mainContainer.style.right = 'auto';
saveUIState(); // Debounced for drag operations
} else if (isResizing) {
// Clamp resizing to reasonable window bounds (sizes are automatic)
const rawWidth = resizeStartWidth + (e.clientX - resizeStartX);
const rawHeight = resizeStartHeight + (e.clientY - resizeStartY);
const newWidth = Math.max(200, Math.min(window.innerWidth, rawWidth));
const newHeight = Math.max(120, Math.min(window.innerHeight, rawHeight));
ui.mainContainer.style.width = `${newWidth}px`;
ui.mainContainer.style.height = `${newHeight}px`;
saveUIState(); // Debounced for resize operations
}
});
document.addEventListener('mouseup', () => {
if (isDragging) {
saveUIState(true); // Immediate save when drag ends
isDragging = false;
header.style.userSelect = '';
}
if (isResizing) {
saveUIState(true); // Immediate save when resize ends
isResizing = false;
}
});
// Persist loop checkbox when used
const loopEl = document.getElementById('loop-checkbox');
loopEl.addEventListener('change', (e) => {
state.isLooping = e.target.checked;
utils.saveToStorage(STORAGE_KEYS.loop, state.isLooping);
});
// Settings: Debug mode
const debugEl = document.getElementById('debug-mode-checkbox');
if (debugEl) {
debugEl.addEventListener('change', (e) => {
CONFIG.DEBUG_MODE = !!e.target.checked;
utils.saveToStorage(STORAGE_KEYS.configDebug, CONFIG.DEBUG_MODE);
utils.log(`Debug mode ${CONFIG.DEBUG_MODE ? 'enabled' : 'disabled'}`);
});
}
// Settings: Response timeout
const timeoutEl = document.getElementById('response-timeout-input');
if (timeoutEl) {
timeoutEl.addEventListener('change', (e) => {
const v = parseInt(e.target.value);
if (!Number.isNaN(v) && v >= 10000 && v <= 6000000) {
CONFIG.RESPONSE_TIMEOUT = v;
utils.saveToStorage(STORAGE_KEYS.configTimeout, v);
utils.log(`Response timeout set to ${v}ms`);
} else {
e.target.value = String(CONFIG.RESPONSE_TIMEOUT);
utils.log('Invalid response timeout', 'warning');
}
});
}
// Settings: default visible
const defVisEl = document.getElementById('default-visible-checkbox');
if (defVisEl)
defVisEl.addEventListener('change', (e) => {
CONFIG.DEFAULT_VISIBLE = !!e.target.checked;
try {
GM_setValue(STORAGE_KEYS.configDefaultVisible, CONFIG.DEFAULT_VISIBLE);
} catch {}
// If user disables default visibility and UI wasn't explicitly opened, keep current visibility but don't force-open later
utils.log(`Default visibility ${CONFIG.DEFAULT_VISIBLE ? 'ON' : 'OFF'}`);
});
// Restore log visibility
try {
const logWrap = document.getElementById('log-container');
const vis = GM_getValue(STORAGE_KEYS.logVisible, false);
if (logWrap) {
logWrap.style.display = vis ? 'flex' : 'none';
}
if (ui.mainContainer) {
ui.mainContainer.classList.toggle('log-open', !!vis);
}
} catch {}
// Chain UI: basic actions
const chainInput = document.getElementById('chain-json-input');
const sampleItemsEl = document.getElementById('dynamic-elements-input');
const chainCards = document.getElementById('chain-cards');
const refreshChainCards = () => {
if (!chainCards) return;
chainCards.innerHTML = '';
let chain;
try {
chain = JSON.parse(chainInput.value || '{}');
// Update global state.chainDefinition when parsing JSON
state.chainDefinition = chain;
} catch {
chain = null;
state.chainDefinition = null;
}
// Reflect dynamicElements in the dedicated textarea
if (
chain &&
(Array.isArray(chain.dynamicElements) || typeof chain.dynamicElements === 'string') &&
sampleItemsEl
) {
try {
sampleItemsEl.value = JSON.stringify(chain.dynamicElements, null, 2);
} catch {
// if dynamicElements is a function string, show raw
try {
sampleItemsEl.value = String(chain.dynamicElements);
} catch {}
}
}
if (!chain || !Array.isArray(chain.steps) || chain.steps.length === 0) {
// Chain cards will show empty state due to CSS :empty selector
return;
}
chain.steps.forEach((step) => {
const card = document.createElement('div');
card.className = 'chain-card';
card.dataset.stepId = step.id;
const typeDisplay =
step.type === 'template'
? 'Template (Batch)'
: step.type === 'js'
? 'JavaScript'
: step.type === 'prompt'
? 'Prompt'
: step.type === 'http'
? 'HTTP Request'
: step.type;
card.innerHTML = `
<div class="title">${step.title || step.id || '(untitled)'}</div>
<div class="meta">type: ${typeDisplay}${step.next ? ` → ${step.next}` : ''}</div>
<div class="actions">
<button class="btn btn-secondary btn-sm" data-action="edit" title="Edit step"><svg width="14" height="14" viewBox="0 0 640 640" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M100.4 417.2C104.5 402.6 112.2 389.3 123 378.5L304.2 197.3L338.1 163.4C354.7 180 389.4 214.7 442.1 267.4L476 301.3L442.1 335.2L260.9 516.4C250.2 527.1 236.8 534.9 222.2 539L94.4 574.6C86.1 576.9 77.1 574.6 71 568.4C64.9 562.2 62.6 553.3 64.9 545L100.4 417.2zM156 413.5C151.6 418.2 148.4 423.9 146.7 430.1L122.6 517L209.5 492.9C215.9 491.1 221.7 487.8 226.5 483.2L155.9 413.5zM510 267.4C493.4 250.8 458.7 216.1 406 163.4L372 129.5C398.5 103 413.4 88.1 416.9 84.6C430.4 71 448.8 63.4 468 63.4C487.2 63.4 505.6 71 519.1 84.6L554.8 120.3C568.4 133.9 576 152.3 576 171.4C576 190.5 568.4 209 554.8 222.5C551.3 226 536.4 240.9 509.9 267.4z"/></svg></button>
<button class="btn btn-danger btn-sm" data-action="delete" title="Delete step"><svg width="14" height="14" viewBox="0 0 448 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/></svg></button>
</div>
`;
card
.querySelector('[data-action="edit"]')
.addEventListener('click', () => openStepEditor(step.id));
card.querySelector('[data-action="delete"]').addEventListener('click', () => {
if (confirm(`Delete step "${step.title || step.id}"?`)) {
chain.steps = chain.steps.filter((s) => s.id !== step.id);
// Remove references to this step
chain.steps.forEach((s) => {
if (s.next === step.id) s.next = '';
});
// Update entry point if needed
if (chain.entryId === step.id) {
chain.entryId = chain.steps.length > 0 ? chain.steps[0].id : '';
}
chainInput.value = JSON.stringify(chain, null, 2);
utils.saveToStorage(STORAGE_KEYS.chainDef, chainInput.value);
refreshChainCards();
utils.log(`Step "${step.title || step.id}" deleted`);
}
});
chainCards.appendChild(card);
});
};
const openStepEditor = (stepId) => {
let chain;
try {
chain = JSON.parse(chainInput.value || '{}');
} catch {
chain = { steps: [] };
}
if (!Array.isArray(chain.steps)) chain.steps = [];
let step = chain.steps.find((s) => s.id === stepId);
if (!step) {
step = { id: stepId || `step-${Date.now()}`, type: 'prompt', title: '', template: '' };
chain.steps.push(step);
}
const modal = document.getElementById('chain-step-modal');
modal.style.display = 'block';
modal.setAttribute('aria-hidden', 'false');
// Populate fields
document.getElementById('step-id-input').value = step.id || '';
document.getElementById('step-title-input').value = step.title || '';
document.getElementById('step-type-select').value = step.type || 'prompt';
// Per-step options
const respTypeSel = document.getElementById('step-response-type');
if (respTypeSel) respTypeSel.value = step.responseType || 'text';
const newChatCb = document.getElementById('step-newchat-checkbox');
if (newChatCb) newChatCb.checked = !!step.newChat;
// Prompt content
const promptEl = document.getElementById('step-prompt-template');
if (promptEl) promptEl.value = step.template || step.content || step.message || '';
// Template fields
document.getElementById('step-template-input').value = step.template || '';
const stepElementsEl = document.getElementById('step-template-elements');
stepElementsEl.value = step.elements || '';
const useSamplesCb = document.getElementById('step-use-dynamicelements-checkbox');
// Override-but-restore: when checked, populate the step elements from chain.dynamicElements
// and disable editing; when unchecked, restore the previous per-step value.
if (useSamplesCb) {
useSamplesCb.checked = !!step.useDynamicElements;
// Replace any existing handler to avoid duplicates
useSamplesCb.onchange = (e) => {
try {
if (e.target.checked) {
// Backup current step value so it can be restored later
try {
modal.dataset.backupStepElements = stepElementsEl.value || '';
} catch {}
// Populate from chain.dynamicElements (prefer chain parsed from editor)
try {
if (
chain &&
(Array.isArray(chain.dynamicElements) ||
typeof chain.dynamicElements === 'string')
) {
try {
stepElementsEl.value = JSON.stringify(chain.dynamicElements, null, 2);
} catch {
stepElementsEl.value = String(chain.dynamicElements);
}
} else {
stepElementsEl.value = '';
}
} catch {}
stepElementsEl.disabled = true;
} else {
// Restore backed-up value (if any) and re-enable editing
try {
const bak = modal.dataset.backupStepElements;
stepElementsEl.value = bak != null ? bak : step.elements || '';
delete modal.dataset.backupStepElements;
} catch {
stepElementsEl.value = step.elements || '';
}
stepElementsEl.disabled = false;
}
} catch (err) {
utils.log('Failed to toggle useDynamicElements: ' + err.message, 'error');
}
};
// Initialize UI state according to the checkbox
if (useSamplesCb.checked) {
// Trigger handler to populate from chain
useSamplesCb.dispatchEvent(new Event('change'));
} else {
stepElementsEl.disabled = false;
}
}
// Prompt
document.getElementById('step-prompt-template').value = step.template || '';
// HTTP fields
document.getElementById('step-http-url').value = step.url || '';
document.getElementById('step-http-method').value = (step.method || 'GET').toUpperCase();
document.getElementById('step-http-headers').value = step.headers
? JSON.stringify(step.headers)
: '';
document.getElementById('step-http-body').value = step.bodyTemplate || '';
// JavaScript
document.getElementById('step-js-code').value = step.code || '';
// Populate next step selector with auto-suggestion
const nextSel = document.getElementById('step-next-select');
nextSel.innerHTML = '<option value="">(end)</option>';
const currentIndex = chain.steps.findIndex((s) => s.id === step.id);
chain.steps.forEach((s, index) => {
if (s.id !== step.id) {
// Don't include self
const opt = document.createElement('option');
opt.value = s.id;
const labelParts = [s.id];
if (s.title) labelParts.push('— ' + s.title);
if (s.type) labelParts.push('(' + s.type + ')');
opt.textContent = labelParts.join(' ');
if (step.next === s.id) {
opt.selected = true;
} else if (!step.next && index === currentIndex + 1) {
// Auto-suggest next sequential step
opt.selected = true;
step.next = s.id;
}
nextSel.appendChild(opt);
}
});
const onTypeChange = () => {
const type = document.getElementById('step-type-select').value;
// Clear all fields first when type changes to prevent contamination
if (step.type && step.type !== type) {
// Clear previous type's fields from the step object and UI
delete step.template;
delete step.elements;
delete step.code;
delete step.url;
delete step.method;
delete step.headers;
delete step.bodyTemplate;
delete step.message;
// Clear form inputs
const clear = (id) => {
const el = document.getElementById(id);
if (el) el.value = id === 'step-http-method' ? 'GET' : '';
};
[
'step-prompt-template',
'step-template-input',
'step-template-elements',
'step-js-code',
'step-http-url',
'step-http-headers',
'step-http-body',
'step-http-method',
].forEach(clear);
// Clear form inputs
document.getElementById('step-prompt-template').value = '';
document.getElementById('step-template-input').value = '';
document.getElementById('step-template-elements').value = '';
document.getElementById('step-js-code').value = '';
document.getElementById('step-http-url').value = '';
document.getElementById('step-http-method').value = 'GET';
document.getElementById('step-http-headers').value = '';
document.getElementById('step-http-body').value = '';
}
// Update step type
step.type = type;
// Toggle field groups based on step type
modal
.querySelectorAll('[data-field="prompt"]')
.forEach((el) => (el.style.display = type === 'prompt' ? 'block' : 'none'));
modal
.querySelectorAll('[data-field="template"]')
.forEach((el) => (el.style.display = type === 'template' ? 'block' : 'none'));
modal
.querySelectorAll('[data-field="http"]')
.forEach((el) => (el.style.display = type === 'http' ? 'block' : 'none'));
modal
.querySelectorAll('[data-field="js"]')
.forEach((el) => (el.style.display = type === 'js' ? 'block' : 'none'));
};
document.getElementById('step-type-select').onchange = onTypeChange;
onTypeChange();
const saveBtn = document.getElementById('save-step-btn');
const deleteBtn = document.getElementById('delete-step-btn');
const closeBtn = document.getElementById('close-step-modal-btn');
const closeModal = () => {
modal.style.display = 'none';
modal.setAttribute('aria-hidden', 'true');
};
closeBtn.onclick = closeModal;
deleteBtn.onclick = () => {
if (confirm(`Delete step "${step.title || step.id}"?`)) {
chain.steps = chain.steps.filter((s) => s.id !== step.id);
// Remove references
chain.steps.forEach((s) => {
if (s.next === step.id) s.next = '';
});
// Update entry point if needed
if (chain.entryId === step.id) {
chain.entryId = chain.steps.length > 0 ? chain.steps[0].id : '';
}
chainInput.value = JSON.stringify(chain, null, 2);
utils.saveToStorage(STORAGE_KEYS.chainDef, chainInput.value);
refreshChainCards();
closeModal();
utils.log(`Step "${step.title || step.id}" deleted`);
}
};
saveBtn.onclick = () => {
const newId = document.getElementById('step-id-input').value.trim() || step.id;
const oldId = step.id;
step.id = newId;
step.title = document.getElementById('step-title-input').value.trim();
step.type = document.getElementById('step-type-select').value;
step.next = document.getElementById('step-next-select').value;
// Clear all type-specific fields first
delete step.template;
delete step.elements;
delete step.code;
delete step.url;
delete step.method;
delete step.headers;
delete step.bodyTemplate;
delete step.message;
delete step.responseType;
delete step.newChat;
delete step.useDynamicElements;
// Save type-specific fields based on current type
if (step.type === 'template') {
step.template = document.getElementById('step-template-input').value;
step.elements = document.getElementById('step-template-elements').value;
step.useDynamicElements = !!document.getElementById('step-use-dynamicelements-checkbox')
?.checked;
} else if (step.type === 'prompt') {
step.template = document.getElementById('step-prompt-template').value;
step.responseType = document.getElementById('step-response-type')?.value || 'text';
step.newChat = !!document.getElementById('step-newchat-checkbox')?.checked;
} else if (step.type === 'http') {
step.url = document.getElementById('step-http-url').value.trim();
step.method = document.getElementById('step-http-method').value.trim();
try {
const headerText = document.getElementById('step-http-headers').value.trim();
step.headers = headerText ? JSON.parse(headerText) : {};
} catch {
step.headers = {};
}
step.bodyTemplate = document.getElementById('step-http-body').value;
} else if (step.type === 'js') {
step.code = document.getElementById('step-js-code').value;
}
// If ID changed, update references
if (oldId !== newId) {
chain.steps.forEach((s) => {
if (s.next === oldId) s.next = newId;
});
if (chain.entryId === oldId) chain.entryId = newId;
}
chainInput.value = JSON.stringify(chain, null, 2);
utils.saveToStorage(STORAGE_KEYS.chainDef, chainInput.value);
refreshChainCards();
closeModal();
utils.log(`Step "${step.title || step.id}" saved`);
// Note: preset save is handled by the dedicated icon in the popup
};
};
const addStepBtn = document.getElementById('add-step-btn');
if (addStepBtn)
addStepBtn.addEventListener('click', () => {
let chain;
try {
chain = JSON.parse(chainInput.value || '{}');
} catch {
chain = {};
}
if (!chain.steps) chain.steps = [];
const id = `step-${(chain.steps.length || 0) + 1}`;
const newStep = {
id,
title: `Step ${chain.steps.length + 1}`,
type: 'prompt',
template: '',
};
// Auto-link the previous step if it doesn't have a next
if (chain.steps.length > 0) {
const lastStep = chain.steps[chain.steps.length - 1];
if (!lastStep.next) {
lastStep.next = id;
}
}
chain.steps.push(newStep);
if (!chain.entryId) chain.entryId = id;
chainInput.value = JSON.stringify(chain, null, 2);
utils.saveToStorage(STORAGE_KEYS.chainDef, chainInput.value);
refreshChainCards();
// Open editor and default to "Select preset"
openStepEditor(id);
// Reset the preset selector to show "Select preset..."
setTimeout(() => {
const presetSelect = document.getElementById('step-preset-select');
if (presetSelect) presetSelect.value = '';
}, 100);
});
const validateChainBtn = document.getElementById('validate-chain-btn');
if (validateChainBtn)
validateChainBtn.addEventListener('click', () => {
// Ensure log is visible when validating for better feedback
try {
const logWrap = document.getElementById('log-container');
if (logWrap && logWrap.style.display === 'none') {
logWrap.style.display = 'flex';
if (ui.mainContainer) ui.mainContainer.classList.add('log-open');
utils.saveToStorage(STORAGE_KEYS.logVisible, true);
}
} catch {}
try {
const c = JSON.parse(chainInput.value || '{}');
if (!c.entryId) throw new Error('Missing entryId');
if (!Array.isArray(c.steps) || !c.steps.length) throw new Error('No steps');
const ids = new Set(c.steps.map((s) => s.id));
if (!ids.has(c.entryId)) throw new Error('entryId not found among steps');
c.steps.forEach((s) => {
if (s.next && !ids.has(s.next))
throw new Error(`Step ${s.id} next '${s.next}' not found`);
});
utils.log('Chain valid');
} catch (e) {
utils.log('Chain invalid: ' + e.message, 'error');
}
});
const runChainBtn = document.getElementById('run-chain-btn');
const stopRunBtn = document.getElementById('stop-run-btn');
if (stopRunBtn) {
stopRunBtn.addEventListener('click', () => {
stopBatchProcessing();
stopRunBtn.style.display = 'none';
});
}
if (runChainBtn)
runChainBtn.addEventListener('click', async () => {
// When running, load whatever is currently in the dynamic elements textarea
try {
const dynEl = document.getElementById('dynamic-elements-input');
let items = [];
if (dynEl) {
const raw = (dynEl.value || '').trim();
if (raw) {
if (raw.startsWith('[') || raw.startsWith('{')) {
try {
const parsed = JSON.parse(raw);
items = Array.isArray(parsed) ? parsed : [parsed];
} catch (e) {
// fallback to processor for function-style inputs
try {
const parsed = await processors.parseDynamicElements(raw);
items = Array.isArray(parsed) ? parsed : [parsed];
} catch {}
}
} else {
try {
const parsed = await processors.parseDynamicElements(raw);
items = Array.isArray(parsed) ? parsed : [parsed];
} catch {}
}
}
}
state.dynamicElements = items;
// If the new list is shorter than what we've already processed, clamp the current index
try {
if (
typeof state.currentBatchIndex === 'number' &&
state.currentBatchIndex > items.length
) {
state.currentBatchIndex = Math.max(0, items.length);
}
} catch {}
// Keep chainDefinition in sync so the JSON reflects the runtime items
try {
if (!state.chainDefinition) {
state.chainDefinition = JSON.parse(
document.getElementById('chain-json-input').value || '{}'
);
}
state.chainDefinition.dynamicElements = items;
const chainInput = document.getElementById('chain-json-input');
if (chainInput) chainInput.value = JSON.stringify(state.chainDefinition, null, 2);
} catch {}
} catch (e) {
utils.log('Failed to read dynamic elements before run: ' + e.message, 'warning');
}
if (stopRunBtn) stopRunBtn.style.display = 'inline-flex';
await runChainWithBatch();
if (stopRunBtn) stopRunBtn.style.display = 'none';
});
// Generic JSON formatter for overlay buttons
const registerJsonFormatter = (btnId, inputId, opts = {}) => {
const btn = document.getElementById(btnId);
if (!btn) return;
btn.addEventListener('click', async () => {
try {
const src = document.getElementById(inputId);
if (!src) return;
const val = (src.value || '').trim();
if (!val) return;
let parsed;
if (!opts.allowFunction && (val.startsWith('[') || val.startsWith('{')))
parsed = JSON.parse(val);
else parsed = await processors.parseDynamicElements(val);
src.value = JSON.stringify(parsed, null, 2);
utils.log(`${opts.label || 'JSON'} formatted`);
} catch (e) {
utils.log(`Invalid ${opts.label || 'value'}: ${e.message}`, 'error');
}
});
};
registerJsonFormatter('format-chain-json-btn', 'chain-json-input', {
label: 'Chain JSON',
allowFunction: false,
});
registerJsonFormatter('format-dyn-elements-btn', 'dynamic-elements-input', {
label: 'Dynamic elements',
allowFunction: true,
});
registerJsonFormatter('format-step-elements-btn', 'step-template-elements', {
label: 'Step elements',
allowFunction: true,
});
registerJsonFormatter('format-http-headers-btn', 'step-http-headers', {
label: 'HTTP headers',
allowFunction: false,
});
registerJsonFormatter('format-http-body-btn', 'step-http-body', {
label: 'HTTP body',
allowFunction: false,
});
// Change events to keep cards in sync and persist data
if (chainInput) {
chainInput.addEventListener('input', () => {
let parsed = null;
try {
parsed = JSON.parse(chainInput.value || '{}');
} catch {
/* ignore parse errors during typing */
}
if (parsed) {
state.chainDefinition = parsed;
refreshChainCards();
} else {
// if invalid, still clear cards to reflect invalid state
refreshChainCards();
}
utils.saveToStorage(STORAGE_KEYS.chainDef, chainInput.value);
});
}
// Stop auto-syncing dynamic elements on input; apply explicitly via button
const applyDynBtn = document.getElementById('apply-dyn-elements-btn');
if (applyDynBtn) {
applyDynBtn.addEventListener('click', async () => {
try {
const src = document.getElementById('dynamic-elements-input');
const raw = (src?.value || '').trim();
if (!raw) {
state.dynamicElements = [];
utils.log('Dynamic elements cleared');
try {
if (!state.chainDefinition) {
const txt = document.getElementById('chain-json-input')?.value || '{}';
state.chainDefinition = JSON.parse(txt);
}
state.chainDefinition.dynamicElements = [];
const chainInput = document.getElementById('chain-json-input');
if (chainInput) chainInput.value = JSON.stringify(state.chainDefinition, null, 2);
} catch {}
refreshBatchProgress(0, 0);
return;
}
let items;
if (raw.startsWith('[') || raw.startsWith('{')) items = JSON.parse(raw);
else items = await processors.parseDynamicElements(raw);
if (!Array.isArray(items)) items = [items];
state.dynamicElements = items;
utils.log(`Applied ${items.length} dynamic element(s) to runtime`);
try {
if (!state.chainDefinition) {
const txt = document.getElementById('chain-json-input')?.value || '{}';
state.chainDefinition = JSON.parse(txt);
}
state.chainDefinition.dynamicElements = items;
const chainInput = document.getElementById('chain-json-input');
if (chainInput) chainInput.value = JSON.stringify(state.chainDefinition, null, 2);
} catch {}
if (!state.isProcessing) refreshBatchProgress(0, items.length);
} catch (e) {
utils.log('Invalid dynamic elements: ' + e.message, 'error');
}
});
}
// Live-sync dynamic elements while running: when user edits the textarea during a run,
// parse and update state.dynamicElements and the chain JSON so the running batch reflects changes.
const dynInputEl = document.getElementById('dynamic-elements-input');
if (dynInputEl) {
dynInputEl.addEventListener('input', async (e) => {
// If not processing, do nothing — user must press Apply to change runtime by default.
if (!state.isProcessing) return;
try {
const raw = (e.target.value || '').trim();
let items = [];
if (raw) {
if (raw.startsWith('[') || raw.startsWith('{')) {
try {
const parsed = JSON.parse(raw);
items = Array.isArray(parsed) ? parsed : [parsed];
} catch {
try {
const parsed = await processors.parseDynamicElements(raw);
items = Array.isArray(parsed) ? parsed : [parsed];
} catch {}
}
} else {
try {
const parsed = await processors.parseDynamicElements(raw);
items = Array.isArray(parsed) ? parsed : [parsed];
} catch {}
}
}
// Replace live items but preserve already-processed count by removing leading items
// that were already processed when appropriate. Simpler approach: replace full list.
state.dynamicElements = items;
// Update chain JSON representation for visibility
try {
if (!state.chainDefinition)
state.chainDefinition = JSON.parse(
document.getElementById('chain-json-input').value || '{}'
);
state.chainDefinition.dynamicElements = items;
const chainInput = document.getElementById('chain-json-input');
if (chainInput) chainInput.value = JSON.stringify(state.chainDefinition, null, 2);
} catch {}
utils.log(`Runtime dynamic elements updated (${items.length} items) while running`);
// Refresh header progress: denominator = processed so far + remaining items
const done = Math.max(0, Number(state.processedCount || 0));
refreshBatchProgress(Math.min(done, done + items.length), done + items.length);
} catch (err) {
utils.log('Failed to live-apply dynamic elements: ' + err.message, 'error');
}
});
}
refreshChainCards();
// Presets: populate selects and wire buttons (normalized storage)
const loadPresetSelects = () => {
// Steps presets
let stepsMapRaw = GM_getValue(STORAGE_KEYS.presetsSteps, {});
let stepsMap = {};
try {
stepsMap = typeof stepsMapRaw === 'string' ? JSON.parse(stepsMapRaw) : stepsMapRaw || {};
} catch {
stepsMap = {};
}
const defaultSteps = {
'Get Weather': {
type: 'http',
url: 'https://wttr.in/{item}?format=j1',
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
'Extract Data': {
type: 'js',
code: 'const raw = steps.weather?.rawText ?? steps.weather?.data;\nconst data = typeof raw === "string" ? JSON.parse(raw) : raw;\nconst tempC = Number(data?.current_condition?.[0]?.temp_C);\nutils.log("Temperature °C:", tempC);\nreturn isNaN(tempC) ? null : tempC;',
},
'Ask ChatGPT': {
type: 'prompt',
template: 'Explain the implications of the temperature {steps.extractData.response} K.',
},
'Basic Prompt': {
type: 'prompt',
template: 'Please analyze {item} and provide 3 key insights.',
},
'API Call': {
type: 'http',
url: 'https://jsonplaceholder.typicode.com/posts/{item}',
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
'Reddit .json': {
type: 'http',
url: 'https://www.reddit.com/.json',
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
'Process JSON': {
type: 'js',
code: 'const raw = steps.apiCall?.rawText ?? steps.apiCall?.data;\nconst data = typeof raw === "string" ? JSON.parse(raw) : raw;\nutils.log("Post title:", data?.title);\nreturn data?.title;',
},
};
Object.entries(defaultSteps).forEach(([name, preset]) => {
if (!Object.prototype.hasOwnProperty.call(stepsMap, name)) stepsMap[name] = preset;
});
try {
GM_setValue(STORAGE_KEYS.presetsSteps, stepsMap);
} catch {}
// Chains presets
let chainsMapRaw = GM_getValue(STORAGE_KEYS.presetsChains, {});
let chainsMap = {};
try {
chainsMap =
typeof chainsMapRaw === 'string' ? JSON.parse(chainsMapRaw) : chainsMapRaw || {};
} catch {
chainsMap = {};
}
const defaultChains = {
'Weather Analysis': JSON.stringify(
{
dynamicElements: ['London', 'Tokyo', 'New York'],
entryId: 'weather',
steps: [
{
id: 'weather',
type: 'http',
url: 'https://wttr.in/{item}?format=j1',
method: 'GET',
next: 'extract',
},
{
id: 'extract',
type: 'js',
code: 'const raw = steps.weather?.rawText ?? steps.weather?.data;\nconst data = typeof raw === "string" ? JSON.parse(raw) : raw;\nconst tempC = Number(data?.current_condition?.[0]?.temp_C);\nutils.log(`Weather for {item}: ${isNaN(tempC)?"n/a":tempC+"°C"}`);\nreturn isNaN(tempC) ? "Unknown" : tempC + "°C";',
next: 'chat',
},
{
id: 'chat',
type: 'prompt',
template:
'In {item}, the current temperature is {steps.extract.response}. Share a fun fact about this city.',
},
],
},
null,
2
),
'Content Research': JSON.stringify(
{
dynamicElements: ['JavaScript', 'TypeScript', 'WebAssembly'],
entryId: 'search',
steps: [
{
id: 'search',
type: 'prompt',
template: 'Research {item} and provide 3 key facts',
next: 'summarize',
},
{
id: 'summarize',
type: 'js',
code: 'const text = steps.search.response || "";\nreturn text.slice(0,200) + (text.length>200?"...":"");',
next: 'expand',
},
{
id: 'expand',
type: 'prompt',
template:
'Using this summary: {steps.summarize.response}, write a short article about {item}',
},
],
},
null,
2
),
'Simple Chain': JSON.stringify(
{
dynamicElements: ['London', 'Tokyo', 'New York'],
entryId: 'step1',
steps: [
{ id: 'step1', type: 'prompt', template: 'Tell me about {item}', next: 'step2' },
{ id: 'step2', type: 'template', template: 'Summary: {steps.step1.response}' },
],
},
null,
2
),
'Reddit JSON': JSON.stringify(
{
dynamicElements: ['javascript'],
entryId: 'redditGet',
steps: [
{
id: 'redditGet',
type: 'http',
url: 'https://www.reddit.com/.json',
method: 'GET',
next: 'logJson',
},
{
id: 'logJson',
type: 'js',
code:
'const raw = steps.redditGet?.rawText ?? steps.redditGet?.data;\n' +
'const data = typeof raw === "string" ? (function(){ try { return JSON.parse(raw); } catch(e){ return raw; } })() : raw;\n' +
'const children = Array.isArray(data?.data?.children) ? data.data.children : [];\n' +
'const posts = children.slice(0,10).map(c => { const d = c.data || {}; return { title: d.title, author: d.author, subreddit: d.subreddit, score: d.score, num_comments: d.num_comments, id: d.id, url: d.url }; });\n' +
'const summary = { kind: data?.kind || "Listing", topPosts: posts };\n' +
'log(`Prepared reddit summary with ${posts.length} posts`);\n' +
'return JSON.stringify(summary);',
next: 'summarize',
},
{
id: 'summarize',
type: 'prompt',
template:
'I have a compact reddit summary: {steps.logJson.response}\n\nBased on this summary, what interesting insights or patterns do you observe about trending topics, engagement (score vs comments), or subreddit activity?',
},
],
},
null,
2
),
'Kanji Mnemonics': JSON.stringify(
{
dynamicElements: [
{ index: 1, kanji: '一', keyword: 'One', kanji_id: '40' },
{ index: 2, kanji: '二', keyword: 'Two', kanji_id: '41' },
{ index: 3, kanji: '三', keyword: 'Three', kanji_id: '42' },
{ index: 4, kanji: '口', keyword: 'Mouth, Entrance', kanji_id: '83' },
{
index: 6,
kanji: '四',
keyword: 'Four',
components: ['legs', 'Mouth, Entrance'],
kanji_id: '43',
},
],
entryId: 'mnemonic',
steps: [
{
id: 'mnemonic',
type: 'prompt',
template:
'Create a vivid mnemonic story for the kanji {item.kanji} meaning {item.keyword}. Components (if any): {item.components}. Respond in 1-2 lines.',
newChat: true,
next: 'imgPrompt',
},
{
id: 'imgPrompt',
type: 'prompt',
template:
'Based on this mnemonic: {steps.mnemonic.response}\\nWrite a concise visual image prompt (no prefatory text).',
newChat: true,
next: 'genImage',
},
{
id: 'genImage',
type: 'prompt',
template:
'Generate an image for this prompt: {steps.imgPrompt.response}. Return the image here in chat.',
responseType: 'image',
newChat: true,
next: 'sendToServer',
},
{
id: 'sendToServer',
type: 'http',
method: 'POST',
url: 'https://postman-echo.com/post',
headers: { 'Content-Type': 'application/json' },
bodyTemplate:
'{"kanjiId": "{item.kanji_id}", "kanji": "{item.kanji}", "mnemonic": "{steps.mnemonic.response}", "imagePrompt": "{steps.imgPrompt.response}", "imageUrl": "{steps.genImage.images[0]}"}',
},
],
},
null,
2
),
};
Object.entries(defaultChains).forEach(([name, preset]) => {
if (!Object.prototype.hasOwnProperty.call(chainsMap, name))
chainsMap[name] = typeof preset === 'string' ? preset : JSON.stringify(preset, null, 2);
});
try {
GM_setValue(STORAGE_KEYS.presetsChains, chainsMap);
} catch {}
const fill = (id, map) => {
const sel = document.getElementById(id);
if (!sel) return;
sel.innerHTML = '<option value="">Select preset...</option>';
Object.keys(map || {})
.sort()
.forEach((name) => {
const o = document.createElement('option');
o.value = name;
o.textContent = name;
sel.appendChild(o);
});
};
fill('composer-preset-select', chainsMap);
fill('step-preset-select', stepsMap);
};
const getComposerPresetName = () =>
(document.getElementById('composer-preset-name-input')?.value || '').trim();
const savePreset = (storeKey, name, value) => {
if (!name) return utils.log('Enter a preset name', 'warning');
try {
const raw = GM_getValue(storeKey, {}) || {};
const map = typeof raw === 'string' ? JSON.parse(raw) : raw;
map[name] = value;
GM_setValue(storeKey, map);
loadPresetSelects();
utils.log(`Preset "${name}" saved`);
} catch (e) {
utils.log('Save failed: ' + e.message, 'error');
}
};
const deletePreset = (storeKey, selId) => {
try {
const sel = document.getElementById(selId);
if (!sel || !sel.value) return utils.log('Select a preset to delete', 'warning');
const name = sel.value;
// Confirm with the user before deleting the selected preset/chain
if (!confirm(`Delete preset/chain "${name}"? This action cannot be undone.`)) {
utils.log(`Delete cancelled for "${name}"`, 'info');
return;
}
const raw = GM_getValue(storeKey, {}) || {};
const map = typeof raw === 'string' ? JSON.parse(raw) : raw;
delete map[name];
GM_setValue(storeKey, map);
loadPresetSelects();
utils.log(`Preset "${name}" deleted`);
} catch (e) {
utils.log('Delete failed: ' + e.message, 'error');
}
};
const loadPreset = (storeKey, selId, apply) => {
try {
const sel = document.getElementById(selId);
if (!sel || !sel.value) return utils.log('Select a preset to load', 'warning');
const raw = GM_getValue(storeKey, {}) || {};
const map = typeof raw === 'string' ? JSON.parse(raw) : raw;
const v = map[sel.value];
if (v == null) return utils.log('Preset not found', 'warning');
apply(v);
utils.log(`Preset "${sel.value}" loaded`);
} catch (e) {
utils.log('Load failed: ' + e.message, 'error');
}
};
loadPresetSelects();
// Composer preset handlers
document.getElementById('save-composer-preset-btn')?.addEventListener('click', () => {
const name = getComposerPresetName();
const chainValue = document.getElementById('chain-json-input')?.value || '';
savePreset(STORAGE_KEYS.presetsChains, name, chainValue);
});
document.getElementById('load-composer-preset-btn')?.addEventListener('click', () => {
const sel = document.getElementById('composer-preset-select');
if (sel && (!sel.value || sel.value.trim() === '')) {
const chainInput = document.getElementById('chain-json-input');
if (chainInput) {
chainInput.value = '';
state.chainDefinition = null;
utils.saveToStorage(STORAGE_KEYS.chainDef, '');
refreshChainCards();
utils.log('Cleared chain definition');
}
return;
}
loadPreset(STORAGE_KEYS.presetsChains, 'composer-preset-select', (v) => {
const chainInput = document.getElementById('chain-json-input');
if (chainInput) {
const str = typeof v === 'string' ? v : JSON.stringify(v, null, 2);
chainInput.value = str;
try {
state.chainDefinition = JSON.parse(str);
} catch {
state.chainDefinition = null;
}
utils.saveToStorage(STORAGE_KEYS.chainDef, str);
// Clear dynamic items when switching presets
state.dynamicElements = [];
const dynEl = document.getElementById('dynamic-elements-input');
if (dynEl) dynEl.value = '';
// Refresh chain cards to show the loaded chain
refreshChainCards();
}
});
});
document.getElementById('delete-composer-preset-btn')?.addEventListener('click', () => {
deletePreset(STORAGE_KEYS.presetsChains, 'composer-preset-select');
});
// Step modal preset handlers
document.getElementById('save-step-preset-btn')?.addEventListener('click', () => {
const modal = document.getElementById('chain-step-modal');
if (!modal || modal.style.display === 'none') return;
// Collect current step data
const stepData = {
type: document.getElementById('step-type-select')?.value || '',
title: document.getElementById('step-title-input')?.value || '',
template:
document.getElementById('step-template-input')?.value ||
document.getElementById('step-prompt-template')?.value ||
'',
elements: document.getElementById('step-template-elements')?.value || '',
code: document.getElementById('step-js-code')?.value || '',
url: document.getElementById('step-http-url')?.value || '',
method: document.getElementById('step-http-method')?.value || 'GET',
headers: document.getElementById('step-http-headers')?.value || '',
bodyTemplate: document.getElementById('step-http-body')?.value || '',
};
const name = prompt('Enter preset name:');
if (name) {
try {
const raw = GM_getValue(STORAGE_KEYS.presetsSteps, {}) || {};
const map = typeof raw === 'string' ? JSON.parse(raw) : raw;
map[name] = stepData;
GM_setValue(STORAGE_KEYS.presetsSteps, map);
} catch (e) {
utils.log('Failed saving step preset: ' + e.message, 'error');
}
loadPresetSelects();
}
});
document.getElementById('step-preset-select')?.addEventListener('change', (e) => {
if (!e.target.value) return;
try {
const raw = GM_getValue(STORAGE_KEYS.presetsSteps, {}) || {};
const map = typeof raw === 'string' ? JSON.parse(raw) : raw;
let stepData = map[e.target.value];
if (typeof stepData === 'string') {
try {
stepData = JSON.parse(stepData);
} catch {}
}
if (!stepData) return;
if (stepData.type) {
const typeSel = document.getElementById('step-type-select');
typeSel.value = stepData.type;
typeSel.dispatchEvent(new Event('change'));
}
if (stepData.title) document.getElementById('step-title-input').value = stepData.title;
if (stepData.template) {
// Apply to both prompt/template fields as applicable
const promptEl = document.getElementById('step-prompt-template');
const tmplEl = document.getElementById('step-template-input');
if (promptEl) promptEl.value = stepData.template;
if (tmplEl) tmplEl.value = stepData.template;
}
if (stepData.elements)
document.getElementById('step-template-elements').value = stepData.elements;
if (stepData.responseType) {
const r = document.getElementById('step-response-type');
if (r) r.value = stepData.responseType;
}
if (typeof stepData.newChat === 'boolean') {
const nc = document.getElementById('step-newchat-checkbox');
if (nc) nc.checked = !!stepData.newChat;
}
if (stepData.code) document.getElementById('step-js-code').value = stepData.code;
if (stepData.url) document.getElementById('step-http-url').value = stepData.url;
if (stepData.method) document.getElementById('step-http-method').value = stepData.method;
if (stepData.headers)
document.getElementById('step-http-headers').value =
typeof stepData.headers === 'string'
? stepData.headers
: JSON.stringify(stepData.headers);
if (stepData.bodyTemplate)
document.getElementById('step-http-body').value = stepData.bodyTemplate;
} catch (err) {
utils.log('Failed to load step preset: ' + err.message, 'error');
}
});
// Add delete step preset button handler
document.getElementById('delete-step-preset-btn')?.addEventListener('click', () => {
const select = document.getElementById('step-preset-select');
if (!select || !select.value) {
utils.log('Select a preset to delete', 'warning');
return;
}
if (confirm(`Delete preset "${select.value}"?`)) {
try {
const raw = GM_getValue(STORAGE_KEYS.presetsSteps, {}) || {};
const map = typeof raw === 'string' ? JSON.parse(raw) : raw;
delete map[select.value];
GM_setValue(STORAGE_KEYS.presetsSteps, map);
loadPresetSelects();
utils.log(`Preset "${select.value}" deleted`);
} catch (e) {
utils.log('Delete failed: ' + e.message, 'error');
}
}
});
};
// Run-lock utilities to avoid cross-tab collisions
const acquireRunLock = () => {
try {
const key = STORAGE_KEYS.runLockKey;
const now = Date.now();
const existing = localStorage.getItem(key);
const selfId =
state.runLockId || (state.runLockId = `${now}-${Math.random().toString(36).slice(2)}`);
if (existing) {
try {
const obj = JSON.parse(existing);
if (obj && obj.id && obj.ts && now - obj.ts < CONFIG.RUN_LOCK_TTL_MS) {
return false; // another tab active
}
} catch {
/* treat as stale */
}
}
localStorage.setItem(key, JSON.stringify({ id: selfId, ts: now }));
// heartbeat
clearInterval(state.runLockTimer);
state.runLockTimer = setInterval(() => {
try {
localStorage.setItem(key, JSON.stringify({ id: selfId, ts: Date.now() }));
} catch (e) {
/* ignore */
}
}, CONFIG.RUN_LOCK_RENEW_MS);
window.addEventListener('beforeunload', releaseRunLock);
return true;
} catch {
return true;
}
};
const releaseRunLock = () => {
try {
clearInterval(state.runLockTimer);
state.runLockTimer = null;
const key = STORAGE_KEYS.runLockKey;
const existing = localStorage.getItem(key);
if (existing) {
const obj = JSON.parse(existing);
if (!obj || obj.id === state.runLockId) localStorage.removeItem(key);
}
} catch (e) {
/* ignore */
}
};
const runChainWithBatch = async () => {
if (!state.chainDefinition) {
try {
state.chainDefinition = JSON.parse(
document.getElementById('chain-json-input').value || '{}'
);
} catch {
state.chainDefinition = null;
}
}
if (!state.chainDefinition) {
utils.log('No chain defined', 'warning');
return;
}
if (!acquireRunLock()) {
utils.log('Another tab is running automation - aborting to prevent collision', 'error');
return;
}
state.isProcessing = true;
updateStatus('processing');
try {
// Prefer runtime batch; if none, allow chain to provide sample items
let items = Array.isArray(state.dynamicElements) ? state.dynamicElements : [];
if (
(!items || items.length === 0) &&
(Array.isArray(state.chainDefinition?.dynamicElements) ||
typeof state.chainDefinition?.dynamicElements === 'string')
) {
items = state.chainDefinition.dynamicElements;
// If dynamicElements is a string (JSON/function), attempt to parse/execute
if (typeof items === 'string') {
try {
const parsed = await processors.parseDynamicElements(items);
if (Array.isArray(parsed)) items = parsed;
} catch {}
}
}
// Fallback: if still empty but chain references {item}, seed with sample cities
if (!items || items.length === 0) {
const usesItem = Array.isArray(state.chainDefinition?.steps)
? state.chainDefinition.steps.some((s) =>
['url', 'template', 'bodyTemplate'].some(
(k) => typeof s?.[k] === 'string' && s[k].includes('{item')
)
)
: false;
if (usesItem) {
utils.log('No dynamic elements provided; using sample items for this chain.', 'warning');
items = ['London', 'Tokyo', 'New York'];
}
}
// Use live state.dynamicElements so runtime edits affect the remaining items.
const stopBtn = document.getElementById('stop-batch-btn');
if (stopBtn) stopBtn.style.display = 'inline-flex';
const stopRunBtn = document.getElementById('stop-run-btn');
if (stopRunBtn) stopRunBtn.style.display = 'inline-flex';
const stopMini = document.getElementById('stop-mini-btn');
if (stopMini) stopMini.style.display = 'inline-flex';
state.cancelRequested = false;
state.currentBatchIndex = 0;
state.processedCount = 0;
updateSubProgress(0, 0);
// If there are no dynamic elements, allow a single run with null item
const liveItems = Array.isArray(state.dynamicElements) ? state.dynamicElements : [];
if (!liveItems || liveItems.length === 0) {
// Single run with empty item
refreshBatchProgress(0, 0);
await processChain(state.chainDefinition, { item: null, index: 1, total: 1 });
} else {
let processed = 0;
// Loop until we've processed all available items or cancel is requested
while (true) {
if (state.cancelRequested) {
utils.log('Run canceled');
break;
}
const itemsNow = Array.isArray(state.dynamicElements) ? state.dynamicElements : [];
const totalNow = Math.max(0, itemsNow.length);
// If no items remain, we're done
if (totalNow === 0) {
break;
}
// Update progress using processed count and dynamic total (processed + remaining)
// Ensure currentBatchIndex reflects what we've processed so far for live updates
state.currentBatchIndex = processed;
state.processedCount = processed;
refreshBatchProgress(processed, processed + totalNow);
// Determine the next item to process
let itemToProcess;
if (state.autoRemoveProcessed) {
// Always take the first item
itemToProcess = itemsNow[0];
if (typeof state.dynamicElements.shift === 'function') {
// We'll remove after processing to avoid racing with input handlers
}
} else {
// Use processed as index; if out of range, break (may happen if list shrank)
if (processed >= itemsNow.length) break;
itemToProcess = itemsNow[processed];
}
utils.log(`🔗 Chain run for item ${processed + 1}/${processed + totalNow}`);
await processChain(state.chainDefinition, {
item: itemToProcess,
index: processed + 1,
total: totalNow,
});
if (state.cancelRequested) {
utils.log('Run canceled');
break;
}
// After processing, update the runtime list according to auto-remove
if (state.autoRemoveProcessed) {
removeHeadItems(1);
processed += 1;
state.currentBatchIndex = processed;
state.processedCount = processed;
} else {
processed += 1;
state.processedCount = processed;
}
// Sync chain JSON so edits are reflected
try {
if (!state.chainDefinition)
state.chainDefinition = JSON.parse(
document.getElementById('chain-json-input').value || '{}'
);
state.chainDefinition.dynamicElements = state.dynamicElements;
const chainInput = document.getElementById('chain-json-input');
if (chainInput) chainInput.value = JSON.stringify(state.chainDefinition, null, 2);
} catch {}
// Update progress after completion of this item; denominator = processed + remaining
const remainingNow = Array.isArray(state.dynamicElements)
? state.dynamicElements.length
: 0;
refreshBatchProgress(processed, processed + remainingNow);
// Wait between items when there are still items left
const remaining = Array.isArray(state.dynamicElements) ? state.dynamicElements.length : 0;
if (remaining > 0) {
utils.log(`⏱️ Waiting ${state.batchWaitTime}ms before next item…`);
await utils.sleep(state.batchWaitTime);
continue;
}
break;
}
}
utils.log('🏁 Chain batch completed');
} catch (e) {
utils.log('Chain error: ' + e.message, 'error');
} finally {
releaseRunLock();
state.isProcessing = false;
updateStatus('idle');
refreshBatchProgress(0, 0);
updateSubProgress(0, 0);
const stopBtn = document.getElementById('stop-batch-btn');
if (stopBtn) stopBtn.style.display = 'none';
const stopRunBtn = document.getElementById('stop-run-btn');
if (stopRunBtn) stopRunBtn.style.display = 'none';
const stopMini = document.getElementById('stop-mini-btn');
if (stopMini) stopMini.style.display = 'none';
}
};
const resolveEntryStep = (chain) => {
if (!chain) return null;
if (chain.entryId) return (chain.steps || []).find((s) => s.id === chain.entryId) || null;
const steps = chain.steps || [];
if (!steps.length) return null;
const referenced = new Set(steps.map((s) => s.next).filter(Boolean));
const first = steps.find((s) => !referenced.has(s.id));
return first || steps[0];
};
// Helper: create a per-step context that exposes previous steps and chain data
const createStepContext = (context) => ({
...context,
item: context.item,
index: context.index,
total: context.total,
steps: context.steps,
chain: context.chain,
});
const handlePromptStep = async (step, stepContext, context) => {
const msg = processors.processDynamicTemplate(step.template || '', stepContext);
// Per-step new chat option
if (step.newChat) {
await startNewChat();
}
const expect = step.responseType === 'image' ? 'image' : 'text';
if (expect === 'image') {
const { el: respEl, images } = await chatGPT.askWith(msg, { expect: 'image' });
const imgs = images || [];
context.lastResponseText = imgs[0] || '';
context.chain[step.id] = { images: imgs };
context.steps[step.id] = { type: 'prompt', responseType: 'image', images: imgs };
utils.log(`🖼️ Step ${step.id} returned ${imgs.length} image(s)`);
utils.log(`💡 Access first image: {steps.${step.id}.images[0]}`);
} else {
const { el: respEl, text: resp } = await chatGPT.ask(msg);
context.lastResponseText = resp;
context.chain[step.id] = { response: resp };
context.steps[step.id] = { type: 'prompt', response: resp, responseText: resp };
utils.log(`📩 Step ${step.id} response (${resp.length} chars)`);
utils.log(
`💡 Access this data in next steps with: {steps.${step.id}.response} or {steps.${step.id}.responseText}`
);
}
};
const handleHttpStep = async (step, stepContext, context) => {
const url = processors.processDynamicTemplate(step.url || '', stepContext);
const method = (step.method || 'GET').toUpperCase();
let headers = step.headers || {};
try {
if (typeof headers === 'string') headers = JSON.parse(headers);
} catch {}
const body = step.bodyTemplate
? processors.processDynamicTemplate(step.bodyTemplate, stepContext)
: undefined;
// Basic retry for transient failures
let res;
let attempt = 0;
let lastErr = null;
while (attempt < 3) {
try {
res = await http.request({ method, url, headers, data: body });
break;
} catch (e) {
lastErr = e;
attempt++;
if (attempt < 3) {
utils.log(`HTTP attempt ${attempt} failed (${e?.message || e}). Retrying...`, 'warning');
await utils.sleep(500 * attempt);
}
}
}
if (!res) throw lastErr || new Error('Network error');
const payload = res.responseText || res.response || '';
let parsedData = payload;
try {
parsedData = JSON.parse(payload);
} catch {}
const httpData = {
status: res.status,
statusText: res.statusText || '',
data: parsedData,
rawText: payload,
headers: res.responseHeaders || {},
url,
method,
};
context.chain[step.id] = { http: httpData };
context.steps[step.id] = { type: 'http', ...httpData };
utils.log(`🌐 HTTP ${method} ${url} → ${res.status}`);
utils.log(
`💡 Access this data with: {steps.${step.id}.data} or {steps.${step.id}.rawText} or {steps.${step.id}.status}`
);
};
const handleJsStep = async (step, stepContext, context) => {
const jsContext = {
elementData: context.item,
index: context.index,
total: context.total,
steps: context.steps,
lastResponse: context.lastResponseText,
};
const ret = await processors.executeCustomCode(
step.code || '',
context.lastResponseText || '',
jsContext
);
context.steps[step.id] = { type: 'js', executed: true, response: ret };
};
const handleTemplateStep = async (step, stepContext, context) => {
let arr = [];
try {
// Allow templating inside elements definition
const elemsSrc = processors.processDynamicTemplate(step.elements || '[]', stepContext);
arr = await processors.parseDynamicElements(elemsSrc || '[]');
} catch {
arr = [];
}
// Optionally use chain-level dynamicElements for nested batching
if (
(!arr || arr.length === 0) &&
step.useDynamicElements &&
(Array.isArray(context.chain?.dynamicElements) ||
typeof context.chain?.dynamicElements === 'string')
) {
arr = context.chain.dynamicElements;
}
if (!Array.isArray(arr) || arr.length === 0) {
utils.log('Template step has no elements; sending one prompt with current context');
const msg = processors.processDynamicTemplate(step.template || '', stepContext);
const { text: resp } = await chatGPT.ask(msg);
context.lastResponseText = resp;
context.chain[step.id] = { response: resp };
context.steps[step.id] = {
type: 'template',
response: resp,
responseText: resp,
itemCount: 0,
};
utils.log(
`💡 Access template data with: {steps.${step.id}.responses} or {steps.${step.id}.lastResponse}`
);
return;
}
utils.log(`🧩 Template step expanding ${arr.length} items`);
updateSubProgress(0, arr.length);
const responses = [];
for (let i = 0; i < arr.length; i++) {
updateSubProgress(i + 1, arr.length);
if (state.cancelRequested) {
utils.log('Run canceled');
break;
}
const child = arr[i];
const itemContext = { ...stepContext, item: child, index: i + 1, total: arr.length };
const msg = processors.processDynamicTemplate(step.template || '', itemContext);
utils.log(`📝 Template item ${i + 1}/${arr.length}: ${utils.clip(msg, 200)}`);
if (step.newChat) {
await startNewChat();
}
const expect = step.responseType === 'image' ? 'image' : 'text';
if (expect === 'image') {
const { images } = await chatGPT.askWith(msg, { expect: 'image' });
responses.push({ item: child, images: images || [] });
context.lastResponseText = (images && images[0]) || '';
} else {
const { text: resp } = await chatGPT.ask(msg);
responses.push({ item: child, response: resp });
context.lastResponseText = resp;
}
if (state.cancelRequested) {
utils.log('Run canceled');
break;
}
if (i < arr.length - 1) {
utils.log(`⏱️ Waiting ${state.batchWaitTime}ms before next template item…`);
await utils.sleep(state.batchWaitTime);
}
}
context.chain[step.id] = { responses };
context.steps[step.id] = {
type: 'template',
responses,
itemCount: responses.length,
lastResponse: responses[responses.length - 1]?.response || '',
};
updateSubProgress(0, 0);
utils.log(
`💡 Access template data with: {steps.${step.id}.responses} or {steps.${step.id}.lastResponse}`
);
};
const processChain = async (chain, baseContext) => {
const entry = resolveEntryStep(chain);
if (!entry) throw new Error('Empty chain');
let step = entry;
let context = {
...baseContext,
lastResponseText: '',
chain: { dynamicElements: chain.dynamicElements || [] },
steps: {},
};
const perStepWait = parseInt(document.getElementById('step-wait-input')?.value || '0') || 0;
while (step) {
utils.log(`➡️ Step ${step.id} (${step.type})`);
const stepContext = createStepContext(context);
try {
if (step.type === 'prompt') await handlePromptStep(step, stepContext, context);
else if (step.type === 'http') await handleHttpStep(step, stepContext, context);
else if (step.type === 'js') await handleJsStep(step, stepContext, context);
else if (step.type === 'template') await handleTemplateStep(step, stepContext, context);
else utils.log(`Unknown step type: ${step.type}`, 'warning');
} catch (err) {
const msg = err?.message || String(err || 'Unknown error');
utils.log(`Step ${step.id} error: ${msg}`, 'error');
throw new Error(msg);
}
step = step.next ? (chain.steps || []).find((s) => s.id === step.next) : null;
if (step && perStepWait > 0) {
utils.log(`⏱️ Waiting ${perStepWait}ms before next step…`);
await utils.sleep(perStepWait);
}
}
};
// Initialize the script
const init = () => {
if (document.getElementById('chatgpt-automation-ui')) {
return; // Already initialized
}
utils.log('Initializing ChatGPT Automation Pro...');
// Wait for page to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createUI);
} else {
createUI();
}
};
// Auto-start
init();
})();