Raiju/js/ai-agent.js
2026-10-25 06:05:42 +03:00

1173 lines
60 KiB
JavaScript

/**
* AI agent for the frontend assistant (agent phase P7).
*
* The agent works through a request on the server (js only drives the steps and shows
* what happened), prepares a change package and waits. Nothing is applied except by the
* button of the package card; a "yes" typed into the chat never applies anything.
*
* It plugs into js/ai-copilot.js through RaijuAiCopilot.registerAgent(): the assistant
* calls render() for the cards below the chat messages and hands over every message while
* the agent mode is on. All text from the server (summaries, content, sources) is inserted
* escaped, links only when they are http(s), and nothing is ever evaluated.
*/
(function() {
'use strict';
const config = window.RJ_AI_AGENT;
// Whether the chat offers the classic assistant next to the agent (Settings > AI agent). Off: agent only, no switch.
const classicAllowed = !window.RJ_AI_AGENT || window.RJ_AI_AGENT.classic !== false;
if (!config || config.available !== true || !window.RaijuAiCopilot || typeof window.RaijuAiCopilot.registerAgent !== 'function') {
return;
}
// The mode is remembered apart for the website and the dashboard: the dashboard's actions (theme, language) are the classic assistant's.
const MODE_KEY = 'raiju.aiAgent.mode' + (window.RJ_AI_COPILOT_MODE === 'dashboard' ? '.dashboard' : '');
const RUN_KEY = 'raiju-ai-agent:run';
const LAST_RUN_KEY = 'raiju-ai-agent:last-run'; // only the run id, shared by all tabs, so a new tab finds the open package again
const LAST_RUN_MAX_AGE = 48 * 3600 * 1000;
const rights = config.rights && typeof config.rights === 'object' ? config.rights : {};
const maxUploadFiles = Number(config.maxUploadFiles) > 0 ? Number(config.maxUploadFiles) : 6;
const maxUploadBytes = Number(config.maxUploadBytes) > 0 ? Number(config.maxUploadBytes) : 10 * 1024 * 1024;
const stepTimeoutMs = 75000;
const visibleSteps = 6;
const TOOL_STEPS = [
'get_site_overview', 'find_in_dashboard', 'get_page_capabilities', 'read_content', 'list_content', 'list_media', 'list_run_media', 'search_stock_images', 'import_stock_image',
'generate_image', 'search_web', 'read_web_result', 'read_url', 'stage_set_field', 'stage_set_image', 'stage_set_page_menus', 'stage_add_list_item', 'stage_hide_list_item', 'stage_remove_list_item',
'stage_move_list_item', 'stage_add_section', 'stage_create_news', 'stage_create_event', 'stage_create_page', 'stage_revert_op', 'validate_changeset', 'ask_user', 'present_changeset'
];
let host = null;
let root = null;
const state = {
mode: readMode(),
run: null,
review: null,
applied: null,
chips: [],
attachedChangeset: '',
uploads: 0,
dragging: false,
busyAction: '',
notice: null,
lastGoal: '',
driving: false,
awaitingChanges: false,
openDetails: {},
lastHtml: ''
};
// ------------------------------------------------------------------
// Small helpers
// ------------------------------------------------------------------
function label(key, fallback, params) {
return host ? host.label(key, fallback, params) : fallback;
}
function esc(value) {
return host ? host.escHtml(value) : String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
}
function sleep(ms) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
function readMode() {
if (!classicAllowed) return 'agent';
try {
return window.localStorage && window.localStorage.getItem(MODE_KEY) === 'classic' ? 'classic' : 'agent';
} catch (error) {
return 'agent';
}
}
function writeMode(mode) {
try {
if (window.localStorage) window.localStorage.setItem(MODE_KEY, mode);
} catch (error) {
// A blocked storage only means the choice is not remembered.
}
}
function persistRun() {
try {
if (!window.sessionStorage) return;
if (!state.run || !state.run.runId) {
window.sessionStorage.removeItem(RUN_KEY);
if (window.localStorage) window.localStorage.removeItem(LAST_RUN_KEY);
return;
}
const stored = { runId: state.run.runId, goal: String(state.lastGoal || '').slice(0, 2000) };
window.sessionStorage.setItem(RUN_KEY, JSON.stringify(stored));
if (window.localStorage) window.localStorage.setItem(LAST_RUN_KEY, JSON.stringify({ at: Date.now(), runId: stored.runId }));
} catch (error) {
// Storage limits or privacy settings must not break editing.
}
}
function readStoredRun() {
try {
const valid = data => data && /^run_[a-z0-9]{6,}$/.test(String(data.runId || '')) ? data : null;
const own = valid(JSON.parse((window.sessionStorage && window.sessionStorage.getItem(RUN_KEY)) || 'null'));
if (own) return own;
// Another tab of this browser started it (or this tab was opened fresh): the open package is still there.
const shared = valid(JSON.parse((window.localStorage && window.localStorage.getItem(LAST_RUN_KEY)) || 'null'));
return shared && Date.now() - Number(shared.at || 0) < LAST_RUN_MAX_AGE ? shared : null;
} catch (error) {
return null;
}
}
// The preview page sends the person back here with #rj-agent-open or #rj-agent-apply={id}. Applying still
// goes through the confirmation of this assistant; the hash only opens it.
function launchRequest() {
const match = /^#rj-agent-(apply|open)(?:=(cs_[a-f0-9]{8,}))?$/.exec(window.location.hash || '');
return match ? { kind: match[1], id: match[2] || '' } : null;
}
function clearLaunchHash() {
try {
window.history.replaceState(null, '', window.location.pathname + window.location.search);
} catch (error) {
// The hash stays; a reload would ask again at most.
}
}
// final: nothing more will load, so a request that found no matching package is dropped.
function handleLaunchRequest(final) {
const request = launchRequest();
if (!request || !host) return;
if (request.kind === 'open') {
clearLaunchHash();
host.open();
return;
}
const review = state.review;
if (review && review.status === 'ready' && review.id === request.id) {
clearLaunchHash();
host.open();
applyPackage();
} else if (final) {
clearLaunchHash();
host.open();
}
}
function apiGet(action, params) {
const url = new URL(host.apiUrl, window.location.href);
url.searchParams.set('action', action);
Object.entries(params || {}).forEach(([key, value]) => url.searchParams.set(key, String(value)));
return fetch(url.href, { credentials: 'same-origin', headers: { Accept: 'application/json' } }).then(async response => {
const data = await response.json().catch(() => null);
if (!response.ok || !data || data.success === false) {
const error = new Error((data && data.message) || label('copilot.request_failed', 'AI Assistant request failed.'));
error.status = response.status;
throw error;
}
return data.data;
});
}
function mediaUrl(changesetId, ref) {
const url = new URL(host.apiUrl, window.location.href);
url.searchParams.set('action', 'ai-agent-media');
url.searchParams.set('id', changesetId);
url.searchParams.set('ref', ref);
return url.href;
}
function safeHttpUrl(value) {
try {
const url = new URL(String(value || ''));
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : '';
} catch (error) {
return '';
}
}
function safeLocalPath(value) {
const path = String(value || '');
return /^\/(?!\/)[^\s\\]*$/.test(path) ? path : '';
}
/** Readable text of a stored value: HTML is reduced to its text (the parser never runs scripts). */
function plainText(value, limit, keepBreaks) {
let text = String(value == null ? '' : value);
if (/<[a-z!/][^>]*>/i.test(text)) {
// Block ends become line breaks, otherwise headings and paragraphs run together.
if (keepBreaks) text = text.replace(/<\/(p|h[1-6]|li|div|blockquote|tr)>|<br\s*\/?>/gi, '$&\n');
try {
text = new DOMParser().parseFromString(text, 'text/html').body.textContent || '';
} catch (error) {
text = text.replace(/<[^>]*>/g, ' ');
}
}
text = keepBreaks
? text.replace(/[ \t\f\v\r]+/g, ' ').replace(/ ?\n ?/g, '\n').replace(/\n{2,}/g, '\n').trim()
: text.replace(/\s+/g, ' ').trim();
const max = limit || 400;
return text.length > max ? text.slice(0, max - 1).trimEnd() + '…' : text;
}
function icon(name) {
const icons = {
attach: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m21.4 11.1-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg>',
ok: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>',
failed: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>',
warning: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.5 2.5 20h19L12 3.5Z"/><path d="M12 10v4.5M12 17.5v.1"/></svg>',
ai: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Z"/></svg>'
};
return icons[name] || '';
}
// ------------------------------------------------------------------
// Run state
// ------------------------------------------------------------------
function isWorking() {
return !!state.run && (state.run.status === 'running' || state.run.status === 'waiting_jobs');
}
function isBusy() {
return isWorking() || state.uploads > 0 || state.busyAction !== '';
}
function applyRunState(data, live) {
if (!data || !data.runId) return;
const previous = state.run && state.run.runId === data.runId ? state.run.status : '';
state.run = Object.assign({}, state.run && state.run.runId === data.runId ? state.run : {}, data);
if (data.status !== 'needs_input' && data.status !== 'awaiting_review') state.awaitingChanges = false;
if (Array.isArray(data.limitWarnings) && host.setLimitWarnings) host.setLimitWarnings(data.limitWarnings);
// The answer and the summary belong in the chat, so the history keeps them. When a run is picked up again
// (reload, a new login, another tab) the chat may lack them, so they are added unless they are there already.
const addToChat = text => {
if (text && (live !== false || !(typeof host.hasMessage === 'function' && host.hasMessage('assistant', text)))) host.pushMessage('assistant', text);
};
if (data.status === 'needs_input' && previous !== 'needs_input') addToChat(String(data.question || '').trim());
if (data.status === 'awaiting_review' && previous !== 'awaiting_review') {
addToChat(String(data.summary || '').trim());
state.review = null;
state.reviewLoading = loadReview();
}
if (data.status === 'cancelled' && previous !== 'cancelled') {
// Whether the cancel was immediate or caught by a running step, the run ends here.
host.pushMessage('assistant', label('agent.cancelled', 'The agent was cancelled. Nothing was changed.'));
clearRun();
return;
}
if (data.status === 'failed') {
state.notice = { type: 'error', text: String(data.error || label('agent.error_generic', 'The agent could not finish.')), retry: !!state.lastGoal };
}
persistRun();
}
async function drive() {
if (state.driving) return;
state.driving = true;
let failures = 0;
try {
while (isWorking()) {
const runId = state.run.runId;
let data;
try {
data = await host.post('ai-agent-step', { runId }, stepTimeoutMs);
failures = 0;
} catch (error) {
if (error && error.sessionExpired) throw error;
failures += 1;
if (failures >= 3) throw error;
await sleep(1500 * failures);
continue;
}
if (!state.run || state.run.runId !== runId) break;
if (data && data.status === 'busy') {
await sleep(1200);
continue;
}
applyRunState(data);
update();
await sleep(state.run && state.run.status === 'waiting_jobs' ? 2500 : 150);
}
} catch (error) {
state.notice = { type: 'error', text: String(error && error.message ? error.message : label('agent.error_connection', 'The connection was interrupted.')), resume: true };
} finally {
state.driving = false;
update();
if (host) host.updateActions();
}
}
async function startRun(goal) {
state.lastGoal = goal;
state.notice = null;
state.review = null;
state.applied = null;
state.run = null;
host.pushMessage('user', goal);
const payload = { goal };
if (state.attachedChangeset) payload.changesetId = state.attachedChangeset;
state.busyAction = 'start';
update();
try {
const data = await host.post('ai-agent-start', payload);
state.chips = [];
state.attachedChangeset = '';
applyRunState(data, true);
} catch (error) {
state.notice = { type: 'error', text: error.message };
} finally {
state.busyAction = '';
}
update();
drive();
}
async function replyRun(message) {
host.pushMessage('user', message);
state.notice = null;
state.busyAction = 'reply';
update();
try {
const data = await host.post('ai-agent-reply', { runId: state.run.runId, message });
if (data && data.status === 'busy') {
state.notice = { type: 'info', text: label('agent.busy', 'The agent is still working. Try again in a moment.') };
} else {
state.review = null;
applyRunState(data, true);
}
} catch (error) {
state.notice = { type: 'error', text: error.message };
} finally {
state.busyAction = '';
}
update();
drive();
}
async function handleMessage(content) {
const text = String(content || '').trim();
if (!text) return true;
const run = state.run;
const reviewOpen = !!state.review && ['staging', 'ready', 'conflict', 'failed'].includes(state.review.status);
if (run && (run.status === 'needs_input' || (run.status === 'awaiting_review' && (reviewOpen || !state.review)))) {
await replyRun(text);
return true;
}
if (isBusy()) return true;
if (state.applied && run) {
host.pushMessage('assistant', label('agent.applied_summary', 'The previous package was applied.'));
}
await startRun(text);
return true;
}
async function cancelRun() {
if (!state.run || state.busyAction === 'cancel') return;
state.busyAction = 'cancel';
update();
try {
const data = await host.post('ai-agent-cancel', { runId: state.run.runId });
if (data && data.status === 'cancelled') {
applyRunState(Object.assign({}, state.run, data));
} else if (data && data.status === 'cancelling') {
state.notice = { type: 'info', text: label('agent.cancelling', 'Cancelling…') };
}
} catch (error) {
state.notice = { type: 'error', text: error.message };
} finally {
state.busyAction = '';
}
update();
drive();
}
// ------------------------------------------------------------------
// The package
// ------------------------------------------------------------------
async function loadReview() {
const id = state.run && state.run.changesetId;
if (!id) return;
try {
const data = await apiGet('ai-agent-changeset', { id });
state.review = data;
if (data.status === 'applied' && data.applied) {
state.applied = { items: Array.isArray(data.applied.items) ? data.applied.items : [], undoToken: String(data.applied.undoToken || '') };
} else if (data.status !== 'applied') {
state.applied = null;
}
} catch (error) {
state.notice = { type: 'error', text: error.message };
}
update();
handleLaunchRequest(false);
}
async function applyPackage() {
const review = state.review;
if (!review || review.status !== 'ready' || isBusy()) return;
const operations = Object.values(review.targets || {}).reduce((total, list) => total + list.length, 0);
const targets = Object.keys(review.targets || {}).map(targetLabel);
// New pages, posts, events, sections and list items start hidden; changes to existing content are live at once.
const createsContent = Object.values(review.targets || {}).some(list => list.some(op => ['create_news', 'create_event', 'create_page', 'section_insert', 'list_insert'].includes(op.type)));
const riskNotes = ((review.validation && review.validation.warnings) || []).filter(warning => warning && typeof warning.code === 'string' && warning.code.indexOf('safety_') === 0);
const risky = riskNotes.length > 0 || !!(review.validation && review.validation.risk && review.validation.risk.level === 'caution');
const confirmed = risky ? await host.confirm({
title: label('agent.risk_confirm_title', 'Apply despite the safety notes?'),
body: label('agent.risk_confirm_body', 'The safety check flagged this package. Apply it only if it is exactly what you asked for. You can undo the package afterwards.'),
items: riskNotes.slice(0, 6).map(note => plainText(note.message, 200)),
confirmLabel: label('agent.risk_confirm_button', 'Apply anyway')
}) : await host.confirm({
title: label('agent.apply_confirm_title', 'Apply this package?'),
body: createsContent
? label('agent.apply_confirm_body', '{count} changes will be saved. Changes to existing content go live right away; new content stays hidden until you publish it. You can undo the package afterwards.', { count: operations })
: label('agent.apply_confirm_body_live', '{count} changes will be saved and go live right away. You can undo the package afterwards.', { count: operations }),
items: targets,
confirmLabel: label('agent.apply_confirm_button', 'Apply')
});
if (!confirmed) return;
state.busyAction = 'apply';
state.notice = null;
update();
try {
const data = await host.post('ai-agent-apply', Object.assign({ id: review.id, previewHash: review.previewHash, confirmed: '1' }, risky ? { confirmRisk: '1' } : {}));
state.applied = { items: Array.isArray(data.items) ? data.items : [], undoToken: String(data.undoToken || '') };
host.pushMessage('assistant', label('agent.applied_title', 'Changes applied'));
} catch (error) {
state.notice = { type: 'error', text: label('agent.apply_failed', 'The package could not be applied. {message}', { message: error.message }) };
} finally {
state.busyAction = '';
}
await loadReview();
}
async function discardPackage() {
const review = state.review;
if (!review || isBusy()) return;
const confirmed = await host.confirm({
title: label('agent.discard_confirm_title', 'Discard this package?'),
body: label('agent.discard_confirm_body', 'The staged changes and images are deleted. Nothing on your site changes.'),
confirmLabel: label('agent.discard_confirm_button', 'Discard')
});
if (!confirmed) return;
state.busyAction = 'discard';
update();
try {
await host.post('ai-agent-discard', { id: review.id });
host.pushMessage('assistant', label('agent.discarded', 'The package was discarded. Nothing was changed.'));
clearRun();
} catch (error) {
state.notice = { type: 'error', text: error.message };
} finally {
state.busyAction = '';
}
update();
}
async function revalidatePackage() {
const review = state.review;
if (!review || isBusy()) return;
state.busyAction = 'revalidate';
state.notice = null;
update();
try {
await host.post('ai-agent-revalidate', { id: review.id });
} catch (error) {
state.notice = { type: 'error', text: error.message };
} finally {
state.busyAction = '';
}
await loadReview();
}
async function undoPackage() {
const review = state.review;
if (!review || !state.applied || isBusy()) return;
const confirmed = await host.confirm({
title: label('agent.undo_confirm_title', 'Undo this package?'),
body: label('agent.undo_confirm_body', 'The affected content returns to the state before the package.'),
confirmLabel: label('agent.undo', 'Undo')
});
if (!confirmed) return;
state.busyAction = 'undo';
state.notice = null;
update();
try {
await host.post('ai-agent-undo', { id: review.id, token: state.applied.undoToken });
host.pushMessage('assistant', label('agent.undone', 'The package was undone.'));
} catch (error) {
state.notice = { type: 'error', text: error.message };
} finally {
state.busyAction = '';
}
await loadReview();
}
async function publishItem(index) {
const item = state.applied && state.applied.items[index];
if (!item || item.published || isBusy()) return;
const confirmed = await host.confirm({
title: label('agent.publish_confirm_title', 'Publish this item?'),
body: label('agent.publish_confirm_body', 'It becomes visible to visitors.'),
items: [itemLabel(item)],
confirmLabel: label('agent.publish', 'Publish')
});
if (!confirmed) return;
state.busyAction = 'publish';
state.notice = null;
update();
try {
const payload = { kind: item.kind, target: item.target };
['listKey', 'index', 'sectionId'].forEach(key => {
if (item[key] !== undefined && item[key] !== null) payload[key] = item[key];
});
await host.post('ai-agent-publish', { id: state.review.id, item: payload });
item.published = true;
} catch (error) {
state.notice = { type: 'error', text: error.message };
} finally {
state.busyAction = '';
}
update();
}
function openPreview(index) {
const review = state.review;
if (!review || review.status !== 'ready') return;
const link = (review.previewLinks || [])[index];
const path = link ? safeLocalPath(link.url) : '';
if (!path) return;
const url = new URL(path, window.location.origin);
url.searchParams.set('rj_agent_preview', review.id);
// The preview opens in this tab and sends the person back here, where the package is still open.
url.searchParams.set('rj_agent_return', window.location.pathname + window.location.search);
window.location.assign(url.href);
}
function clearRun() {
state.run = null;
state.review = null;
state.applied = null;
state.notice = null;
state.awaitingChanges = false;
persistRun();
}
// ------------------------------------------------------------------
// Rendering
// ------------------------------------------------------------------
function targetLabel(target) {
const value = String(target || '');
const separator = value.indexOf(':');
const kind = separator > 0 ? value.slice(0, separator) : '';
const name = separator > 0 ? value.slice(separator + 1) : value;
if (value === 'page:footer' || value === 'page:sidebar') return label('agent.target_shared', 'Shared content: {name}', { name });
if (kind === 'news') return label('agent.target_news', 'News post: {name}', { name });
if (kind === 'event') return label('agent.target_event', 'Event: {name}', { name });
return label('agent.target_page', 'Page: {name}', { name });
}
function itemLabel(item) {
const kinds = { news: 'agent.item_news', event: 'agent.item_event', page: 'agent.item_page', section: 'agent.item_section', list_item: 'agent.item_list_item' };
const fallbacks = { news: 'News post', event: 'Event', page: 'Page', section: 'Section', list_item: 'List item' };
return label(kinds[item.kind] || 'agent.item_page', fallbacks[item.kind] || 'Item') + ': ' + String(item.target || '').replace(/^[a-z]+:/, '');
}
function stepText(tool, detail) {
const name = TOOL_STEPS.includes(tool) ? tool : '';
const clean = plainText(detail, 80);
if (!name) return label('agent.step.unknown', 'Working');
return label('agent.step.' + name, 'Working', { detail: clean });
}
function eventDetail(event) {
const raw = String(event.label || '');
const tool = String(event.tool || '');
if (tool === 'read_web_result') return '';
if (tool === 'read_url') {
try {
return new URL(raw.slice(tool.length).trim()).hostname;
} catch (error) {
return '';
}
}
return raw.indexOf(tool) === 0 ? raw.slice(tool.length).trim() : '';
}
/** Finished steps, plus what the agent is about to do (the tools of the latest turn that did not run yet). */
function timeline() {
const steps = [];
let pending = [];
(state.run && Array.isArray(state.run.events) ? state.run.events : []).forEach(event => {
if (event.type === 'turn') {
pending = Array.isArray(event.tools) ? event.tools.slice() : [];
} else if (event.type === 'tool') {
pending.shift();
steps.push({ tool: String(event.tool || ''), detail: eventDetail(event), ok: event.ok !== false });
} else if (event.type === 'reply' || event.type === 'start') {
pending = [];
}
});
return { steps, next: pending.length ? pending[0] : '' };
}
function renderRunCard() {
const run = state.run;
if (!run) return '';
const working = isWorking();
const { steps, next } = timeline();
if (!working && !steps.length) return '';
let headline = '';
if (working) {
if (run.status === 'waiting_jobs') headline = label('agent.status_waiting_image', 'Waiting for the image…');
else headline = next ? stepText(next, '') + '…' : label('agent.status_thinking', 'Thinking…');
}
const items = steps.map(step => '<li class="' + (step.ok ? 'is-ok' : 'is-failed') + '">' +
'<span class="rj-agent-step__mark">' + icon(step.ok ? 'ok' : 'failed') + '</span>' +
'<span>' + esc(stepText(step.tool, step.detail)) + (step.ok ? '' : ' <em>(' + esc(label('agent.step_failed', 'did not work')) + ')</em>') + '</span></li>');
let list = '';
if (working) {
list = items.length ? '<ol class="rj-agent-steps">' + items.slice(-visibleSteps).join('') + '</ol>' : '';
} else if (items.length) {
const open = state.openDetails.steps === true ? ' open' : '';
list = '<details class="rj-agent-steps-details" data-agent-details="steps"' + open + '><summary>' +
esc(label('agent.steps_title', '{count} steps', { count: items.length })) + '</summary><ol class="rj-agent-steps">' + items.join('') + '</ol></details>';
}
const head = working
? '<div class="rj-agent-run__head"><span class="rj-copilot-spinner" aria-hidden="true"></span><strong>' + esc(headline) + '</strong>' +
'<button type="button" class="rj-agent-link" data-agent-action="cancel"' + (state.busyAction === 'cancel' ? ' disabled' : '') + '>' + esc(label('agent.cancel', 'Cancel')) + '</button></div>'
: '';
return '<section class="rj-agent-run" aria-label="' + esc(label('agent.title', 'AI agent')) + '">' + head + list + '</section>';
}
function describeOpValue(value) {
const text = String(value == null ? '' : value);
if (text === '') return '<span class="rj-agent-empty">' + esc(label('agent.empty_value', '(empty)')) + '</span>';
if (text[0] === '{') {
try {
const object = JSON.parse(text);
if (object && typeof object === 'object') {
const parts = [];
['title', 'date', 'time', 'location', 'excerpt', 'description', 'content'].forEach(key => {
if (object[key]) parts.push('<strong>' + esc(key) + '</strong> ' + esc(plainText(object[key], 240)));
});
if (Array.isArray(object.sections)) parts.push('<strong>sections</strong> ' + esc(String(object.sections.length)));
if (parts.length) return parts.join('<br>');
}
} catch (error) {
// Not JSON: show it as text.
}
}
return esc(plainText(text, 400, true));
}
// A package of a few changes shows them right away; a long one stays folded.
const OPEN_OPERATIONS_UP_TO = 4;
function renderOperation(op, openByDefault) {
const key = 'op:' + op.opId;
const stored = state.openDetails[key];
const open = (stored === undefined ? openByDefault : stored === true) ? ' open' : '';
return '<li class="rj-agent-op' + (op.destructive ? ' is-destructive' : '') + '"><details data-agent-details="' + esc(key) + '"' + open + '><summary>' + esc(op.label) +
(op.destructive ? ' <span class="rj-agent-tag rj-agent-tag--danger">' + esc(label('agent.destructive', 'Deletes content')) + '</span>' : '') + '</summary>' +
'<div class="rj-agent-diff"><div><span>' + esc(label('agent.before', 'Before')) + '</span><p>' + describeOpValue(op.before) + '</p></div>' +
'<div><span>' + esc(label('agent.after', 'After')) + '</span><p>' + describeOpValue(op.after) + '</p></div></div></details></li>';
}
function renderMedia(review) {
const entries = Object.entries(review.media || {});
if (!entries.length) return '';
const cards = entries.map(([ref, media]) => {
const source = String(media.source || '');
const sourceLabel = source === 'stock' ? label('agent.image_stock', 'Stock photo')
: source === 'generated' ? label('agent.image_generated', 'Generated image') : label('agent.image_upload', 'Your upload');
const credit = media.credit && media.credit.attribution ? '<span class="rj-agent-media__credit">' + esc(String(media.credit.attribution)) + '</span>' : '';
const ai = media.ai === 'generated' ? '<span class="rj-agent-tag rj-agent-tag--ai">' + icon('ai') + esc(label('agent.image_ai', 'AI-generated')) + '</span>' : '';
return '<figure class="rj-agent-media"><img src="' + esc(mediaUrl(review.id, ref)) + '" alt="' + esc(sourceLabel) + '" loading="lazy" width="96" height="72">' +
'<figcaption>' + esc(sourceLabel) + credit + ai + '</figcaption></figure>';
});
return '<div class="rj-agent-block"><h4>' + esc(label('agent.images', 'Images')) + '</h4><div class="rj-agent-media-grid">' + cards.join('') + '</div></div>';
}
function renderSources(review) {
const sources = Array.isArray(review.sources) ? review.sources : [];
if (!sources.length) return '';
const items = sources.map(source => {
const url = safeHttpUrl(source.url);
const title = String(source.title || source.host || url);
const name = url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(plainText(title, 90)) + '</a>' : esc(plainText(title, 90));
const warning = source.warning
? '<span class="rj-agent-source__warning" role="img" aria-label="' + esc(label('agent.source_warning', 'This page contains text that tries to instruct an AI. It was ignored.')) +
'" title="' + esc(label('agent.source_warning', 'This page contains text that tries to instruct an AI. It was ignored.')) + '">' + icon('warning') + '</span>'
: '';
return '<li>' + warning + name + '<span class="rj-agent-source__meta">' + esc([source.host, source.date].filter(Boolean).join(' · ')) + '</span></li>';
});
return '<div class="rj-agent-block"><h4>' + esc(label('agent.sources', 'Sources')) + '</h4><ul class="rj-agent-sources">' + items.join('') + '</ul></div>';
}
function renderChecks(review) {
const errors = review.validation && Array.isArray(review.validation.errors) ? review.validation.errors : [];
const warnings = review.validation && Array.isArray(review.validation.warnings) ? review.validation.warnings : [];
const parts = [];
if (review.status === 'conflict' || (Array.isArray(review.conflicts) && review.conflicts.length)) {
parts.push('<div class="rj-agent-note rj-agent-note--warn"><p>' + esc(label('agent.conflict', 'The content changed after the agent started. Revalidate the package to continue.')) + '</p>' +
'<button type="button" class="rj-copilot-copy" data-agent-action="revalidate"' + (isBusy() ? ' disabled' : '') + '>' + esc(label('agent.revalidate', 'Revalidate')) + '</button></div>');
}
if (errors.length && review.status !== 'conflict') {
parts.push('<div class="rj-agent-note rj-agent-note--warn"><strong>' + esc(label('agent.validation_errors', 'Problems found')) + '</strong><ul>' +
errors.slice(0, 6).map(error => '<li>' + esc(plainText(error.message, 200)) + '</li>').join('') + '</ul></div>');
}
const targets = review.renderCheck && review.renderCheck.targets ? Object.entries(review.renderCheck.targets) : [];
const failedChecks = targets.filter(([, check]) => check && check.status === 'failed');
if (failedChecks.length) {
parts.push('<div class="rj-agent-note rj-agent-note--warn"><strong>' + esc(label('agent.render_failed', 'A page needs attention')) + '</strong><ul>' +
failedChecks.slice(0, 4).map(([target, check]) => '<li>' + esc(targetLabel(target)) + ': ' + esc(plainText((check.messages || [])[0] || '', 160)) + '</li>').join('') + '</ul></div>');
}
const safetyNotes = warnings.filter(warning => warning && typeof warning.code === 'string' && warning.code.indexOf('safety_') === 0);
if (safetyNotes.length) {
parts.push('<div class="rj-agent-note rj-agent-note--warn rj-agent-risk" role="alert"><strong>' + esc(label('agent.risk_title', 'Safety check: please look closely')) + '</strong><ul>' +
safetyNotes.slice(0, 6).map(note => '<li>' + esc(plainText(note.message, 220)) + '</li>').join('') + '</ul></div>');
}
const otherWarnings = warnings.filter(warning => !(warning && typeof warning.code === 'string' && warning.code.indexOf('safety_') === 0));
if (otherWarnings.length) {
parts.push('<details class="rj-agent-notes" data-agent-details="notes"' + (state.openDetails.notes === true ? ' open' : '') + '><summary>' + esc(label('agent.validation_warnings', 'Notes')) + ' (' + otherWarnings.length + ')</summary><ul>' +
otherWarnings.slice(0, 8).map(warning => '<li>' + esc(plainText(warning.message || warning, 200)) + '</li>').join('') + '</ul></details>');
}
if (!parts.length && review.status === 'ready') {
parts.push('<p class="rj-agent-ok">' + icon('ok') + esc(label('agent.validation_ok', 'All checks passed')) + '</p>');
}
return parts.join('');
}
function renderReviewCard() {
const review = state.review;
const run = state.run;
if (!review || !run || run.status !== 'awaiting_review' || !['staging', 'ready', 'conflict', 'failed'].includes(review.status)) return '';
const operationCount = Object.values(review.targets || {}).reduce((total, list) => total + list.length, 0);
const groups = Object.entries(review.targets || {}).map(([target, ops]) => '<section class="rj-agent-target"><h4>' + esc(targetLabel(target)) + '</h4><ul>' +
ops.map(op => renderOperation(op, operationCount <= OPEN_OPERATIONS_UP_TO)).join('') + '</ul></section>').join('');
const questions = Array.isArray(run.openQuestions) && run.openQuestions.length
? '<div class="rj-agent-block"><h4>' + esc(label('agent.open_questions', 'Open questions')) + '</h4><ul>' + run.openQuestions.map(question => '<li>' + esc(String(question)) + '</li>').join('') + '</ul></div>' : '';
const previews = Array.isArray(review.previewLinks) ? review.previewLinks : [];
const canApply = review.status === 'ready' && !isBusy();
const previewButtons = previews.slice(0, 3).map((link, index) => '<button type="button" class="rj-copilot-copy" data-agent-action="preview" data-arg="' + index + '"' + (review.status === 'ready' ? '' : ' disabled') +
' title="' + esc(label('agent.preview_hint', 'Opens the page with the changes. Only you see it.')) + '">' +
esc(previews.length > 1 ? label('agent.preview_page', 'Preview: {name}', { name: plainText(link.target.replace(/^[a-z]+:/, ''), 30) }) : label('agent.preview', 'Preview')) + '</button>').join('');
return '<section class="rj-agent-package" aria-label="' + esc(label('agent.package_title', 'Change package')) + '">' +
'<header><strong>' + esc(label('agent.package_title', 'Change package')) + '</strong><span class="rj-agent-tag' + (review.status === 'ready' ? ' rj-agent-tag--ok' : ' rj-agent-tag--warn') + '">' +
esc(review.status === 'ready' ? label('agent.status_ready', 'Ready') : review.status === 'conflict' ? label('agent.status_conflict', 'Out of date') : label('agent.status_check', 'Needs attention')) + '</span></header>' +
(run.summary ? '<p class="rj-agent-summary">' + esc(String(run.summary)) + '</p>' : '') + questions +
renderChecks(review) + groups + renderMedia(review) + renderSources(review) +
'<footer class="rj-agent-actions">' + previewButtons +
'<button type="button" class="rj-copilot-copy" data-agent-action="changes"' + (isBusy() ? ' disabled' : '') + '>' + esc(label('agent.request_changes', 'Request changes')) + '</button>' +
'<button type="button" class="rj-copilot-copy" data-agent-action="discard"' + (isBusy() ? ' disabled' : '') + '>' + esc(label('agent.discard', 'Discard')) + '</button>' +
'<button type="button" class="rj-copilot-apply" data-agent-action="apply"' + (canApply ? '' : ' disabled') + '>' +
(state.busyAction === 'apply' ? '<span class="rj-copilot-spinner" aria-hidden="true"></span>' : '') + esc(label('agent.apply', 'Apply changes')) + '</button></footer></section>';
}
function renderAppliedCard() {
const review = state.review;
if (!review || !state.run) return '';
if (review.status === 'undone') {
return '<section class="rj-agent-package rj-agent-package--quiet"><p>' + esc(label('agent.undone', 'The package was undone.')) + '</p></section>';
}
if (review.status !== 'applied' || !state.applied) return '';
const canPublish = review.canPublish === true || rights.publish === true;
const rows = state.applied.items.map((item, index) => {
const action = item.published
? '<span class="rj-agent-tag rj-agent-tag--ok">' + esc(label('agent.published', 'Published')) + '</span>'
: canPublish ? '<button type="button" class="rj-copilot-copy" data-agent-action="publish" data-arg="' + index + '"' + (isBusy() ? ' disabled' : '') + '>' + esc(label('agent.publish', 'Publish')) + '</button>'
: '<span class="rj-agent-hint">' + esc(label('agent.publish_not_allowed', 'Publishing needs a role with that right.')) + '</span>';
return '<li><span>' + esc(itemLabel(item)) + '</span>' + action + '</li>';
});
return '<section class="rj-agent-package rj-agent-package--applied" aria-label="' + esc(label('agent.applied_title', 'Changes applied')) + '">' +
'<header><strong>' + esc(label('agent.applied_title', 'Changes applied')) + '</strong><span class="rj-agent-tag rj-agent-tag--ok">' + icon('ok') + '</span></header>' +
'<p class="rj-agent-summary">' + esc(state.applied.items.length
? label('agent.applied_note', 'A backup was made. Changes to existing content are live; new content is hidden until you publish it.')
: label('agent.applied_note_live', 'A backup was made. The changes are live. Undo restores the previous state.')) + '</p>' +
(rows.length ? '<ul class="rj-agent-items">' + rows.join('') + '</ul>' : '') +
'<footer class="rj-agent-actions">' +
'<button type="button" class="rj-copilot-copy" data-agent-action="reload">' + esc(label('agent.reload', 'Reload page')) + '</button>' +
'<button type="button" class="rj-copilot-copy" data-agent-action="undo"' + (isBusy() ? ' disabled' : '') + '>' + esc(label('agent.undo', 'Undo')) + '</button></footer></section>';
}
// Places in the dashboard the agent pointed to. The links open in a new tab and only ever lead to the same site.
function safeDashboardUrl(url) {
const value = String(url || '');
return /^\/(?!\/)[^\s"'<>]*$/.test(value) ? value : '';
}
function renderLinksCard() {
const run = state.run;
if (!run || !Array.isArray(run.links) || !run.links.length || !['needs_input', 'awaiting_review', 'failed'].includes(run.status)) return '';
const rows = run.links.slice(0, 4).map(link => {
const url = safeDashboardUrl(link.url);
const open = url ? '<a class="rj-agent-link rj-agent-where__open" href="' + esc(url) + '" target="_blank" rel="noopener">' + esc(label('copilot.dash_open_new_tab', 'Open in a new tab')) + '</a>' : '';
const note = link.note ? '<span class="rj-agent-hint">' + esc(plainText(link.note, 160)) + '</span>' : '';
return '<li><span><strong>' + esc(plainText(link.title, 80)) + '</strong> <span class="rj-agent-hint">' + esc(plainText(link.path, 160)) + '</span>' + note + '</span>' + open + '</li>';
});
return '<section class="rj-agent-package rj-agent-package--quiet rj-agent-where" aria-label="' + esc(label('agent.where_title', 'Where to find it')) + '"><strong>' + esc(label('agent.where_title', 'Where to find it')) + '</strong><ul class="rj-agent-items">' + rows.join('') + '</ul></section>';
}
function renderNotice() {
const notice = state.notice;
if (!notice || !notice.text) return '';
const actions = [];
if (notice.retry && state.lastGoal) actions.push('<button type="button" class="rj-copilot-copy" data-agent-action="retry">' + esc(label('agent.retry', 'Start again')) + '</button>');
if (notice.resume) actions.push('<button type="button" class="rj-copilot-copy" data-agent-action="resume">' + esc(label('agent.continue', 'Continue')) + '</button>');
return '<div class="rj-agent-note rj-agent-note--' + (notice.type === 'error' ? 'warn' : 'info') + '" role="' + (notice.type === 'error' ? 'alert' : 'status') + '"><p>' + esc(notice.text) + '</p>' + actions.join('') + '</div>';
}
function renderIntro() {
if (state.run || state.review || state.notice) return '';
return '<div class="rj-agent-intro"><strong>' + esc(label('agent.title', 'AI agent')) + '</strong><p>' +
esc(label('agent.intro', 'The agent works through your request, prepares a package of changes and shows it to you. Nothing goes live until you apply it.')) + '</p></div>';
}
function buildHtml() {
if (state.mode !== 'agent') return '';
const cards = renderIntro() + renderRunCard() + renderLinksCard() + renderReviewCard() + renderAppliedCard() + renderNotice();
return cards ? '<div class="rj-agent" data-agent-root>' + cards + '</div>' : '';
}
function renderChips() {
const box = root && root.querySelector('[data-agent-chips]');
if (!box) return;
const show = state.mode === 'agent' && (state.chips.length > 0 || state.uploads > 0);
box.hidden = !show;
if (!show) {
box.innerHTML = '';
return;
}
const chips = state.chips.map(chip => '<span class="rj-agent-chip"><img src="' + esc(chip.thumbnail) + '" alt="" width="28" height="28"><span>' + esc(plainText(chip.name, 28)) + '</span></span>').join('');
const busy = state.uploads > 0 ? '<span class="rj-agent-chip rj-agent-chip--busy"><span class="rj-copilot-spinner" aria-hidden="true"></span>' + esc(label('agent.upload_busy', 'Uploading…')) + '</span>' : '';
const clear = state.chips.length ? '<button type="button" class="rj-agent-link" data-agent-action="clear-images">' + esc(label('agent.chips_remove', 'Remove images')) + '</button>' : '';
box.innerHTML = chips + busy + clear;
}
function updateControls() {
if (!root) return;
const agent = state.mode === 'agent';
root.classList.toggle('rj-copilot--agent', agent);
root.querySelectorAll('[data-agent-action="mode"]').forEach(button => {
const pressed = button.getAttribute('data-arg') === state.mode;
button.setAttribute('aria-pressed', pressed ? 'true' : 'false');
});
const input = root.querySelector('.rj-copilot-input');
if (input) {
const run = state.run;
let key = 'agent.placeholder';
let fallback = 'Tell the agent what to change, e.g. “Add a news post about …”';
if (!agent) {
key = 'copilot.placeholder';
fallback = 'Ask how to change this page...';
} else if (isWorking()) {
key = 'agent.placeholder_busy';
fallback = 'The agent is working…';
} else if (run && run.status === 'needs_input') {
key = 'agent.placeholder_answer';
fallback = 'Answer the agent…';
} else if (run && run.status === 'awaiting_review' && state.review && ['staging', 'ready', 'conflict'].includes(state.review.status)) {
key = 'agent.placeholder_changes';
fallback = 'What should be different in the package?';
}
input.setAttribute('placeholder', label(key, fallback));
}
const attach = root.querySelector('[data-agent-action="attach"]');
if (attach) {
const canAttach = agent && rights.uploadImages === true;
attach.hidden = !canAttach;
const blocked = !!state.run && state.run.status !== 'cancelled' && state.run.status !== 'failed' && !state.applied;
attach.disabled = blocked || state.uploads > 0 || state.chips.length >= maxUploadFiles;
attach.title = blocked ? label('agent.attach_blocked', 'Attach images before you send a request.') : label('agent.attach_hint', 'Attach images (JPEG, PNG or WebP). The agent can use them in the package.');
}
const mode = root.querySelector('.rj-agent-mode');
if (mode) mode.hidden = !classicAllowed;
renderChips();
if (host) host.updateActions();
}
function update() {
if (!host || !root) return;
const next = buildHtml();
updateControls();
if (next === state.lastHtml) return;
const list = document.querySelector('[data-copilot-messages]');
const stick = !list || list.scrollHeight - list.scrollTop - list.clientHeight < 90;
host.render({ keepScroll: !stick });
}
// ------------------------------------------------------------------
// Uploads
// ------------------------------------------------------------------
function uploadFile(file) {
const form = new FormData();
form.append('action', 'ai-agent-upload');
form.append('csrf_token', host.csrfToken);
form.append('image', file, file.name);
if (state.attachedChangeset) form.append('changesetId', state.attachedChangeset);
return fetch(host.apiUrl, { method: 'POST', body: form, credentials: 'same-origin' }).then(async response => {
const data = await response.json().catch(() => null);
if (!response.ok || !data || data.success === false) throw new Error((data && data.message) || label('copilot.request_failed', 'AI Assistant request failed.'));
return data.data;
});
}
async function handleFiles(fileList) {
if (!rights.uploadImages || state.mode !== 'agent') return;
const files = Array.from(fileList || []);
if (!files.length) return;
if (state.run && !state.applied && !['cancelled', 'failed'].includes(state.run.status)) {
state.notice = { type: 'info', text: label('agent.attach_blocked', 'Attach images before you send a request.') };
update();
return;
}
state.notice = null;
for (const file of files) {
if (state.chips.length >= maxUploadFiles) {
state.notice = { type: 'info', text: label('agent.upload_limit', 'You can attach up to {count} images at once.', { count: maxUploadFiles }) };
break;
}
if (!/^image\/(jpeg|png|webp)$/.test(file.type) || !/\.(jpe?g|png|webp)$/i.test(file.name)) {
state.notice = { type: 'error', text: label('agent.upload_type', 'Only JPEG, PNG and WebP images can be attached.') };
continue;
}
if (file.size > maxUploadBytes) {
state.notice = { type: 'error', text: label('agent.upload_size', '{name} is too large (max. {size} MB).', { name: plainText(file.name, 40), size: Math.floor(maxUploadBytes / 1048576) }) };
continue;
}
state.uploads += 1;
updateControls();
try {
const data = await uploadFile(file);
state.attachedChangeset = String(data.changesetId || '');
const ref = String(data.media && data.media.mediaRef || '');
state.chips.push({ name: file.name, ref, thumbnail: mediaUrl(state.attachedChangeset, ref) });
} catch (error) {
state.notice = { type: 'error', text: label('agent.upload_failed', '{name}: {message}', { name: plainText(file.name, 40), message: error.message }) };
} finally {
state.uploads -= 1;
}
}
update();
}
async function clearImages() {
const id = state.attachedChangeset;
state.chips = [];
state.attachedChangeset = '';
update();
if (id) {
try {
await host.post('ai-agent-discard', { id });
} catch (error) {
// The staged images are cleaned up on the server anyway.
}
}
}
// ------------------------------------------------------------------
// Events
// ------------------------------------------------------------------
function onClick(event) {
const target = event.target instanceof Element ? event.target.closest('[data-agent-action]') : null;
if (!target || !root || !root.contains(target)) return;
const action = target.getAttribute('data-agent-action');
const arg = target.getAttribute('data-arg');
if (action === 'mode') {
setMode(arg === 'classic' ? 'classic' : 'agent');
} else if (action === 'cancel') {
cancelRun();
} else if (action === 'apply') {
applyPackage();
} else if (action === 'discard') {
discardPackage();
} else if (action === 'revalidate') {
revalidatePackage();
} else if (action === 'undo') {
undoPackage();
} else if (action === 'publish') {
publishItem(Number(arg));
} else if (action === 'preview') {
openPreview(Number(arg));
} else if (action === 'changes') {
state.awaitingChanges = true;
updateControls();
const input = root.querySelector('.rj-copilot-input');
if (input) input.focus();
} else if (action === 'attach') {
const file = root.querySelector('[data-agent-file]');
if (file) file.click();
} else if (action === 'clear-images') {
clearImages();
} else if (action === 'reload') {
window.location.reload();
} else if (action === 'retry') {
const goal = state.lastGoal;
state.notice = null;
clearRun();
if (goal) handleMessage(goal);
} else if (action === 'resume') {
state.notice = null;
update();
drive();
}
}
function setMode(mode) {
if (mode === 'classic' && !classicAllowed) return;
if (state.mode === mode) return;
state.mode = mode;
writeMode(mode);
updateControls();
host.render({ keepScroll: true });
}
function hasFiles(event) {
const types = event.dataTransfer && event.dataTransfer.types ? Array.from(event.dataTransfer.types) : [];
return types.indexOf('Files') !== -1;
}
function bindDrop(panel) {
let depth = 0;
const setDragging = value => {
state.dragging = value;
panel.classList.toggle('is-dragging', value);
};
panel.addEventListener('dragenter', event => {
if (state.mode !== 'agent' || !rights.uploadImages || !hasFiles(event)) return;
event.preventDefault();
depth += 1;
setDragging(true);
});
panel.addEventListener('dragover', event => {
if (state.mode !== 'agent' || !rights.uploadImages || !hasFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
});
panel.addEventListener('dragleave', () => {
depth = Math.max(0, depth - 1);
if (depth === 0) setDragging(false);
});
panel.addEventListener('drop', event => {
if (state.mode !== 'agent' || !rights.uploadImages || !hasFiles(event)) return;
event.preventDefault();
depth = 0;
setDragging(false);
handleFiles(event.dataTransfer.files);
});
}
// ------------------------------------------------------------------
// Mounting and resuming
// ------------------------------------------------------------------
function mount(shell, hostApi) {
host = hostApi;
root = shell;
if (root.querySelector('[data-agent-controls]')) return;
const form = root.querySelector('[data-copilot-form]');
const actions = root.querySelector('.rj-copilot-actions');
const panel = root.querySelector('.rj-copilot-panel');
if (!form || !actions || !panel) return;
const chips = document.createElement('div');
chips.className = 'rj-agent-chips';
chips.setAttribute('data-agent-chips', '');
chips.hidden = true;
form.insertBefore(chips, form.firstChild);
const controls = document.createElement('div');
controls.className = 'rj-agent-controls';
controls.setAttribute('data-agent-controls', '');
controls.innerHTML = '<div class="rj-agent-mode" role="group" aria-label="' + esc(label('agent.mode_label', 'Mode')) + '">' +
'<button type="button" data-agent-action="mode" data-arg="agent" aria-pressed="true" title="' + esc(label('agent.mode_agent_hint', 'The agent prepares a complete change package for you to review.')) + '">' + esc(label('agent.mode_agent', 'Agent')) + '</button>' +
'<button type="button" data-agent-action="mode" data-arg="classic" aria-pressed="false" title="' + esc(label('agent.mode_classic_hint', 'Ask questions and draft single changes step by step.')) + '">' + esc(label('agent.mode_classic', 'Classic')) + '</button></div>' +
(rights.uploadImages === true
? '<button type="button" class="rj-agent-attach" data-agent-action="attach" aria-label="' + esc(label('agent.attach', 'Attach images')) + '">' + icon('attach') + '</button>' +
'<input type="file" data-agent-file accept="image/jpeg,image/png,image/webp" multiple hidden>' : '');
actions.insertBefore(controls, actions.firstChild);
const fileInput = root.querySelector('[data-agent-file]');
if (fileInput) {
fileInput.addEventListener('change', () => {
handleFiles(fileInput.files);
fileInput.value = '';
});
}
root.addEventListener('click', onClick);
root.addEventListener('toggle', event => {
const details = event.target instanceof Element ? event.target.closest('[data-agent-details]') : null;
if (details) state.openDetails[details.getAttribute('data-agent-details')] = details.open === true;
}, true);
bindDrop(panel);
const overlay = document.createElement('div');
overlay.className = 'rj-agent-drop';
overlay.setAttribute('aria-hidden', 'true');
overlay.textContent = label('agent.drop_hint', 'Drop images here to attach them');
panel.appendChild(overlay);
updateControls();
resume();
}
async function resume() {
await resumeRun();
if (state.reviewLoading) await state.reviewLoading;
handleLaunchRequest(true);
}
async function resumeRun() {
const stored = readStoredRun();
if (!stored || state.run) return;
try {
const data = await apiGet('ai-agent-status', { runId: stored.runId });
state.lastGoal = String(stored.goal || '');
applyRunState(data, false); // an open review is loaded from there
if (data.status === 'cancelled') clearRun();
update();
if (isWorking()) drive();
} catch (error) {
if (error && (error.status === 404 || error.status === 403)) {
state.run = null;
persistRun();
}
}
update();
}
function reset() {
state.run = null;
state.review = null;
state.applied = null;
state.notice = null;
state.chips = [];
state.attachedChangeset = '';
state.lastGoal = '';
state.awaitingChanges = false;
state.openDetails = {};
persistRun();
if (root) updateControls();
}
const registered = window.RaijuAiCopilot.registerAgent({
mount,
reset,
isActive: () => state.mode === 'agent',
isBusy,
handleMessage,
render() {
state.lastHtml = buildHtml();
return state.lastHtml;
},
afterRender() {
updateControls();
}
});
if (!registered) return;
})();