/**
* 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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)>|
/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: '',
ok: '',
failed: '',
warning: '',
ai: ''
};
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 => '
' + describeOpValue(op.before) + '
' + describeOpValue(op.after) + '
' + esc(label('agent.conflict', 'The content changed after the agent started. Revalidate the package to continue.')) + '
' + '' + icon('ok') + esc(label('agent.validation_ok', 'All checks passed')) + '
'); } 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]) => '' + esc(String(run.summary)) + '
' : '') + questions + renderChecks(review) + groups + renderMedia(review) + renderSources(review) + '' + esc(label('agent.undone', 'The package was undone.')) + '
' + 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.')) + '
' + (rows.length ? '' + esc(notice.text) + '
' + actions.join('') + '' + 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.')) + '