/** * 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 => '
  • ' + '' + icon(step.ok ? 'ok' : 'failed') + '' + '' + esc(stepText(step.tool, step.detail)) + (step.ok ? '' : ' (' + esc(label('agent.step_failed', 'did not work')) + ')') + '
  • '); let list = ''; if (working) { list = items.length ? '
      ' + items.slice(-visibleSteps).join('') + '
    ' : ''; } else if (items.length) { const open = state.openDetails.steps === true ? ' open' : ''; list = '
    ' + esc(label('agent.steps_title', '{count} steps', { count: items.length })) + '
      ' + items.join('') + '
    '; } const head = working ? '
    ' + esc(headline) + '' + '
    ' : ''; return '
    ' + head + list + '
    '; } function describeOpValue(value) { const text = String(value == null ? '' : value); if (text === '') return '' + esc(label('agent.empty_value', '(empty)')) + ''; 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('' + esc(key) + ' ' + esc(plainText(object[key], 240))); }); if (Array.isArray(object.sections)) parts.push('sections ' + esc(String(object.sections.length))); if (parts.length) return parts.join('
    '); } } 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 '
  • ' + esc(op.label) + (op.destructive ? ' ' + esc(label('agent.destructive', 'Deletes content')) + '' : '') + '' + '
    ' + esc(label('agent.before', 'Before')) + '

    ' + describeOpValue(op.before) + '

    ' + '
    ' + esc(label('agent.after', 'After')) + '

    ' + describeOpValue(op.after) + '

  • '; } 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 ? '' + esc(String(media.credit.attribution)) + '' : ''; const ai = media.ai === 'generated' ? '' + icon('ai') + esc(label('agent.image_ai', 'AI-generated')) + '' : ''; return '
    ' + esc(sourceLabel) + '' + '
    ' + esc(sourceLabel) + credit + ai + '
    '; }); return '

    ' + esc(label('agent.images', 'Images')) + '

    ' + cards.join('') + '
    '; } 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 ? '' + esc(plainText(title, 90)) + '' : esc(plainText(title, 90)); const warning = source.warning ? '' + icon('warning') + '' : ''; return '
  • ' + warning + name + '' + esc([source.host, source.date].filter(Boolean).join(' · ')) + '
  • '; }); return '

    ' + esc(label('agent.sources', 'Sources')) + '

    '; } 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('

    ' + esc(label('agent.conflict', 'The content changed after the agent started. Revalidate the package to continue.')) + '

    ' + '
    '); } if (errors.length && review.status !== 'conflict') { parts.push('
    ' + esc(label('agent.validation_errors', 'Problems found')) + '
    '); } 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('
    ' + esc(label('agent.render_failed', 'A page needs attention')) + '
    '); } const safetyNotes = warnings.filter(warning => warning && typeof warning.code === 'string' && warning.code.indexOf('safety_') === 0); if (safetyNotes.length) { parts.push(''); } const otherWarnings = warnings.filter(warning => !(warning && typeof warning.code === 'string' && warning.code.indexOf('safety_') === 0)); if (otherWarnings.length) { parts.push('
    ' + esc(label('agent.validation_warnings', 'Notes')) + ' (' + otherWarnings.length + ')
    '); } if (!parts.length && review.status === 'ready') { parts.push('

    ' + 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(targetLabel(target)) + '

    ').join(''); const questions = Array.isArray(run.openQuestions) && run.openQuestions.length ? '

    ' + esc(label('agent.open_questions', 'Open questions')) + '

    ' : ''; const previews = Array.isArray(review.previewLinks) ? review.previewLinks : []; const canApply = review.status === 'ready' && !isBusy(); const previewButtons = previews.slice(0, 3).map((link, index) => '').join(''); return '
    ' + '
    ' + esc(label('agent.package_title', 'Change package')) + '' + 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')) + '
    ' + (run.summary ? '

    ' + esc(String(run.summary)) + '

    ' : '') + questions + renderChecks(review) + groups + renderMedia(review) + renderSources(review) + '
    ' + previewButtons + '' + '' + '
    '; } function renderAppliedCard() { const review = state.review; if (!review || !state.run) return ''; if (review.status === 'undone') { return '

    ' + esc(label('agent.undone', 'The package was undone.')) + '

    '; } 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 ? '' + esc(label('agent.published', 'Published')) + '' : canPublish ? '' : '' + esc(label('agent.publish_not_allowed', 'Publishing needs a role with that right.')) + ''; return '
  • ' + esc(itemLabel(item)) + '' + action + '
  • '; }); return '
    ' + '
    ' + esc(label('agent.applied_title', 'Changes applied')) + '' + icon('ok') + '
    ' + '

    ' + 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 ? '' : '') + '
    ' + '' + '
    '; } // 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 ? '' + esc(label('copilot.dash_open_new_tab', 'Open in a new tab')) + '' : ''; const note = link.note ? '' + esc(plainText(link.note, 160)) + '' : ''; return '
  • ' + esc(plainText(link.title, 80)) + ' ' + esc(plainText(link.path, 160)) + '' + note + '' + open + '
  • '; }); return '
    ' + esc(label('agent.where_title', 'Where to find it')) + '
    '; } function renderNotice() { const notice = state.notice; if (!notice || !notice.text) return ''; const actions = []; if (notice.retry && state.lastGoal) actions.push(''); if (notice.resume) actions.push(''); return '

    ' + esc(notice.text) + '

    ' + actions.join('') + '
    '; } function renderIntro() { if (state.run || state.review || state.notice) return ''; return '
    ' + esc(label('agent.title', 'AI agent')) + '

    ' + 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.')) + '

    '; } function buildHtml() { if (state.mode !== 'agent') return ''; const cards = renderIntro() + renderRunCard() + renderLinksCard() + renderReviewCard() + renderAppliedCard() + renderNotice(); return cards ? '
    ' + cards + '
    ' : ''; } 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 => '' + esc(plainText(chip.name, 28)) + '').join(''); const busy = state.uploads > 0 ? '' + esc(label('agent.upload_busy', 'Uploading…')) + '' : ''; const clear = state.chips.length ? '' : ''; 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 = '
    ' + '' + '
    ' + (rights.uploadImages === true ? '' + '' : ''); 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; })();