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

3109 lines
154 KiB
JavaScript

(function() {
'use strict';
if (window.RJ_AI_FEATURES_ENABLED === false || window.RJ_AI_COPILOT_AVAILABLE === false || window.RaijuAiCopilot) {
return;
}
const tokenMeta = document.querySelector('meta[name="csrf-token"]');
if (!tokenMeta || !tokenMeta.content) {
return;
}
const contentPageMeta = document.querySelector('meta[name="content-page"]');
const csrfToken = tokenMeta.content;
const copilotMode = window.RJ_AI_COPILOT_MODE === 'dashboard' ? 'dashboard' : 'frontend';
let contentPage = contentPageMeta && contentPageMeta.content ? String(contentPageMeta.content).trim() : inferHostContentPage();
const apiUrl = window.RJ_ADMIN_API_URL || inferAdminApiUrl();
const requestTimeoutMs = 45000;
const imageRequestTimeoutMs = 300000;
const imageJobPollMs = 12000;
const imageJobRunning = new Set();
const imageJobFinished = new Set();
const imageJobNoticeStorageKey = 'raiju.aiImageJobNotices.v1';
const imageJobNoticeLimit = 80;
let imageJobPollTimer = null;
// The agent (js/ai-agent.js) plugs in here; without it the assistant behaves exactly as before.
let agentHook = null;
const state = {
open: false,
loading: false,
loadingMessage: '',
context: null,
contextPromise: null,
messages: [],
proposals: [],
changeDraft: null,
contentDraft: null,
createdContent: null,
imageDraft: null,
lastImageResult: null,
lastApplied: null,
lastInstruction: '',
currentSessionId: '',
historyItems: [],
historyOpen: false,
historyLoaded: false,
historySaving: false,
historyTimer: null,
changeFormOpen: false,
contentFormOpen: false,
sessionExpired: false,
selectedField: null,
selectedFieldPath: '',
selectedElement: null,
limitWarnings: [],
lastFocusedBeforeOpen: null,
modal: null,
maximized: false
};
function escHtml(value) {
const div = document.createElement('div');
div.textContent = value == null ? '' : String(value);
return div.innerHTML;
}
function renderInlineMarkdown(value) {
let html = escHtml(value == null ? '' : String(value));
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/\*\*([^*\n][\s\S]*?[^*\n])\*\*/g, '<strong>$1</strong>');
html = html.replace(/__([^_\n][\s\S]*?[^_\n])__/g, '<strong>$1</strong>');
html = html.replace(/(^|[^*])\*([^*\n][^*\n]*?[^*\n])\*(?!\*)/g, '$1<em>$2</em>');
html = html.replace(/(^|[^_])_([^_\n][^_\n]*?[^_\n])_(?!_)/g, '$1<em>$2</em>');
html = html.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
return html.replace(/\n/g, '<br>');
}
function label(key, fallback, params) {
let text = (window.RJ_LANG && window.RJ_LANG[key]) || fallback || key;
Object.entries(params || {}).forEach(([name, value]) => {
text = text.replace(new RegExp('\\{' + name + '\\}', 'g'), String(value));
});
return text;
}
function readableFieldLabel(path) {
const raw = String(path || '').trim();
if (!raw) return label('copilot.unknown_field', 'selected field');
const section = raw.match(/^sections\.(\d+)\.(.+)$/);
if (section) {
return label('copilot.section_field_label', 'Section {section} {field}', {
section: Number(section[1]) + 1,
field: section[2].replace(/[._-]+/g, ' ')
});
}
return raw.replace(/[._-]+/g, ' ');
}
function truncateOptionLabel(value, limit) {
const text = String(value || '').replace(/\s+/g, ' ').trim();
const max = Number(limit || 72);
if (text.length <= max) return text;
return text.slice(0, Math.max(0, max - 1)).trimEnd() + '…';
}
function truncateTargetLabel(value, limit) {
const text = String(value || '').replace(/\s+/g, ' ').trim();
const max = Number(limit || 104);
if (text.length <= max) return text;
const match = text.match(/^(\[[^\]]+\]\s*)(.*?)(\s+-\s+[^-]+)$/);
if (!match) return truncateOptionLabel(text, max);
const prefix = match[1];
const middle = match[2];
const suffix = match[3];
const room = max - prefix.length - suffix.length - 1;
if (room < 12) return truncateOptionLabel(text, max);
return prefix + truncateOptionLabel(middle, room) + suffix;
}
function currentFieldPreview(field) {
if (!field) return '';
return String(field.preview || '').trim();
}
function renderCurrentFieldPreview(field) {
const content = currentFieldPreview(field);
let body = label('copilot.current_field_select', 'Choose a target field to preview its current content.');
if (field) {
body = content || label('copilot.current_field_empty', 'This field is currently empty.');
}
return '<div class="rj-copilot-current-field" data-change-draft-current>' +
'<span>' + escHtml(label('copilot.current_field_content', 'Current content')) + '</span>' +
'<p' + (content ? '' : ' class="is-empty"') + '>' + escHtml(body) + '</p>' +
'</div>';
}
function icon(name) {
const icons = {
history: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/><path d="M12 7v5l3 2"/></svg>',
newChat: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.1 2.1 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>',
trash: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14Z"/></svg>',
maximize: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M16 3h3a2 2 0 0 1 2 2v3"/><path d="M8 21H5a2 2 0 0 1-2-2v-3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>',
minimize: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M16 3v3a2 2 0 0 0 2 2h3"/><path d="M8 21v-3a2 2 0 0 0-2-2H3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></svg>',
close: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>'
};
return icons[name] || '';
}
function inferAdminApiUrl() {
const script = document.currentScript && document.currentScript.src ? document.currentScript.src : '';
if (script) {
try {
const url = new URL(script, window.location.href);
url.pathname = url.pathname.replace(/\/js\/ai-copilot\.js$/, '/admin/api.php');
url.search = '';
url.hash = '';
return url.href;
} catch (error) {
// Fall through to the root-relative default.
}
}
return '/admin/api.php';
}
function inferHostContentPage() {
if (window.__cmsNewsPost && window.__cmsNewsPost.id) {
return 'news:' + String(window.__cmsNewsPost.id).trim();
}
const newsPostEl = document.querySelector('[data-news-post]');
if (newsPostEl && newsPostEl.getAttribute('data-news-post')) {
return 'news:' + String(newsPostEl.getAttribute('data-news-post')).trim();
}
const editablePages = Array.from(document.querySelectorAll('[data-page]'))
.map(element => String(element.getAttribute('data-page') || '').trim())
.filter(Boolean);
const uniquePages = editablePages.filter((page, index) => editablePages.indexOf(page) === index);
if (uniquePages.length === 1) {
return uniquePages[0];
}
return '';
}
function syncContentPageFromHost() {
if (copilotMode !== 'dashboard' || typeof window.RJ_AI_COPILOT_GET_CONTENT_PAGE !== 'function') {
const next = contentPage || inferHostContentPage();
if (next && next !== contentPage) {
contentPage = next;
state.context = null;
state.contextPromise = null;
state.selectedField = null;
state.selectedFieldPath = '';
clearSelectedElement();
}
return contentPage;
}
const next = String(window.RJ_AI_COPILOT_GET_CONTENT_PAGE() || '').trim();
if (next !== contentPage) {
contentPage = next;
state.context = null;
state.contextPromise = null;
state.selectedField = null;
state.selectedFieldPath = '';
clearSelectedElement();
}
return contentPage;
}
function assistantUiLanguage() {
const raw = String(window.RJ_AI_ASSISTANT_LANGUAGE || document.documentElement.lang || navigator.language || 'en').trim();
const normalized = raw.replace('_', '-').match(/^[a-zA-Z]{2,3}(?:-[a-zA-Z]{2})?/);
return normalized ? normalized[0] : 'en';
}
function post(action, payload, timeoutMs) {
const form = new FormData();
form.append('action', action);
form.append('csrf_token', csrfToken);
Object.entries(payload || {}).forEach(([key, value]) => {
form.append(key, typeof value === 'string' ? value : JSON.stringify(value));
});
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeout = controller
? window.setTimeout(() => controller.abort(), timeoutMs || requestTimeoutMs)
: null;
return fetch(apiUrl, {
method: 'POST',
body: form,
credentials: 'same-origin',
signal: controller ? controller.signal : undefined
}).then(async response => {
const data = await response.json().catch(() => null);
if (!response.ok || !data || data.success === false) {
if (data && data.session_expired) {
state.sessionExpired = true;
const expired = new Error(label('copilot.session_expired', 'Your session expired. Please log in again in the dashboard, then reload this page.'));
expired.sessionExpired = true;
throw expired;
}
throw new Error((data && data.message) || label('copilot.request_failed', 'AI Assistant request failed.'));
}
return data.data;
}).catch(error => {
if (error && error.name === 'AbortError') {
if (action === 'ai-copilot-generate-image') {
throw new Error(label('copilot.image_request_timeout', 'Image generation timed out. No image preview was created.'));
}
throw new Error(label('copilot.request_timeout', 'AI Assistant request timed out. No change was applied.'));
}
throw error;
}).finally(() => {
if (timeout) window.clearTimeout(timeout);
});
}
function supportsStreaming() {
return typeof TextDecoder !== 'undefined'
&& typeof window.ReadableStream !== 'undefined'
&& typeof Response !== 'undefined';
}
// POST to an SSE action and surface text deltas as they arrive. Resolves
// with the final "done" payload; throws on gate errors or stream errors.
function postStream(action, payload, onDelta) {
const form = new FormData();
form.append('action', action);
form.append('csrf_token', csrfToken);
Object.entries(payload || {}).forEach(([key, value]) => {
form.append(key, typeof value === 'string' ? value : JSON.stringify(value));
});
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
let idleTimer = null;
const armIdleTimeout = () => {
if (!controller) return;
if (idleTimer) window.clearTimeout(idleTimer);
idleTimer = window.setTimeout(() => controller.abort(), 60000);
};
armIdleTimeout();
return fetch(apiUrl, {
method: 'POST',
body: form,
credentials: 'same-origin',
signal: controller ? controller.signal : undefined
}).then(response => {
const contentType = String(response.headers.get('Content-Type') || '');
if (!response.ok || contentType.indexOf('text/event-stream') === -1 || !response.body || !response.body.getReader) {
return response.json().catch(() => null).then(data => {
if (data && data.success !== false && data.data) return data.data;
if (data && data.session_expired) {
state.sessionExpired = true;
const expired = new Error(label('copilot.session_expired', 'Your session expired. Please log in again in the dashboard, then reload this page.'));
expired.sessionExpired = true;
throw expired;
}
throw new Error((data && data.message) || label('copilot.request_failed', 'AI Assistant request failed.'));
});
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let donePayload = null;
let streamError = null;
const handleLine = line => {
line = line.trim();
if (!line || line.indexOf('data:') !== 0) return;
let event = null;
try { event = JSON.parse(line.slice(5).trim()); } catch (e) { return; }
if (!event) return;
if (typeof event.delta === 'string' && onDelta) onDelta(event.delta);
if (event.error) streamError = new Error(event.error);
if (event.done) donePayload = event;
};
const pump = () => reader.read().then(({ value, done }) => {
armIdleTimeout();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
let idx;
while ((idx = buffer.indexOf('\n')) !== -1) {
handleLine(buffer.slice(0, idx));
buffer = buffer.slice(idx + 1);
}
if (!done) return pump();
if (buffer) handleLine(buffer);
if (streamError) throw streamError;
return donePayload;
});
return pump();
}).catch(error => {
if (error && error.name === 'AbortError') {
throw new Error(label('copilot.request_timeout', 'AI Assistant request timed out. No change was applied.'));
}
throw error;
}).finally(() => {
if (idleTimer) window.clearTimeout(idleTimer);
});
}
function runImageJob(job) {
if (!job || !job.id || imageJobRunning.has(job.id)) return;
imageJobRunning.add(job.id);
const form = new FormData();
form.append('action', 'ai-image-job-run');
form.append('job_id', job.id);
form.append('csrf_token', csrfToken);
fetch(apiUrl, {
method: 'POST',
body: form,
credentials: 'same-origin',
cache: 'no-store'
}).catch(() => {
// Polling will surface the persisted error state if the runner failed.
}).finally(() => {
imageJobRunning.delete(job.id);
});
}
function imageJobNoticeKey(job) {
if (!job || !job.id) return '';
return [job.id, job.status || '', job.finishedAt || job.updatedAt || ''].join(':');
}
function readImageJobNotices() {
try {
const raw = window.localStorage ? window.localStorage.getItem(imageJobNoticeStorageKey) : '';
const notices = raw ? JSON.parse(raw) : {};
return notices && typeof notices === 'object' ? notices : {};
} catch (error) {
return {};
}
}
function writeImageJobNotices(notices) {
if (!window.localStorage) return;
try {
const entries = Object.entries(notices || {})
.sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
.slice(0, imageJobNoticeLimit);
window.localStorage.setItem(imageJobNoticeStorageKey, JSON.stringify(Object.fromEntries(entries)));
} catch (error) {
// Notification history is only used to avoid repeated UI messages.
}
}
function imageJobNoticeWasShown(job) {
const key = imageJobNoticeKey(job);
if (!key) return false;
return !!readImageJobNotices()[key];
}
function markImageJobNoticeShown(job) {
const key = imageJobNoticeKey(job);
if (!key) return;
const notices = readImageJobNotices();
notices[key] = Date.now();
writeImageJobNotices(notices);
}
function handleImageJobFinished(job, activeJobId) {
if (!job || !job.id) return;
const noticeAlreadyShown = imageJobFinished.has(job.id) || imageJobNoticeWasShown(job);
imageJobFinished.add(job.id);
const isActiveJob = job.id === activeJobId;
if (!isActiveJob || noticeAlreadyShown) {
markImageJobNoticeShown(job);
return;
}
if (job.status === 'success' && job.result && job.result.proposal) {
if (job.result.context) state.context = job.result.context;
state.imageDraft = null;
state.proposals = [job.result.proposal];
state.messages.push({ role: 'assistant', content: label('copilot.image_draft_ready', 'Generated image ready. Review the preview card before applying it.') });
} else if (job.status === 'error') {
state.messages.push({ role: 'assistant', content: job.error || label('copilot.image_request_failed', 'Image generation failed.') });
}
markImageJobNoticeShown(job);
setLoading(false);
renderMessages();
}
function pollImageJobs(activeJobId) {
const url = apiUrl + '?action=ai-image-jobs&open_only=0&limit=30&csrf_token=' + encodeURIComponent(csrfToken);
fetch(url, { credentials: 'same-origin', cache: 'no-store' })
.then(response => response.json())
.then(result => {
if (!result || result.success === false) throw new Error(result && result.message ? result.message : 'Error');
const jobs = Array.isArray(result.data && result.data.jobs) ? result.data.jobs : [];
const openJobs = jobs.filter(job => job && (job.status === 'queued' || job.status === 'running'));
const runningJobs = openJobs.filter(job => job.status === 'running');
const queuedJobs = openJobs.filter(job => job.status === 'queued');
if (!runningJobs.length && queuedJobs.length) {
runImageJob(queuedJobs[queuedJobs.length - 1]);
}
jobs
.filter(job => job && (job.status === 'success' || job.status === 'error'))
.forEach(job => handleImageJobFinished(job, activeJobId));
if (openJobs.length) {
imageJobPollTimer = window.setTimeout(() => pollImageJobs(activeJobId || ''), imageJobPollMs);
} else {
imageJobPollTimer = null;
}
})
.catch(() => {
imageJobPollTimer = window.setTimeout(() => pollImageJobs(activeJobId || ''), 15000);
});
}
function startImageJobPolling(activeJobId) {
if (imageJobPollTimer) {
window.clearTimeout(imageJobPollTimer);
imageJobPollTimer = null;
}
pollImageJobs(activeJobId || '');
}
function setLoading(isLoading, messageKey, fallback) {
state.loading = isLoading;
state.loadingMessage = isLoading ? label(messageKey || 'copilot.working', fallback || 'AI Assistant is working...') : '';
}
// Limits that are nearly or fully used, shown above the chat so they can be raised in time.
function setLimitWarnings(warnings) {
const next = Array.isArray(warnings) ? warnings.filter(item => item && typeof item.kind === 'string') : [];
if (JSON.stringify(next) === JSON.stringify(state.limitWarnings)) return;
state.limitWarnings = next;
renderMessages({ keepScroll: true });
}
function renderLimitWarnings() {
if (!state.limitWarnings.length) return '';
const money = cents => (Number(cents || 0) / 100).toFixed(2) + ' EUR';
const lines = state.limitWarnings.map(warning => {
const value = warning.kind === 'month' ? money : String;
const text = label(warning.level === 'reached' ? 'ai.limit_reached' : 'ai.limit_warning', '', {
kind: label('ai.limit_kind_' + warning.kind, warning.kind),
used: value(warning.used),
limit: value(warning.limit),
percent: warning.percent
});
return '<p class="rj-copilot-limit__line rj-copilot-limit__line--' + escHtml(warning.level) + '">' + escHtml(text) + '</p>';
});
return '<div class="rj-copilot-limit" role="status">' + lines.join('') + '<p class="rj-copilot-limit__hint">' + escHtml(label('ai.limit_ask_admin', '')) + '</p></div>';
}
function renderMessages(options) {
const list = document.querySelector('[data-copilot-messages]');
if (!list) return;
const keepScroll = !!(options && options.keepScroll === true);
const scrollTop = list.scrollTop;
const intro = state.context ? contextIntro(state.context) : '';
const messages = state.messages.map((message, messageIndex) => {
return '<div class="rj-copilot-msg rj-copilot-msg--' + escHtml(message.role) + '">' +
'<div class="rj-copilot-msg__bubble">' + renderInlineMarkdown(message.content) + '</div>' +
'</div>' + renderDashboardActions(message, messageIndex);
}).join('');
const loading = state.loading
? '<div class="rj-copilot-msg rj-copilot-msg--assistant"><div class="rj-copilot-msg__bubble rj-copilot-msg__bubble--loading"><span class="rj-copilot-spinner" aria-hidden="true"></span>' + escHtml(state.loadingMessage || label('copilot.working', 'AI Assistant is working...')) + '</div></div>'
: '';
const agentHtml = agentHook && agentHook.isActive() ? agentHook.render() : '';
list.innerHTML = renderLimitWarnings() + intro + messages + renderContentDraft() + renderCreatedContent() + renderLastImageResult() + renderUndo() + renderProposals() + agentHtml + loading;
list.setAttribute('aria-busy', state.loading ? 'true' : 'false');
list.scrollTop = keepScroll ? scrollTop : list.scrollHeight;
if (agentHook) agentHook.afterRender(list);
updateActionAvailability();
renderModal();
persistSession();
}
// ---- Dashboard actions (dashboard assistant only) --------------------------------------------------------------
// The server offers cards (open a section, change the dashboard look); the person confirms with a button and
// the dashboard performs the action through its own settings forms.
// Links the server builds lead to the dashboard of this site only: a path on the same host, opened in a new tab.
function safeDashboardUrl(url) {
const value = String(url || '');
return /^\/(?!\/)[^\s"'<>]*$/.test(value) ? value : '';
}
function dashboardNewTabLink(action) {
const url = safeDashboardUrl(action && action.url);
return url ? '<a class="rj-copilot-dash-link" href="' + escHtml(url) + '" target="_blank" rel="noopener">' + escHtml(label('copilot.dash_open_new_tab', 'Open in a new tab')) + '</a>' : '';
}
function renderDashboardActions(message, messageIndex) {
if (!Array.isArray(message.actions) || !message.actions.length) return '';
const cards = message.actions.map((action, actionIndex) => {
if (!action || action.status === 'dismissed') return '';
const ref = ' data-dash-message="' + messageIndex + '" data-dash-action="' + actionIndex + '"';
const detail = action.detail ? '<span>' + escHtml(action.detail) + '</span>' : '';
let footer = '';
if (action.type === 'link') {
footer = dashboardNewTabLink(action);
} else if (action.type === 'noop') {
footer = '';
} else if (action.status === 'working') {
footer = '<em>' + escHtml(label('copilot.dash_applying', 'Working...')) + '</em>';
} else if (action.status === 'done') {
footer = '<em class="rj-copilot-dash__ok">' + escHtml(action.note || label('copilot.dash_done', 'Done.')) + '</em>';
} else {
const run = action.type === 'open' ? label('copilot.dash_open_btn', 'Open') : label('copilot.dash_apply', 'Apply');
footer = (action.status === 'failed' ? '<em class="rj-copilot-dash__error">' + escHtml(action.note || label('copilot.dash_failed', 'That did not work.')) + '</em>' : '') +
'<button type="button" class="rj-copilot-dash-run"' + ref + '>' + escHtml(run) + '</button>' +
(action.type === 'open' ? dashboardNewTabLink(action) : '') +
'<button type="button" class="rj-copilot-dash-dismiss"' + ref + '>' + escHtml(label('copilot.dash_dismiss', 'Dismiss')) + '</button>';
}
return '<section class="rj-copilot-dash' + (action.type === 'link' ? ' rj-copilot-dash--link' : '') + '"><div><strong>' + escHtml(action.title || '') + '</strong>' + detail + '</div>' + (footer ? '<footer>' + footer + '</footer>' : '') + '</section>';
}).join('');
return cards ? '<div class="rj-copilot-dash-list">' + cards + '</div>' : '';
}
async function runDashboardAction(messageIndex, actionIndex) {
const message = state.messages[messageIndex];
const action = message && Array.isArray(message.actions) ? message.actions[actionIndex] : null;
if (!action || action.status === 'working' || action.status === 'done' || !['open', 'setting'].includes(action.type)) return;
const runner = window.RaijuDashboardActions;
if (!runner || typeof runner.run !== 'function') {
action.status = 'failed';
action.note = label('copilot.dash_failed', 'That did not work.');
renderMessages({ keepScroll: true });
return;
}
action.status = 'working';
renderMessages({ keepScroll: true });
try {
const outcome = await runner.run({ type: action.type, section: action.section, settingsTab: action.settingsTab, setting: action.setting, value: action.value });
action.status = 'done';
action.note = outcome && outcome.message ? outcome.message : '';
} catch (error) {
action.status = 'failed';
action.note = error && error.message ? error.message : '';
}
renderMessages({ keepScroll: true });
}
function renderChangeDraftForm() {
const draft = state.changeDraft || {};
const instruction = String(draft.instruction || '');
const selected = draft.field || state.selectedField || null;
const selectedRef = selected ? String(selected.id || selected.path || '') : '';
const targetFields = getChangeTargetFields();
const fieldOptions = '<option value="">' + escHtml(label('copilot.choose_target_field_placeholder', 'Select a field...')) + '</option>' + targetFields.map(field => {
const ref = String(field.id || field.path || '');
const selectedAttr = ref && ref === selectedRef ? ' selected' : '';
return '<option value="' + escHtml(ref) + '"' + selectedAttr + '>' + escHtml(changeTargetLabel(field)) + '</option>';
}).join('');
return '<div class="rj-copilot-modal-backdrop" role="presentation">' +
'<section class="rj-copilot-modal rj-copilot-modal--wide" role="dialog" aria-modal="true" aria-labelledby="rj-copilot-change-modal-title">' +
'<header><strong id="rj-copilot-change-modal-title">' + escHtml(label('copilot.change_details_title', 'Prepare change')) + '</strong><span>' + escHtml(label('copilot.change_details_hint', 'Describe the change. The Assistant will create a reviewable preview before anything is applied.')) + '</span></header>' +
'<form class="rj-copilot-change-form">' +
'<div class="rj-copilot-modal__body"><div class="rj-copilot-content-fields">' +
'<label><span>' + escHtml(label('copilot.choose_target_field', 'Choose target field')) + ' *</span><select data-change-draft-field>' + fieldOptions + '</select></label>' +
'<p class="rj-copilot-field-hint">' + escHtml(label('copilot.choose_target_field_hint', 'You can also click an editable element on the page before opening this dialog.')) + '</p>' +
renderCurrentFieldPreview(selected) +
'<label><span>' + escHtml(label('copilot.change_instruction', 'Change instruction')) + ' *</span><textarea rows="5" data-change-draft-instruction placeholder="' + escHtml(label('copilot.change_instruction_placeholder', 'Describe what should change on this page...')) + '">' + escHtml(instruction) + '</textarea></label>' +
'</div></div>' +
'<footer>' +
'<button type="button" class="rj-copilot-change-cancel">' + escHtml(label('copilot.modal_cancel', 'Cancel')) + '</button>' +
'<button type="submit" class="rj-copilot-change-submit">' + escHtml(label('copilot.prepare_preview', 'Prepare preview')) + '</button>' +
'</footer>' +
'</form>' +
'</section>' +
'</div>';
}
function renderContentDraft() {
const draftWrap = state.contentDraft;
if (!draftWrap || !draftWrap.draft) return '';
const draft = draftWrap.draft;
const fields = Object.entries(draft.draft || {}).map(([key, value]) => {
return '<div><span>' + escHtml(key) + '</span><p>' + escHtml(value == null ? '' : String(value)).replace(/\n/g, '<br>') + '</p></div>';
}).join('');
const missing = Array.isArray(draft.missing) && draft.missing.length
? '<p class="rj-copilot-create__missing">' + escHtml(label('copilot.missing', 'Missing: {fields}', { fields: draft.missing.join(', ') })) + '</p>'
: '';
const action = draft.canCreate
? '<button type="button" class="rj-copilot-create-btn">' + escHtml(label('copilot.create_draft', 'Create draft')) + '</button>'
: '<button type="button" class="rj-copilot-details-btn">' + escHtml(label('copilot.complete_details', 'Complete details')) + '</button>';
return '<section class="rj-copilot-create" aria-label="Draft content preview">' +
'<header><strong>' + escHtml(label('copilot.content_draft_title', '{type} draft', { type: (draft.contentType || 'content').toUpperCase() })) + '</strong><span>' + escHtml(draft.canCreate ? label('copilot.ready_to_create', 'Ready to create') : label('copilot.needs_details', 'Needs details')) + '</span></header>' +
'<div class="rj-copilot-create__fields">' + fields + '</div>' +
(draft.safetyNote ? '<p class="rj-copilot-safety" role="alert">' + escHtml(draft.safetyNote) + '</p>' : '') +
missing +
'<footer>' + action + '</footer>' +
'</section>';
}
function renderCreatedContent() {
const created = state.createdContent;
if (!created || !created.id) return '';
const status = created.private
? label('copilot.private_draft', 'Private draft')
: (created.hidden ? label('copilot.hidden_draft', 'Hidden draft') : label('copilot.draft_created', 'Draft created'));
const adminLink = created.adminUrl
? '<a href="' + escHtml(created.adminUrl) + '">' + escHtml(label('copilot.open_dashboard', 'Open in dashboard')) + '</a>'
: '';
const publicLink = created.publicUrl
? '<a href="' + escHtml(created.publicUrl) + '">' + escHtml(label('copilot.open_preview', 'Open preview URL')) + '</a>'
: '';
const publishBtn = created.publishable && (created.private || created.hidden)
? '<button type="button" class="rj-copilot-publish-btn">' + escHtml(label('copilot.publish', 'Publish')) + '</button>'
: '';
return '<section class="rj-copilot-created" aria-label="Created content">' +
'<header><strong>' + escHtml(label('copilot.created_title', '{type} created', { type: (created.contentType || 'content').toUpperCase() })) + '</strong><span>' + escHtml(status) + '</span></header>' +
'<p>' + escHtml(created.id) + '</p>' +
'<footer>' + adminLink + publicLink + publishBtn + '</footer>' +
'</section>';
}
function renderLastImageResult() {
const image = state.lastImageResult;
if (!image || !image.path) return '';
const alt = image.alt
? '<span>' + escHtml(label('copilot.alt_text', 'Alt text')) + ': ' + escHtml(image.alt) + '</span>'
: '';
const prompt = image.prompt
? '<span>' + escHtml(label('copilot.image_prompt', 'Prompt')) + ': ' + escHtml(raijuShortSummary(image.prompt)) + '</span>'
: '';
const src = image.path.charAt(0) === '/' ? image.path : '/' + image.path;
return '<section class="rj-copilot-image-result" aria-label="' + escHtml(label('copilot.generated_image', 'Generated image')) + '">' +
'<img src="' + escHtml(src) + '" alt="">' +
'<div>' +
'<strong>' + escHtml(label('copilot.generated_image_applied', 'Generated image applied')) + '</strong>' +
'<span>' + escHtml(image.label || image.field || '') + '</span>' +
prompt +
alt +
'</div>' +
'</section>';
}
function imageFieldPreviewSrc(field) {
if (!field) return '';
const candidates = [];
const domSrc = imageFieldDomSrc(field.path);
if (domSrc) candidates.push(domSrc);
if (field.preview) candidates.push(field.preview);
if (field.value) candidates.push(field.value);
for (const candidate of candidates) {
const src = normalizeImagePreviewSrc(candidate);
if (src) return src;
}
return '';
}
function imageFieldDomSrc(path) {
if (!path) return '';
try {
const element = document.querySelector(fieldSelector(path));
if (!element) return '';
const img = element.matches('img') ? element : element.querySelector('img');
return img ? (img.currentSrc || img.src || img.getAttribute('src') || '') : '';
} catch (error) {
return '';
}
}
function normalizeImagePreviewSrc(value) {
const src = String(value || '').trim();
if (!src) return '';
if (/^(https?:|data:image\/|blob:)/i.test(src)) return src;
if (/^\/assets\/images\//i.test(src)) return src;
if (/^assets\/images\//i.test(src)) return '/' + src;
return '';
}
function renderImageDraftOptions() {
const draft = state.imageDraft;
if (!draft || !Array.isArray(draft.fields) || !draft.fields.length) return '';
const selectedFieldId = draft.fieldId || (draft.fields[0].id || draft.fields[0].path);
const fieldButtons = draft.fields.map(field => {
const fieldId = field.id || field.path;
const active = fieldId === selectedFieldId ? ' is-active' : '';
const previewSrc = imageFieldPreviewSrc(field);
const preview = previewSrc
? '<img src="' + escHtml(previewSrc) + '" alt="" loading="lazy" referrerpolicy="no-referrer">'
: '<span>' + escHtml(field.label || field.path) + '</span>';
return '<button type="button" class="rj-copilot-image-field' + active + '" data-image-draft-field="' + escHtml(fieldId) + '">' +
preview +
'<strong>' + escHtml(field.label || field.path) + '</strong>' +
'</button>';
}).join('');
const modeButton = mode => {
const active = draft.mode === mode ? ' is-active' : '';
const key = mode === 'edit' ? 'copilot.image_mode_edit' : 'copilot.image_mode_generate';
const fallback = mode === 'edit' ? 'With reference image' : 'From prompt';
const pressed = draft.mode === mode ? 'true' : 'false';
return '<button type="button" class="rj-copilot-image-mode' + active + '" data-image-draft-mode="' + mode + '" aria-pressed="' + pressed + '">' + escHtml(label(key, fallback)) + '</button>';
};
const optionSelect = (name, labelKey, fallback, options) => {
const current = draft.options && draft.options[name] != null ? String(draft.options[name]) : '';
const optionHtml = options.map(option => {
const selected = String(option.value) === current ? ' selected' : '';
return '<option value="' + escHtml(option.value) + '"' + selected + '>' + escHtml(option.label) + '</option>';
}).join('');
return '<label><span>' + escHtml(label(labelKey, fallback)) + '</span><select data-image-draft-option="' + escHtml(name) + '">' + optionHtml + '</select></label>';
};
const promptHtml = '<label class="rj-copilot-image-prompt">' +
'<span>' + escHtml(label('copilot.image_prompt', 'Prompt')) + '</span>' +
'<textarea rows="3" data-image-draft-prompt placeholder="' + escHtml(label('copilot.image_prompt_placeholder', 'Describe the image to generate...')) + '">' + escHtml(draft.instruction || '') + '</textarea>' +
'</label>';
const optionsHtml = '<div class="rj-copilot-image-settings">' +
optionSelect('size', 'copilot.image_size', 'Size', [
{ value: 'auto', label: label('copilot.image_size_auto', 'Auto') },
{ value: '1024x1024', label: label('copilot.image_size_square', 'Square') },
{ value: '1536x1024', label: label('copilot.image_size_landscape', 'Landscape') },
{ value: '1024x1536', label: label('copilot.image_size_portrait', 'Portrait') }
]) +
optionSelect('count', 'copilot.image_count', 'Images', [
{ value: '1', label: '1' },
{ value: '2', label: '2' },
{ value: '3', label: '3' },
{ value: '4', label: '4' }
]) +
optionSelect('outputFormat', 'copilot.image_format', 'Format', [
{ value: 'webp', label: 'WebP' },
{ value: 'png', label: 'PNG' },
{ value: 'jpeg', label: 'JPEG' }
]) +
optionSelect('quality', 'copilot.image_quality', 'Quality', [
{ value: 'auto', label: label('copilot.image_quality_auto', 'Auto') },
{ value: 'low', label: label('copilot.image_quality_low', 'Low') },
{ value: 'medium', label: label('copilot.image_quality_medium', 'Medium') },
{ value: 'high', label: label('copilot.image_quality_high', 'High') }
]) +
'</div>';
return '<section class="rj-copilot-image-options" aria-label="Image draft options">' +
'<div class="rj-copilot-image-fields">' + fieldButtons + '</div>' +
'<div class="rj-copilot-image-mode-group"><span>' + escHtml(label('copilot.image_mode_label', 'Mode')) + '</span><div class="rj-copilot-image-modes" role="group" aria-label="' + escHtml(label('copilot.image_mode_label', 'Mode')) + '">' + modeButton('generate') + modeButton('edit') + '</div></div>' +
promptHtml +
optionsHtml +
'<footer><button type="button" class="rj-copilot-image-run">' + escHtml(label('copilot.image_generate_action', 'Generate image')) + '</button></footer>' +
'</section>';
}
function renderUndo() {
const undo = state.lastApplied && state.lastApplied.undo;
if (!undo || !undo.backup) return '';
const undoDetail = state.lastApplied.label || readableFieldLabel(state.lastApplied.path || undo.path || '');
return '<section class="rj-copilot-undo" aria-label="Undo last AI change">' +
'<div><strong>' + escHtml(label('copilot.last_change', 'Last AI change applied')) + '</strong><span>' + escHtml(label('copilot.undo_detail', 'Changed: {field}', { field: undoDetail })) + '</span></div>' +
'<button type="button" class="rj-copilot-undo-btn">' + escHtml(label('copilot.undo', 'Undo')) + '</button>' +
'</section>';
}
function renderProposals() {
if (!state.proposals.length) return '';
const summary = state.proposals.length > 1
? '<section class="rj-copilot-proposal-summary" aria-label="AI suggestion summary">' +
'<header><strong>' + escHtml(label('copilot.proposal_summary_title', 'Planned changes')) + '</strong><span>' + escHtml(label('copilot.proposal_summary_hint', '{count} changes will be reviewed together.', { count: state.proposals.length })) + '</span></header>' +
'<ul>' + state.proposals.map(proposal => {
return '<li><strong>' + escHtml(proposal.label || proposal.path) + '</strong><span>' + escHtml(proposalSummaryText(proposal)) + '</span></li>';
}).join('') + '</ul>' +
'<footer><button type="button" class="rj-copilot-apply-all">' + escHtml(label('copilot.apply_all', 'Apply all')) + '</button></footer>' +
'</section>'
: '';
const cards = state.proposals.map((proposal, index) => {
const isImage = proposal.type === 'image';
const currentHtml = renderProposalValue(proposal, proposal.current);
const draftHtml = renderProposalValue(proposal, proposal.preview || proposal.value || '');
const variantsHtml = isImage && Array.isArray(proposal.paths) && proposal.paths.length > 1
? '<div class="rj-copilot-variants">' + proposal.paths.map((path, variantIndex) => {
const active = path === proposal.value ? ' is-active' : '';
return '<button type="button" class="rj-copilot-variant' + active + '" data-proposal-variant="' + index + ':' + variantIndex + '"><img src="' + escHtml(path) + '" alt=""></button>';
}).join('') + '</div>'
: '';
const altHtml = isImage && proposal.altValue
? '<label class="rj-copilot-alt"><span>' + escHtml(label('copilot.alt_text', 'Alt text')) + '</span><input type="text" value="' + escHtml(proposal.altValue) + '" data-proposal-alt="' + index + '"></label>'
: '';
return '<article class="rj-copilot-proposal">' +
'<header><strong>' + escHtml(proposal.label || proposal.path) + '</strong><span>' + escHtml(proposal.type || 'field') + '</span></header>' +
'<div class="rj-copilot-proposal__diff">' +
'<div><span>' + escHtml(label('copilot.current', 'Current')) + '</span><p>' + currentHtml + '</p></div>' +
'<div><span>' + escHtml(label('copilot.draft', 'Draft')) + '</span><p>' + draftHtml + '</p></div>' +
'</div>' +
variantsHtml +
altHtml +
(proposal.reason ? '<p class="rj-copilot-proposal__reason">' + escHtml(proposal.reason) + '</p>' : '') +
(proposal.safetyNote ? '<p class="rj-copilot-safety" role="alert">' + escHtml(proposal.safetyNote) + '</p>' : '') +
'<footer>' +
'<span>' + escHtml(label('copilot.review_backup', 'Review before applying. A backup is created server-side.')) + '</span>' +
'<div class="rj-copilot-proposal__actions">' +
'<button type="button" class="rj-copilot-copy" data-proposal-copy="' + index + '">' + escHtml(label('copilot.copy', 'Copy')) + '</button>' +
'<button type="button" class="rj-copilot-apply" data-proposal-index="' + index + '">' + escHtml(label('copilot.apply', 'Apply')) + '</button>' +
'</div>' +
'</footer>' +
'</article>';
}).join('');
return '<section class="rj-copilot-proposals" aria-label="Draft AI suggestions">' + summary + cards + '</section>';
}
function proposalSummaryText(proposal) {
if (proposal.type === 'visibility') {
return String(proposal.preview || proposal.value || '').toLowerCase() === 'hidden' || String(proposal.value || '').toLowerCase() === 'hide'
? label('copilot.summary_hide', 'Hide field')
: label('copilot.summary_show', 'Show field');
}
if (proposal.type === 'image') {
return label('copilot.summary_image', 'Replace image');
}
const value = proposal.preview || proposal.value || '';
return raijuShortSummary(value);
}
function raijuShortSummary(value) {
const text = String(value == null ? '' : value).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
return text.length > 90 ? text.slice(0, 87) + '...' : text;
}
function renderProposalValue(proposal, value) {
if (proposal.type === 'image' && value) {
return '<img class="rj-copilot-proposal__img" src="' + escHtml(value) + '" alt="">';
}
if (proposal.type === 'boolean') {
const boolText = String(value).toLowerCase() === 'true' || value === true ? 'true' : 'false';
return '<span class="rj-copilot-bool rj-copilot-bool--' + boolText + '">' + boolText + '</span>';
}
if (proposal.type === 'visibility') {
const stateText = String(value || '').toLowerCase() === 'hidden' || String(value || '').toLowerCase() === 'hide' ? 'hidden' : 'visible';
return '<span class="rj-copilot-option">' + escHtml(stateText) + '</span>';
}
if (proposal.type === 'select') {
return '<span class="rj-copilot-option">' + escHtml(value || '') + '</span>';
}
return escHtml(value || '').replace(/\n/g, '<br>');
}
function contextIntro(context) {
if (!context.ai || !context.ai.enabled || !context.ai.hasApiKey || !context.ai.features.backendAssistant) {
return '<div class="rj-copilot-status">' + escHtml(label('copilot.not_ready', 'AI is not ready yet. Check AI Settings in the dashboard.')) + '</div>';
}
const page = context.page || {};
const title = page.exists
? (page.title || page.contentPage || label('copilot.current_page', 'current page'))
: (copilotMode === 'dashboard' ? label('copilot.dashboard_context', 'Dashboard') : label('copilot.current_page', 'current page'));
const fieldCount = Array.isArray(page.fields) ? page.fields.length : 0;
const contentTypes = Array.isArray(context.contentTypes)
? context.contentTypes.filter(type => type.available).map(type => label(type.labelKey || '', type.label || type.id || '')).slice(0, 4).join(', ')
: '';
const selected = state.selectedField
? '<span class="rj-copilot-context__selected">' + escHtml(label('copilot.selected_field', 'Selected field: {field}', { field: state.selectedField.label || state.selectedField.path })) + '</span>'
: '';
return '<div class="rj-copilot-context">' +
'<strong>' + escHtml(title) + '</strong>' +
'<span>' + escHtml((page.exists ? label('copilot.field_hints', '{count} editable field hints found', { count: fieldCount }) : label('copilot.dashboard_hint', 'General site and dashboard assistance')) + (contentTypes ? ' - ' + contentTypes : '')) + '</span>' +
selected +
'</div>';
}
function renderShell() {
const root = document.createElement('div');
root.className = 'rj-copilot';
root.innerHTML = '' +
'<button class="rj-copilot-toggle" type="button" aria-expanded="false" aria-controls="rj-copilot-panel" title="' + escHtml(label('copilot.title', 'AI Assistant')) + '">' +
'<svg viewBox="0 0 60.02 54.01" aria-hidden="true"><path d="M24.05,16.95c0.67,0.21,1.32,0.47,1.96,0.75c0.17-8.68,7.27-15.69,15.98-15.69s16,7.18,16,16c0,3.72-1.28,7.35-3.67,10.2c-0.28,0.33-0.31,0.81-0.07,1.17l2.62,4.13l-6.66-1.48c-0.23-0.05-0.48-0.02-0.69,0.09C47.21,33.34,44.62,34,42,34c-1.45,0-2.85-0.21-4.18-0.57c0.09,0.69,0.14,1.39,0.16,2.1C39.28,35.83,40.62,36,42,36c2.82,0,5.62-0.67,8.13-1.94l8.66,1.92C58.86,36,58.93,36,59.01,36c0.32,0,0.63-0.16,0.82-0.43c0.23-0.33,0.24-0.77,0.03-1.11l-3.52-5.56c2.38-3.12,3.68-6.96,3.68-10.9c0-9.93-8.07-18-18-18S24.63,7.51,24.07,16.94L24.05,16.95z M34,11.01h16v2H34V11.01z M34,17.01h16v2H34V17.01z M38,23.01h12v2H38V23.01z M13.69,32.01h-0.46l-1.05,4h2.57L13.69,32.01z M9.54,51.9c2.52,1.35,5.4,2.11,8.46,2.11c9.94,0,18-8.06,18-18s-8.06-18-18-18s-18,8.06-18,18c0,4.36,1.55,8.36,4.13,11.47L0,54.01L9.54,51.9z M20.46,40.01h2.5v-8h-2.5v-2h7v2h-2.5v8h2.5v2h-7V40.01z M11.49,30.76c0.12-0.44,0.51-0.75,0.97-0.75h2c0.45,0,0.85,0.31,0.97,0.75l2.96,11.25h-2.07l-1.05-4h-3.62l-1.05,4H8.53L11.49,30.76z"/></svg>' +
'</button>' +
'<section class="rj-copilot-panel" id="rj-copilot-panel" role="dialog" aria-modal="false" aria-label="' + escHtml(label('copilot.title', 'AI Assistant')) + '" hidden>' +
'<header class="rj-copilot-header">' +
'<div class="rj-copilot-header__top">' +
'<strong>' + escHtml(label('copilot.title', 'AI Assistant')) + '</strong>' +
'<button class="rj-copilot-close" type="button" aria-label="' + escHtml(label('copilot.close', 'Close AI Assistant')) + '">' + icon('close') + '</button>' +
'</div>' +
'<div class="rj-copilot-header__actions">' +
'<button class="rj-copilot-history-btn" type="button" aria-label="' + escHtml(label('copilot.history', 'Chat history')) + '" title="' + escHtml(label('copilot.history', 'Chat history')) + '">' + icon('history') + '</button>' +
'<button class="rj-copilot-new-btn" type="button" aria-label="' + escHtml(label('copilot.new_chat', 'New chat')) + '" title="' + escHtml(label('copilot.new_chat', 'New chat')) + '">' + icon('newChat') + '</button>' +
'<button class="rj-copilot-delete-btn" type="button" aria-label="' + escHtml(label('copilot.delete_chat', 'Delete current chat')) + '" title="' + escHtml(label('copilot.delete_chat', 'Delete current chat')) + '">' + icon('trash') + '</button>' +
'<button class="rj-copilot-maximize" type="button" aria-label="' + escHtml(label('copilot.maximize', 'Maximize AI Assistant')) + '" title="' + escHtml(label('copilot.maximize', 'Maximize AI Assistant')) + '">' + icon('maximize') + '</button>' +
'</div>' +
'</header>' +
'<div class="rj-copilot-messages" data-copilot-messages aria-live="polite" aria-busy="false"></div>' +
'<form class="rj-copilot-form" data-copilot-form>' +
'<textarea class="rj-copilot-input" rows="2" placeholder="' + escHtml(label('copilot.placeholder', 'Ask how to change this page...')) + '" aria-label="' + escHtml(label('copilot.ask', 'Ask AI Assistant')) + '"></textarea>' +
'<div class="rj-copilot-actions">' +
'<button class="rj-copilot-draft" type="button" disabled title="' + escHtml(label('copilot.draft_changes_hint', 'Open a guided dialog to prepare a reviewed page change.')) + '">' + escHtml(label('copilot.draft_changes', 'Draft changes')) + '</button>' +
'<button class="rj-copilot-content" type="button" disabled title="' + escHtml(label('copilot.draft_content_hint', 'Open a guided dialog to prepare a new page, news post, event, or appointment.')) + '">' + escHtml(label('copilot.draft_content', 'Draft content')) + '</button>' +
'<button class="rj-copilot-image" type="button" disabled hidden title="' + escHtml(label('copilot.draft_image_hint', 'Open image options and enter or refine the image prompt.')) + '">' + escHtml(label('copilot.draft_image', 'Image')) + '</button>' +
'<button class="rj-copilot-send" type="submit">' + escHtml(label('copilot.send', 'Send')) + '</button>' +
'</div>' +
'</form>' +
'</section>' +
'<div class="rj-copilot-modal-host" data-copilot-modal-host></div>';
document.body.appendChild(root);
syncMaximizedState(root);
bind(root);
if (agentHook) agentHook.mount(root, agentHost());
}
function renderModal() {
const host = document.querySelector('[data-copilot-modal-host]');
if (!host) return;
const modal = state.modal;
if (state.changeFormOpen) {
host.innerHTML = renderChangeDraftForm();
const first = host.querySelector('[data-change-draft-instruction]');
if (first) setTimeout(() => first.focus(), 0);
return;
}
if (state.contentFormOpen && state.contentDraft && state.contentDraft.draft) {
host.innerHTML = renderContentDraftForm();
const first = host.querySelector('[data-content-draft-field]');
if (first) setTimeout(() => first.focus(), 0);
return;
}
if (modal) {
const items = Array.isArray(modal.items) && modal.items.length
? '<ul>' + modal.items.map(item => '<li>' + escHtml(item) + '</li>').join('') + '</ul>'
: '';
host.innerHTML = '<div class="rj-copilot-modal-backdrop" role="presentation">' +
'<section class="rj-copilot-modal" role="dialog" aria-modal="true" aria-labelledby="rj-copilot-modal-title">' +
'<header><strong id="rj-copilot-modal-title">' + escHtml(modal.title || label('copilot.confirm_title', 'Confirm action')) + '</strong></header>' +
'<div class="rj-copilot-modal__body">' + (modal.body ? '<p>' + escHtml(modal.body) + '</p>' : '') + items + '</div>' +
'<footer>' +
'<button type="button" class="rj-copilot-modal-cancel">' + escHtml(modal.cancelLabel || label('copilot.modal_cancel', 'Cancel')) + '</button>' +
'<button type="button" class="rj-copilot-modal-confirm">' + escHtml(modal.confirmLabel || label('copilot.modal_confirm', 'Confirm')) + '</button>' +
'</footer>' +
'</section>' +
'</div>';
const confirm = host.querySelector('.rj-copilot-modal-confirm');
if (confirm) confirm.focus();
return;
}
if (state.imageDraft) {
host.innerHTML = '<div class="rj-copilot-modal-backdrop" role="presentation">' +
'<section class="rj-copilot-modal rj-copilot-modal--wide rj-copilot-modal--image" role="dialog" aria-modal="true" aria-labelledby="rj-copilot-image-modal-title">' +
'<header><strong id="rj-copilot-image-modal-title">' + escHtml(label('copilot.image_options_title', 'Image options')) + '</strong><span>' + escHtml(label('copilot.image_options_hint', 'Choose the target image and generation mode.')) + '</span></header>' +
'<div class="rj-copilot-modal__body rj-copilot-modal__body--flush">' + renderImageDraftOptions() + '</div>' +
'<footer>' +
'<button type="button" class="rj-copilot-image-cancel">' + escHtml(label('copilot.modal_cancel', 'Cancel')) + '</button>' +
'</footer>' +
'</section>' +
'</div>';
const prompt = host.querySelector('[data-image-draft-prompt]');
if (prompt) prompt.focus();
return;
}
if (state.historyOpen) {
host.innerHTML = renderHistoryModal();
return;
}
host.innerHTML = '';
}
function contentDraftFieldConfig(type) {
if (type === 'event') {
return [
{ key: 'title', kind: 'text', required: true },
{ key: 'lang', kind: 'lang', required: true },
{ key: 'date', kind: 'date', required: true },
{ key: 'time', kind: 'time', required: true },
{ key: 'location', kind: 'text', required: true },
{ key: 'description', kind: 'textarea', required: false },
{ key: 'admission', kind: 'text', required: false },
{ key: 'url', kind: 'text', required: false }
];
}
if (type === 'news') {
return [
{ key: 'title', kind: 'text', required: true },
{ key: 'slug', kind: 'text', required: true },
{ key: 'lang', kind: 'lang', required: true },
{ key: 'date', kind: 'date', required: true },
{ key: 'excerpt', kind: 'textarea', required: false },
{ key: 'content', kind: 'textarea', required: true },
{ key: 'author', kind: 'text', required: false }
];
}
return [
{ key: 'title', kind: 'text', required: true },
{ key: 'slug', kind: 'text', required: true },
{ key: 'lang', kind: 'lang', required: true },
{ key: 'description', kind: 'textarea', required: false },
{ key: 'content', kind: 'textarea', required: true }
];
}
function renderContentDraftForm() {
const draft = state.contentDraft && state.contentDraft.draft ? state.contentDraft.draft : null;
const type = draft ? (draft.contentType || 'page') : 'page';
const values = draft && draft.draft ? draft.draft : {};
const missing = Array.isArray(draft && draft.missing) ? draft.missing : [];
const languageOptions = state.context && state.context.site && state.context.site.languages
? Object.entries(state.context.site.languages)
: [];
const fields = contentDraftFieldConfig(type).map(field => {
const value = values[field.key] == null ? '' : String(values[field.key]);
const required = field.required || missing.includes(field.key);
const labelText = field.key + (required ? ' *' : '');
if (field.kind === 'textarea') {
return '<label><span>' + escHtml(labelText) + '</span><textarea rows="4" data-content-draft-field="' + escHtml(field.key) + '">' + escHtml(value) + '</textarea></label>';
}
if (field.kind === 'lang') {
const options = (languageOptions.length ? languageOptions : [['en', 'English']]).map(([code, name]) => {
return '<option value="' + escHtml(code) + '"' + (String(code) === value ? ' selected' : '') + '>' + escHtml(String(name || code)) + '</option>';
}).join('');
return '<label><span>' + escHtml(labelText) + '</span><select data-content-draft-field="' + escHtml(field.key) + '">' + options + '</select></label>';
}
return '<label><span>' + escHtml(labelText) + '</span><input type="' + escHtml(field.kind) + '" value="' + escHtml(value) + '" data-content-draft-field="' + escHtml(field.key) + '"></label>';
}).join('');
const typeOptions = [
['page', label('copilot.content_type_page', 'Page')],
['news', label('copilot.content_type_news', 'News post')],
['event', label('copilot.content_type_event', 'Event / appointment')]
].map(([value, text]) => '<option value="' + escHtml(value) + '"' + (value === type ? ' selected' : '') + '>' + escHtml(text) + '</option>').join('');
return '<div class="rj-copilot-modal-backdrop" role="presentation">' +
'<section class="rj-copilot-modal rj-copilot-modal--wide" role="dialog" aria-modal="true" aria-labelledby="rj-copilot-content-modal-title">' +
'<header><strong id="rj-copilot-content-modal-title">' + escHtml(label('copilot.complete_details_title', 'Complete content details')) + '</strong></header>' +
'<form class="rj-copilot-content-form">' +
'<div class="rj-copilot-modal__body"><div class="rj-copilot-content-fields">' +
'<label><span>' + escHtml(label('copilot.content_type', 'Content type')) + '</span><select data-content-draft-type>' + typeOptions + '</select></label>' +
fields +
'</div></div>' +
'<footer>' +
'<button type="button" class="rj-copilot-content-cancel">' + escHtml(label('copilot.modal_cancel', 'Cancel')) + '</button>' +
'<button type="submit" class="rj-copilot-content-submit">' + escHtml(label('copilot.update_draft', 'Update draft')) + '</button>' +
'</footer>' +
'</form>' +
'</section>' +
'</div>';
}
function renderHistoryModal() {
const items = state.historyItems.length
? state.historyItems.map(item => {
const active = item.id && item.id === state.currentSessionId ? ' is-active' : '';
const meta = [item.pageTitle || item.contentPage || '', formatHistoryDate(item.updatedAt)].filter(Boolean).join(' - ');
return '<article class="rj-copilot-history-item' + active + '">' +
'<button type="button" class="rj-copilot-history-load" data-history-load="' + escHtml(item.id) + '">' +
'<strong>' + escHtml(item.title || label('copilot.untitled_chat', 'Untitled chat')) + '</strong>' +
'<span>' + escHtml(meta) + '</span>' +
'</button>' +
'<button type="button" class="rj-copilot-history-delete" data-history-delete="' + escHtml(item.id) + '" aria-label="' + escHtml(label('copilot.delete_chat', 'Delete chat')) + '">' + icon('trash') + '</button>' +
'</article>';
}).join('')
: '<p class="rj-copilot-history-empty">' + escHtml(state.historyLoaded ? label('copilot.history_empty', 'No archived chats yet.') : label('copilot.history_loading', 'Loading chat history...')) + '</p>';
return '<div class="rj-copilot-modal-backdrop" role="presentation">' +
'<section class="rj-copilot-modal rj-copilot-modal--wide" role="dialog" aria-modal="true" aria-labelledby="rj-copilot-history-title">' +
'<header><strong id="rj-copilot-history-title">' + escHtml(label('copilot.history', 'Chat history')) + '</strong></header>' +
'<div class="rj-copilot-modal__body">' +
'<div class="rj-copilot-history-list">' + items + '</div>' +
'</div>' +
'<footer>' +
'<button type="button" class="rj-copilot-history-close">' + escHtml(label('copilot.modal_cancel', 'Close')) + '</button>' +
'<button type="button" class="rj-copilot-history-new">' + escHtml(label('copilot.new_chat', 'New chat')) + '</button>' +
'</footer>' +
'</section>' +
'</div>';
}
function formatHistoryDate(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function confirmCopilot(options) {
return new Promise(resolve => {
state.modal = Object.assign({}, options || {}, { resolve });
renderModal();
});
}
function closeCopilotModal(result) {
const modal = state.modal;
state.modal = null;
renderModal();
if (modal && typeof modal.resolve === 'function') {
modal.resolve(result === true);
}
}
function setOpen(open) {
if (state.embedded && !open) return; // an embedded chat is always open; leaving the card closes nothing
if (open && !state.open) {
state.lastFocusedBeforeOpen = document.activeElement instanceof HTMLElement ? document.activeElement : null;
}
state.open = open;
const panel = document.getElementById('rj-copilot-panel');
const toggle = document.querySelector('.rj-copilot-toggle');
if (!panel || !toggle) return;
panel.hidden = !open;
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) {
loadContext();
const input = panel.querySelector('.rj-copilot-input');
if (input && !state.embedded) setTimeout(() => input.focus(), 0);
} else {
const restoreTarget = state.lastFocusedBeforeOpen && document.contains(state.lastFocusedBeforeOpen)
? state.lastFocusedBeforeOpen
: toggle;
state.lastFocusedBeforeOpen = null;
if (restoreTarget) setTimeout(() => restoreTarget.focus(), 0);
}
persistSession();
}
function setMaximized(maximized) {
state.maximized = maximized;
syncMaximizedState();
persistSession();
}
function syncMaximizedState(root) {
const container = root || document.querySelector('.rj-copilot');
if (!container) return;
container.classList.toggle('is-maximized', state.maximized === true);
const button = container.querySelector('.rj-copilot-maximize');
if (!button) return;
const key = state.maximized ? 'copilot.minimize' : 'copilot.maximize';
const fallback = state.maximized ? 'Minimize AI Assistant' : 'Maximize AI Assistant';
button.setAttribute('aria-label', label(key, fallback));
button.setAttribute('title', label(key, fallback));
button.innerHTML = icon(state.maximized ? 'minimize' : 'maximize');
}
function bind(root) {
const toggle = root.querySelector('.rj-copilot-toggle');
const close = root.querySelector('.rj-copilot-close');
const maximize = root.querySelector('.rj-copilot-maximize');
const historyBtn = root.querySelector('.rj-copilot-history-btn');
const newBtn = root.querySelector('.rj-copilot-new-btn');
const deleteBtn = root.querySelector('.rj-copilot-delete-btn');
const form = root.querySelector('[data-copilot-form]');
const input = root.querySelector('.rj-copilot-input');
const draftBtn = root.querySelector('.rj-copilot-draft');
const contentBtn = root.querySelector('.rj-copilot-content');
const imageBtn = root.querySelector('.rj-copilot-image');
toggle.addEventListener('click', () => setOpen(!state.open));
close.addEventListener('click', () => setOpen(false));
maximize.addEventListener('click', () => setMaximized(!state.maximized));
historyBtn.addEventListener('click', event => {
event.stopPropagation();
openHistory();
});
newBtn.addEventListener('click', event => {
event.stopPropagation();
startNewChat();
});
deleteBtn.addEventListener('click', event => {
event.stopPropagation();
deleteCurrentChat();
});
document.addEventListener('click', trackSelectedEditableField, true);
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && state.open) {
event.preventDefault();
if (state.imageDraft) {
state.imageDraft = null;
renderMessages();
} else if (state.changeFormOpen) {
state.changeFormOpen = false;
renderMessages();
} else if (state.historyOpen) {
state.historyOpen = false;
renderMessages();
} else if (state.contentFormOpen) {
state.contentFormOpen = false;
renderMessages();
} else if (state.modal) {
closeCopilotModal(false);
} else {
setOpen(false);
}
return;
}
if (event.key === 'Tab' && state.open) {
trapPanelFocus(event);
}
});
input.addEventListener('keydown', event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
form.requestSubmit();
}
});
input.addEventListener('input', updateActionAvailability);
form.addEventListener('submit', event => {
event.preventDefault();
const value = input.value.trim();
if (!value || state.loading) return;
if (agentHook && agentHook.isActive() && agentHook.isBusy()) return;
input.value = '';
updateActionAvailability();
handleUserMessage(value);
});
draftBtn.addEventListener('click', async () => {
const value = input.value.trim();
if (state.loading) return;
if (!canDraftChanges()) {
state.messages.push({ role: 'assistant', content: label('copilot.no_safe_field', 'I could not map that request to a safe editable field on this page yet.') });
renderMessages();
return;
}
if (copilotMode === 'frontend' && !await ensureVisualEditorForWrite()) return;
openChangeDraftForm(value);
});
contentBtn.addEventListener('click', async () => {
const value = input.value.trim();
if (state.loading) return;
if (!canDraftContent()) {
state.messages.push({ role: 'assistant', content: label('copilot.content_unavailable', 'Content creation is not available for your role or this site configuration.') });
renderMessages();
return;
}
if (copilotMode === 'frontend' && !await ensureVisualEditorForWrite()) return;
openContentDraftForm(value);
});
imageBtn.addEventListener('click', async () => {
const value = input.value.trim();
if (state.loading) return;
if (!canDraftImage()) {
state.messages.push({ role: 'assistant', content: label('copilot.image_unavailable', 'Image generation is not available for your role, this page, or the current AI settings.') });
renderMessages();
return;
}
if (copilotMode === 'frontend' && !await ensureVisualEditorForWrite()) return;
draftImage(value, { silent: true });
});
root.addEventListener('click', event => {
const variantBtn = event.target.closest('[data-proposal-variant]');
if (variantBtn && !state.loading) {
selectImageVariant(variantBtn.dataset.proposalVariant);
return;
}
const btn = event.target.closest('[data-proposal-index]');
if (btn && !state.loading) {
const index = Number(btn.dataset.proposalIndex);
if (Number.isInteger(index) && state.proposals[index]) applyProposal(index, { skipConfirm: true });
return;
}
const dashRun = event.target.closest('.rj-copilot-dash-run');
if (dashRun) {
runDashboardAction(Number(dashRun.dataset.dashMessage), Number(dashRun.dataset.dashAction));
return;
}
const dashDismiss = event.target.closest('.rj-copilot-dash-dismiss');
if (dashDismiss) {
const dismissed = state.messages[Number(dashDismiss.dataset.dashMessage)];
if (dismissed && Array.isArray(dismissed.actions) && dismissed.actions[Number(dashDismiss.dataset.dashAction)]) {
dismissed.actions[Number(dashDismiss.dataset.dashAction)].status = 'dismissed';
renderMessages({ keepScroll: true });
}
return;
}
if (event.target.closest('.rj-copilot-apply-all') && !state.loading) {
applyAllProposals({ skipConfirm: true });
return;
}
const copyBtn = event.target.closest('[data-proposal-copy]');
if (copyBtn && !state.loading) {
const index = Number(copyBtn.dataset.proposalCopy);
if (Number.isInteger(index) && state.proposals[index]) copyProposal(index);
return;
}
if (event.target.closest('.rj-copilot-create-btn') && !state.loading) {
createContentDraft();
return;
}
if (event.target.closest('.rj-copilot-details-btn') && !state.loading) {
state.contentFormOpen = true;
renderMessages();
return;
}
if (event.target.closest('.rj-copilot-change-cancel') && !state.loading) {
state.changeFormOpen = false;
renderMessages();
return;
}
if (event.target.closest('.rj-copilot-content-cancel') && !state.loading) {
state.contentFormOpen = false;
renderMessages();
return;
}
if (event.target.closest('.rj-copilot-publish-btn') && !state.loading) {
publishCreatedContent();
return;
}
if (event.target.closest('.rj-copilot-undo-btn') && !state.loading) {
undoLastApply();
return;
}
const imageFieldBtn = event.target.closest('[data-image-draft-field]');
if (imageFieldBtn && !state.loading) {
selectImageDraftField(imageFieldBtn.dataset.imageDraftField || '');
return;
}
const imageModeBtn = event.target.closest('[data-image-draft-mode]');
if (imageModeBtn && !state.loading) {
selectImageDraftMode(imageModeBtn.dataset.imageDraftMode || '');
return;
}
if (event.target.closest('.rj-copilot-image-run') && !state.loading) {
runImageDraft();
return;
}
if (event.target.closest('.rj-copilot-image-cancel') && !state.loading) {
state.imageDraft = null;
renderMessages();
return;
}
const historyLoad = event.target.closest('[data-history-load]');
if (historyLoad && !state.loading) {
loadHistoryChat(historyLoad.dataset.historyLoad || '');
return;
}
const historyDelete = event.target.closest('[data-history-delete]');
if (historyDelete && !state.loading) {
deleteHistoryChat(historyDelete.dataset.historyDelete || '');
return;
}
if (event.target.closest('.rj-copilot-history-close')) {
state.historyOpen = false;
renderMessages();
return;
}
if (event.target.closest('.rj-copilot-history-new')) {
startNewChat();
return;
}
if (event.target.closest('.rj-copilot-modal-confirm')) {
closeCopilotModal(true);
return;
}
if (event.target.classList.contains('rj-copilot-modal-backdrop') && state.changeFormOpen) {
state.changeFormOpen = false;
renderMessages();
return;
}
if (event.target.classList.contains('rj-copilot-modal-backdrop') && state.contentFormOpen) {
state.contentFormOpen = false;
renderMessages();
return;
}
if (event.target.closest('.rj-copilot-modal-cancel') || event.target.classList.contains('rj-copilot-modal-backdrop')) {
if (state.modal) {
closeCopilotModal(false);
} else if (state.historyOpen) {
state.historyOpen = false;
renderMessages();
} else if (state.changeFormOpen) {
state.changeFormOpen = false;
renderMessages();
} else if (state.imageDraft) {
state.imageDraft = null;
renderMessages();
} else {
closeCopilotModal(false);
}
}
});
root.addEventListener('submit', event => {
if (event.target.closest('.rj-copilot-change-form')) {
event.preventDefault();
if (state.loading) return;
submitChangeDraftForm(event.target);
return;
}
if (!event.target.closest('.rj-copilot-content-form')) return;
event.preventDefault();
if (state.loading) return;
submitContentDraftForm(event.target);
});
root.addEventListener('input', event => {
const inputEl = event.target.closest('[data-proposal-alt]');
if (inputEl) {
const index = Number(inputEl.dataset.proposalAlt);
if (Number.isInteger(index) && state.proposals[index]) {
state.proposals[index].altValue = inputEl.value;
}
return;
}
const imagePromptEl = event.target.closest('[data-image-draft-prompt]');
if (imagePromptEl && state.imageDraft) {
state.imageDraft.instruction = imagePromptEl.value;
return;
}
const changeInstructionEl = event.target.closest('[data-change-draft-instruction]');
if (changeInstructionEl && state.changeDraft) {
state.changeDraft.instruction = changeInstructionEl.value;
}
});
root.addEventListener('change', event => {
const changeFieldEl = event.target.closest('[data-change-draft-field]');
if (changeFieldEl && state.changeDraft) {
const field = findChangeTargetField(changeFieldEl.value || '');
state.changeDraft.field = field || null;
if (field) {
state.selectedField = field;
state.selectedFieldPath = field.path || '';
markContextElementByPath(field.path || '');
} else {
state.selectedField = null;
state.selectedFieldPath = '';
markContextElementByPath('');
}
renderMessages();
return;
}
const typeEl = event.target.closest('[data-content-draft-type]');
if (typeEl && state.contentFormOpen && state.contentDraft && state.contentDraft.draft) {
state.contentDraft.draft.contentType = typeEl.value || 'page';
state.contentDraft.draft.draft = {};
state.contentDraft.draft.missing = [];
renderMessages();
return;
}
const optionEl = event.target.closest('[data-image-draft-option]');
if (!optionEl || !state.imageDraft) return;
updateImageDraftOption(optionEl.dataset.imageDraftOption || '', optionEl.value);
});
}
function openHistory() {
state.historyOpen = true;
renderMessages();
post('ai-copilot-history-list', { contentPage }).then(result => {
state.historyItems = Array.isArray(result && result.items) ? result.items : [];
state.historyLoaded = true;
}).catch(error => {
state.historyItems = [];
state.historyLoaded = true;
state.messages.push({ role: 'assistant', content: error.message });
}).finally(renderMessages);
}
function startNewChat() {
state.historyOpen = false;
state.imageDraft = null;
state.proposals = [];
state.contentDraft = null;
state.createdContent = null;
state.lastImageResult = null;
state.lastApplied = null;
state.lastInstruction = '';
state.currentSessionId = newSessionId();
if (agentHook) agentHook.reset();
state.messages = [{
role: 'assistant',
content: label('copilot.intro_message', 'I can help with this page and explain how to edit or create content safely. Write what you want to change or create.')
}];
persistSession();
renderMessages();
}
async function deleteCurrentChat() {
if (!state.currentSessionId && !state.messages.length) return;
const confirmed = await confirmCopilot({
title: label('copilot.delete_chat_title', 'Delete current chat?'),
body: label('copilot.delete_chat_confirm', 'Delete the current AI Assistant chat history? This cannot be undone.'),
confirmLabel: label('copilot.delete_chat', 'Delete current chat')
});
if (!confirmed) return;
const id = state.currentSessionId;
if (id) {
post('ai-copilot-history-delete', { id }).catch(() => null);
}
startNewChat();
}
async function deleteHistoryChat(id) {
if (!id) return;
const confirmed = await confirmCopilot({
title: label('copilot.delete_chat_title', 'Delete chat?'),
body: label('copilot.delete_chat_confirm', 'Delete this AI Assistant chat history? This cannot be undone.'),
confirmLabel: label('copilot.delete_chat', 'Delete chat')
});
if (!confirmed) return;
post('ai-copilot-history-delete', { id }).then(() => {
state.historyItems = state.historyItems.filter(item => item.id !== id);
if (state.currentSessionId === id) {
startNewChat();
} else {
renderMessages();
}
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
renderMessages();
});
}
function loadHistoryChat(id) {
if (!id) return;
post('ai-copilot-history-load', { id }).then(result => {
const chat = result && result.chat ? result.chat : null;
if (!chat) return;
state.currentSessionId = chat.id || id;
if (agentHook) agentHook.reset();
state.messages = Array.isArray(chat.messages) ? chat.messages : [];
state.lastInstruction = chat.lastInstruction || '';
state.lastImageResult = chat.lastImageResult || null;
state.proposals = [];
state.contentDraft = null;
state.createdContent = null;
state.imageDraft = null;
state.lastApplied = null;
state.historyOpen = false;
persistSession();
renderMessages();
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
renderMessages();
});
}
function getPanelFocusableElements(panel) {
return Array.from(panel.querySelectorAll('a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'))
.filter(element => !element.hidden && element.getClientRects().length > 0);
}
function trapPanelFocus(event) {
const modal = document.querySelector('.rj-copilot-modal');
if (modal) {
trapFocusWithin(event, modal);
return;
}
const panel = document.getElementById('rj-copilot-panel');
if (!panel || panel.hidden) return;
trapFocusWithin(event, panel);
}
function trapFocusWithin(event, container) {
const focusable = getPanelFocusableElements(container);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
} else if (!container.contains(document.activeElement)) {
event.preventDefault();
first.focus();
}
}
function loadContext() {
syncContentPageFromHost();
ensureContextReady();
}
function ensureContextReady() {
syncContentPageFromHost();
if (state.context) {
renderMessages();
return Promise.resolve(true);
}
if (state.contextPromise) {
return state.contextPromise;
}
setLoading(true, 'copilot.working_context', 'Loading page context...');
renderMessages();
state.contextPromise = post('ai-copilot-context', {
contentPage,
mode: copilotMode,
uiLanguage: assistantUiLanguage()
}).then(context => {
state.context = context;
state.limitWarnings = Array.isArray(context && context.limitWarnings) ? context.limitWarnings : [];
resolveSelectedField();
if (!state.messages.length) {
state.messages.push({
role: 'assistant',
content: label('copilot.intro_message', 'I can help with this page and explain how to edit or create content safely. Write what you want to change or create.')
});
}
return true;
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
return false;
}).finally(() => {
state.contextPromise = null;
setLoading(false);
renderMessages();
});
return state.contextPromise;
}
async function handleUserMessage(content) {
syncContentPageFromHost();
// In agent mode the agent takes every message. A "yes" never applies a package; only its button does.
if (agentHook && agentHook.isActive()) {
await agentHook.handleMessage(content);
return;
}
if (isConfirmationIntent(content)) {
if (state.proposals.length) {
if (state.proposals.length === 1) {
applyProposal(0, { skipConfirm: true, confirmedByChat: true });
} else {
applyAllProposals({ skipConfirm: true, confirmedByChat: true });
}
return;
}
if (state.contentDraft && state.contentDraft.draft && state.contentDraft.draft.canCreate) {
createContentDraft({ skipConfirm: true, confirmedByChat: true });
return;
}
if (state.createdContent && state.createdContent.id && state.createdContent.publishable) {
publishCreatedContent({ skipConfirm: true, confirmedByChat: true });
return;
}
state.messages.push({
role: 'assistant',
content: label('copilot.no_pending_preview', 'There is no reviewed AI preview waiting to apply. Ask for a change first, then use the preview card.')
});
renderMessages();
return;
}
if (looksLikeImageRequest(content)) {
if (!state.context && !await ensureContextReady()) return;
if (canDraftImage()) {
draftImage(content);
} else {
state.lastInstruction = content;
state.messages.push({ role: 'user', content });
state.messages.push({
role: 'assistant',
content: label('copilot.image_unavailable', 'Image generation is not available for your role, this page, or the current AI settings.')
});
renderMessages();
}
return;
}
if (looksLikeContentCreateRequest(content)) {
if (!state.context && !await ensureContextReady()) return;
if (canDraftContent()) {
draftContent(content);
} else {
state.lastInstruction = content;
state.messages.push({ role: 'user', content });
state.messages.push({
role: 'assistant',
content: label('copilot.content_unavailable', 'Content creation is not available for your role or this site configuration.')
});
renderMessages();
}
return;
}
if (looksLikeNavigationMenuRequest(content)) {
state.lastInstruction = content;
state.messages.push({ role: 'user', content });
state.messages.push({
role: 'assistant',
content: label('copilot.navigation_manual_required', 'No change was applied. I cannot safely change page navigation from chat yet. Open Page Settings / Navigation in the Content Editor and select the menus where this page should appear.')
});
renderMessages();
return;
}
if (looksLikeTranslateRequest(content)) {
if (!state.context && !await ensureContextReady()) return;
if (hasPermission('suggestField') && copilotMode !== 'dashboard') {
draftTranslation(content);
} else {
state.lastInstruction = content;
state.messages.push({ role: 'user', content });
state.messages.push({
role: 'assistant',
content: label('copilot.translate_unavailable', 'Translation drafts are not available for your role or on this surface. Open the page in the Visual Editor to translate it.')
});
renderMessages();
}
return;
}
if (looksLikeChangeRequest(content) && canDraftChanges()) {
draftChanges(content);
return;
}
sendMessage(content);
}
function draftTranslation(instruction) {
syncContentPageFromHost();
state.lastInstruction = instruction;
state.proposals = [];
state.contentDraft = null;
state.imageDraft = null;
state.messages.push({ role: 'user', content: instruction });
setLoading(true, 'copilot.working_translate', 'Translating the page content...');
renderMessages();
post('ai-copilot-translate', {
contentPage,
instruction
}).then(result => {
state.proposals = Array.isArray(result && result.proposals) ? result.proposals : [];
const target = String((result && result.targetLang) || '').toUpperCase();
state.messages.push({
role: 'assistant',
content: state.proposals.length
? label('copilot.translate_ready', 'I drafted {target} translations. Review the preview cards below; applying saves them to the {target} page.', { target })
: label('copilot.translate_no_fields', 'I could not draft translations for this page. Check that the target language page exists and has translatable fields.')
});
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
}).finally(() => {
setLoading(false);
renderMessages();
});
}
let streamRenderTimer = null;
function renderMessagesThrottled() {
if (streamRenderTimer) return;
streamRenderTimer = window.setTimeout(() => {
streamRenderTimer = null;
renderMessages();
}, 120);
}
function sendMessage(content) {
syncContentPageFromHost();
state.lastInstruction = content;
state.proposals = [];
state.contentDraft = null;
state.imageDraft = null;
state.messages.push({ role: 'user', content });
setLoading(true, 'copilot.working_chat', 'Thinking about your question...');
renderMessages();
const payload = {
contentPage,
mode: copilotMode,
uiLanguage: assistantUiLanguage(),
messages: state.messages.map(message => ({ role: message.role, content: message.content }))
};
// With dashboard actions the answer comes in one piece together with its cards, so no streaming.
const useStream = supportsStreaming() && !(copilotMode === 'dashboard' && state.context && state.context.dashboardActions === true);
let streamingMessage = null;
const onDelta = delta => {
if (!streamingMessage) {
streamingMessage = { role: 'assistant', content: '' };
state.messages.push(streamingMessage);
setLoading(false);
}
streamingMessage.content += delta;
renderMessagesThrottled();
};
const finish = result => {
if (result && result.context) state.context = result.context;
if (result && result.limits) setLimitWarnings(result.limits.warnings);
const reply = (result && result.reply) || (streamingMessage && streamingMessage.content) || label('copilot.no_response', 'No response.');
if (streamingMessage) {
streamingMessage.content = reply;
if (result && Array.isArray(result.actions) && result.actions.length) streamingMessage.actions = result.actions;
} else {
const entry = { role: 'assistant', content: reply };
if (result && Array.isArray(result.actions) && result.actions.length) entry.actions = result.actions;
state.messages.push(entry);
}
};
const request = useStream
? postStream('ai-copilot-chat-stream', payload, onDelta).then(finish)
: post('ai-copilot-chat', payload).then(finish);
request.catch(error => {
if (!streamingMessage && useStream && !error.sessionExpired) {
// Streaming produced no output: retry once over the JSON endpoint.
return post('ai-copilot-chat', payload).then(finish).catch(fallbackError => {
state.messages.push({ role: 'assistant', content: fallbackError.message });
});
}
state.messages.push({ role: 'assistant', content: error.message });
}).finally(() => {
setLoading(false);
renderMessages();
});
}
async function draftChanges(instruction) {
syncContentPageFromHost();
if (!await ensureVisualEditorForWrite()) return;
state.lastInstruction = instruction;
state.proposals = [];
state.contentDraft = null;
state.createdContent = null;
state.imageDraft = null;
const targetField = resolveSelectedFieldForInstruction(instruction);
const visibilityAction = detectVisibilityIntent(instruction);
if (visibilityAction && targetField && hasPermission('toggleVisibility')) {
state.selectedField = targetField;
draftVisibility(instruction, visibilityAction);
return;
}
const htmlFormat = detectHtmlFormatIntent(instruction);
if (htmlFormat && targetField && targetField.type === 'html') {
state.selectedField = targetField;
draftHtmlFormat(instruction, htmlFormat);
return;
}
state.messages.push({ role: 'user', content: instruction });
setLoading(true, 'copilot.working_draft', 'Drafting a safe change...');
renderMessages();
post('ai-copilot-suggest', {
contentPage,
instruction,
fieldRef: targetField ? (targetField.id || targetField.path) : ''
}).then(result => {
if (result && result.context) state.context = result.context;
state.proposals = Array.isArray(result && result.proposals) ? result.proposals : [];
state.messages.push({
role: 'assistant',
content: state.proposals.length
? label('copilot.suggestions_ready', 'I drafted safe field-level suggestions. Review the preview cards below.')
: label('copilot.no_safe_field', 'I could not map that request to a safe editable field on this page yet.')
});
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
}).finally(() => {
setLoading(false);
renderMessages();
});
}
async function draftVisibility(instruction, visibilityAction) {
if (!await ensureVisualEditorForWrite()) return;
state.messages.push({ role: 'user', content: instruction });
setLoading(true, 'copilot.working_draft', 'Drafting a safe change...');
renderMessages();
post('ai-copilot-visibility', {
contentPage,
fieldRef: state.selectedField.id || state.selectedField.path,
visibilityAction,
instruction
}).then(result => {
if (result && result.context) state.context = result.context;
state.proposals = Array.isArray(result && result.proposals) ? result.proposals : [];
state.messages.push({
role: 'assistant',
content: state.proposals.length
? label('copilot.suggestions_ready', 'I drafted safe field-level suggestions. Review the preview cards below.')
: label('copilot.no_safe_field', 'I could not map that request to a safe editable field on this page yet.')
});
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
}).finally(() => {
setLoading(false);
renderMessages();
});
}
async function draftHtmlFormat(instruction, format) {
if (!await ensureVisualEditorForWrite()) return;
state.messages.push({ role: 'user', content: instruction });
setLoading(true, 'copilot.working_draft', 'Drafting a safe change...');
renderMessages();
post('ai-copilot-format-html', {
contentPage,
fieldRef: state.selectedField.id || state.selectedField.path,
format,
instruction
}).then(result => {
if (result && result.context) state.context = result.context;
state.proposals = Array.isArray(result && result.proposals) ? result.proposals : [];
state.messages.push({
role: 'assistant',
content: state.proposals.length
? label('copilot.suggestions_ready', 'I drafted safe field-level suggestions. Review the preview cards below.')
: label('copilot.no_safe_field', 'I could not map that request to a safe editable field on this page yet.')
});
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
}).finally(() => {
setLoading(false);
renderMessages();
});
}
function looksLikeTranslateRequest(instruction) {
const text = String(instruction || '').toLowerCase();
return /translat|übersetz|uebersetz|traduc|tradui|tradur|traduz|tłumacz|przetłumacz|tlumacz|çevir|cevir|přelož|prelozit|preloz/.test(text);
}
function looksLikeChangeRequest(instruction) {
const text = String(instruction || '').toLowerCase();
if (state.selectedField && !looksLikeHelpQuestion(text)) return true;
return /\b(change|update|replace|rewrite|improve|shorten|extend|make|set|hide|show|rename|edit|apply|ändern|aendern|änder|aender|ersetzen|aktualisieren|verbessern|kürzen|kuerzen|verlängern|verlaengern|mach|mache|setz|setze|schreib|schreibe|formulier|formuliere|passe|anpassen|tausch|tausche|ausblenden|anzeigen|umbenennen|zitat|titel|text)\b/.test(text);
}
function looksLikeImageRequest(instruction) {
const text = String(instruction || '').toLowerCase();
return /\b(image|picture|photo|illustration|generate image|create image|new image|edit image|replace image|bild|foto|illustration|bild generieren|bild erstellen|neues bild|bild bearbeiten|bild tauschen|bild ersetzen)\b/.test(text);
}
function looksLikeContentCreateRequest(instruction) {
const text = String(instruction || '').toLowerCase();
if (looksLikeHelpQuestion(text)) return false;
const createVerb = /\b(create|add|make|draft|publish|new|erstelle|erstell|lege|leg|anlegen|erstellt|hinzufügen|hinzufuegen|neu)\b/.test(text);
const contentType = /\b(page|site page|webpage|news|post|article|event|appointment|termin|veranstaltung|seite|unterseite|newsbeitrag|meldung|beitrag|artikel)\b/.test(text);
const explicitGermanPage = /\b(neue|neuen|neuer)\s+(seite|unterseite|meldung|termin|veranstaltung|beitrag|newsbeitrag)\b/.test(text);
const explicitEnglishPage = /\b(new|create|add)\s+(page|webpage|news|post|article|event|appointment)\b/.test(text);
return (createVerb && contentType) || explicitGermanPage || explicitEnglishPage;
}
function looksLikeNavigationMenuRequest(instruction) {
const text = String(instruction || '').toLowerCase();
if (looksLikeHelpQuestion(text)) return false;
const navigationTarget = /\b(menu|navigation|nav|header|footer|pages|info|menü|menue|navigation|kopfmenü|hauptmenü|seitenmenü)\b/.test(text);
const action = /\b(add|show|include|display|enable|set|change|update|aufnehmen|anzeigen|hinzufügen|hinzufuegen|setzen|ändern|aendern|aktivieren|erscheinen)\b/.test(text);
return navigationTarget && action;
}
function looksLikeHelpQuestion(text) {
return /^(how|where|why|what|which|can you explain|explain|help|wie|wo|warum|wieso|was|welche|kannst du erklären|erklaer|erklär|hilfe)\b/.test(String(text || '').trim());
}
function isConfirmationIntent(instruction) {
const text = String(instruction || '').trim().toLowerCase();
return /^(yes|y|ok|okay|do it|apply|confirm|go ahead|ja|jep|jawohl|passt|mach das|anwenden|bestätigen|bestaetigen|genau)$/i.test(text);
}
function inlineEditorAvailable() {
return window.InlineEditor && typeof window.InlineEditor.enterEditMode === 'function';
}
function inlineEditorActive() {
if (window.InlineEditor && typeof window.InlineEditor.isEditMode === 'function') {
return window.InlineEditor.isEditMode();
}
return document.body.classList.contains('edit-mode-active');
}
async function ensureVisualEditorForWrite() {
if (inlineEditorActive()) return true;
if (!inlineEditorAvailable()) {
state.messages.push({ role: 'assistant', content: label('copilot.visual_editor_unavailable', 'The Visual Editor is not available on this page, so I cannot safely prepare page changes here.') });
renderMessages();
return false;
}
const confirmed = await confirmCopilot({
title: label('copilot.enable_visual_editor_title', 'Activate Visual Editor?'),
body: label('copilot.enable_visual_editor_confirm', 'The Visual Editor must be active before AI changes can be prepared on this page. Activate it now?'),
confirmLabel: label('copilot.enable_visual_editor_action', 'Activate Visual Editor')
});
if (!confirmed) {
state.messages.push({ role: 'assistant', content: label('copilot.visual_editor_required', 'No change was made. Activate the Visual Editor first, then ask me to prepare the change again.') });
renderMessages();
return false;
}
window.InlineEditor.enterEditMode();
state.messages.push({ role: 'assistant', content: label('copilot.visual_editor_enabled', 'Visual Editor is active. I will prepare the change as a preview first.') });
return true;
}
function detectHtmlFormatIntent(instruction) {
const text = String(instruction || '').toLowerCase();
if (/\b(bold|strong|fett)\b/.test(text)) return 'strong';
if (/\b(italic|italics|kursiv)\b/.test(text)) return 'em';
if (/\b(underline|underlined|unterstrichen)\b/.test(text)) return 'u';
return '';
}
function detectVisibilityIntent(instruction) {
const text = String(instruction || '').toLowerCase();
if (/\b(hide|hidden|ausblenden|verstecken|unsichtbar)\b/.test(text) || /nicht anzeigen/.test(text)) return 'hide';
if (/\b(show|visible|anzeigen|einblenden|sichtbar)\b/.test(text) || /wieder anzeigen/.test(text)) return 'show';
return '';
}
function openChangeDraftForm(instruction) {
const selected = findChangeTargetField(state.selectedField && (state.selectedField.id || state.selectedField.path))
|| findChangeTargetField(state.changeDraft && state.changeDraft.field && (state.changeDraft.field.id || state.changeDraft.field.path));
state.changeDraft = {
instruction: instruction || (state.changeDraft && state.changeDraft.instruction) || '',
field: selected
};
state.changeFormOpen = true;
state.contentFormOpen = false;
state.imageDraft = null;
state.historyOpen = false;
state.modal = null;
renderMessages();
}
function submitChangeDraftForm(form) {
const fieldEl = form.querySelector('[data-change-draft-field]');
const instructionEl = form.querySelector('[data-change-draft-instruction]');
const field = fieldEl ? findChangeTargetField(fieldEl.value || '') : null;
if (!field) {
state.messages.push({ role: 'assistant', content: label('copilot.choose_target_required', 'Choose the field you want to change first.') });
if (fieldEl) fieldEl.focus();
renderMessages();
return;
}
const instruction = instructionEl ? instructionEl.value.trim() : '';
if (!instruction) {
if (instructionEl) instructionEl.focus();
return;
}
state.selectedField = field;
state.selectedFieldPath = field.path || '';
markContextElementByPath(field.path || '');
state.changeDraft = { instruction, field };
state.changeFormOpen = false;
draftChanges(instruction);
}
function openContentDraftForm(briefing) {
const existing = state.contentDraft && state.contentDraft.draft ? state.contentDraft.draft : null;
const contentType = existing ? (existing.contentType || 'page') : 'page';
const values = existing && existing.draft ? Object.assign({}, existing.draft) : {};
if (briefing && !values.content && !values.description) {
values.content = briefing;
}
state.contentDraft = {
draft: {
contentType,
draft: values,
missing: [],
canCreate: false
}
};
state.contentFormOpen = true;
state.changeFormOpen = false;
state.imageDraft = null;
state.historyOpen = false;
state.modal = null;
renderMessages();
}
function draftContent(instruction) {
syncContentPageFromHost();
state.lastInstruction = instruction;
const existingDraft = state.contentDraft && state.contentDraft.draft ? state.contentDraft.draft : null;
state.proposals = [];
state.contentDraft = null;
state.createdContent = null;
state.imageDraft = null;
state.messages.push({ role: 'user', content: instruction });
setLoading(true, 'copilot.working_content', 'Preparing a content draft...');
renderMessages();
post('ai-copilot-draft-content', {
contentPage,
instruction,
existingDraft
}).then(result => {
state.contentDraft = result && result.draft ? result : null;
const draft = state.contentDraft && state.contentDraft.draft;
state.messages.push({
role: 'assistant',
content: draft && draft.canCreate
? label('copilot.content_draft_ready_message', 'I prepared a new {type} draft. Review the preview before creating it.', { type: draft.contentType })
: label('copilot.content_draft_needs_details_message', 'I prepared a draft, but it needs more details before it can be created.')
});
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
}).finally(() => {
setLoading(false);
renderMessages();
});
}
function submitContentDraftForm(form) {
const current = state.contentDraft && state.contentDraft.draft ? state.contentDraft.draft : null;
if (!current) return;
const values = Object.assign({}, current.draft || {});
form.querySelectorAll('[data-content-draft-field]').forEach(input => {
values[input.getAttribute('data-content-draft-field')] = input.value.trim();
});
state.contentFormOpen = false;
draftContentFromForm(current.contentType || 'page', values);
}
function draftContentFromForm(contentType, values) {
const instruction = label('copilot.update_draft_instruction', 'Update the content draft with the submitted form fields.');
state.lastInstruction = instruction;
state.proposals = [];
state.createdContent = null;
state.imageDraft = null;
setLoading(true, 'copilot.working_content', 'Preparing a content draft...');
renderMessages();
post('ai-copilot-draft-content', {
contentPage,
contentType,
instruction: instruction + ' ' + JSON.stringify(values),
existingDraft: {
contentType,
missing: [],
draft: values
}
}).then(result => {
state.contentDraft = result && result.draft ? result : null;
const draft = state.contentDraft && state.contentDraft.draft;
state.messages.push({
role: 'assistant',
content: draft && draft.canCreate
? label('copilot.content_draft_ready_message', 'I prepared a new {type} draft. Review the preview before creating it.', { type: draft.contentType })
: label('copilot.content_draft_needs_details_message', 'I prepared a draft, but it needs more details before it can be created.')
});
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
}).finally(() => {
setLoading(false);
renderMessages();
});
}
function draftImage(instruction, options) {
options = options || {};
syncContentPageFromHost();
state.lastInstruction = instruction;
state.proposals = [];
state.changeFormOpen = false;
state.contentDraft = null;
state.createdContent = null;
const imageFields = getImageFields();
if (!imageFields.length) {
state.messages.push({ role: 'assistant', content: label('copilot.no_image_field', 'I could not find an editable image field on this page.') });
renderMessages();
return;
}
let field = imageFields[0];
if (state.selectedField && state.selectedField.type === 'image') {
const selectedImageField = imageFields.find(item => item.id === state.selectedField.id || item.path === state.selectedField.path);
if (selectedImageField) field = selectedImageField;
}
if (!options.silent && String(instruction || '').trim()) {
state.messages.push({ role: 'user', content: instruction });
}
state.imageDraft = {
instruction: extractImagePrompt(instruction),
fields: imageFields,
fieldId: field.id || field.path,
mode: 'generate',
options: {
size: inferImageFieldSize(field),
count: '1',
outputFormat: 'webp',
quality: 'auto'
}
};
updateImageDraftSizeFromField(field);
if (!options.silent) {
state.messages.push({ role: 'assistant', content: label('copilot.image_options_ready', 'Choose the image field and whether to generate a new image or edit the current one.') });
}
renderMessages();
}
function runImageDraft() {
const draft = state.imageDraft;
if (!draft || !Array.isArray(draft.fields) || !draft.fields.length) return;
const field = draft.fields.find(item => (item.id || item.path) === draft.fieldId) || draft.fields[0];
const instruction = String(draft.instruction || '').trim();
if (!instruction) {
state.messages.push({ role: 'assistant', content: label('copilot.image_prompt_required', 'Add a prompt before generating an image draft.') });
renderMessages();
return;
}
if (state.sessionExpired) {
state.messages.push({ role: 'assistant', content: label('copilot.session_expired', 'Your session expired. Please log in again in the dashboard, then reload this page.') });
renderMessages();
return;
}
const useCurrentAsReference = draft.mode === 'edit';
setLoading(true, 'copilot.working_image', 'Generating image...');
renderMessages();
post('ai-copilot-generate-image', {
contentPage,
fieldRef: field.id || field.path,
instruction,
imageMode: useCurrentAsReference ? 'edit' : 'generate',
useCurrentAsReference: useCurrentAsReference ? '1' : '',
size: draft.options && draft.options.size ? draft.options.size : 'auto',
count: draft.options && draft.options.count ? draft.options.count : '1',
outputFormat: draft.options && draft.options.outputFormat ? draft.options.outputFormat : 'webp',
quality: draft.options && draft.options.quality ? draft.options.quality : 'auto'
}, imageRequestTimeoutMs).then(result => {
if (result && result.context) state.context = result.context;
if (result && result.job) {
state.messages.push({ role: 'assistant', content: label('copilot.image_job_queued', 'Image generation is running in the background. You can keep working; I will show the preview when it is ready.') });
runImageJob(result.job);
startImageJobPolling(result.job.id);
return;
}
if (result && result.proposal) {
state.imageDraft = null;
state.proposals = [result.proposal];
state.messages.push({ role: 'assistant', content: label('copilot.image_draft_ready', 'Generated image ready. Review the preview card before applying it.') });
} else {
state.messages.push({ role: 'assistant', content: label('copilot.image_no_proposal', 'Image generation returned no preview.') });
}
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
}).finally(() => {
if (!imageJobPollTimer) {
setLoading(false);
}
renderMessages();
});
}
function extractImagePrompt(instruction) {
let prompt = String(instruction || '').trim();
prompt = prompt.replace(/^\/?(draft|generate|create|make)\s+(an?\s+)?(image|picture|photo|illustration)\s*(of|for|showing|with)?\s*/i, '');
prompt = prompt.replace(/^(ich\s+)?(möchte|will|hätte\s+gerne|brauche|erstelle|generiere|mach|mache)\s+(mir\s+)?(bitte\s+)?(ein|eine|einen)?\s*(bild|foto|illustration)\s*(von|für|mit)?\s*/i, '');
prompt = prompt.replace(/^(bitte\s+)?(ein|eine|einen)\s+(bild|foto|illustration)\s*(von|für|mit)?\s*/i, '');
prompt = prompt.replace(/^["'„“”]+|["'„“”]+$/g, '').trim();
return prompt || String(instruction || '').trim();
}
function trackSelectedEditableField(event) {
const target = event.target instanceof Element ? event.target : null;
if (!target || target.closest('.rj-copilot')) return;
const editable = target.closest('[data-field][data-page]');
if (!editable) return;
const page = editable.getAttribute('data-page') || '';
if (page !== contentPage) return;
const path = editable.getAttribute('data-field') || '';
state.selectedFieldPath = path;
markSelectedElement(editable);
const field = findContextField(path);
if (!field) return;
state.selectedField = field;
if (state.open) renderMessages();
}
function findContextField(path) {
const fields = state.context && state.context.page && Array.isArray(state.context.page.fields)
? state.context.page.fields
: [];
return fields.find(field => field && field.path === path)
|| fields.find(field => field && typeof field.path === 'string' && field.path.indexOf(path + '.') === 0)
|| null;
}
function findContextFieldByPath(path) {
const fields = state.context && state.context.page && Array.isArray(state.context.page.fields)
? state.context.page.fields
: [];
return fields.find(field => field && field.path === path) || null;
}
function changeTargetLabel(field) {
if (!field) return label('copilot.unknown_field', 'selected field');
const raw = field.label || readableFieldLabel(field.path || field.id || '');
const section = String(raw).match(/^(Sec\. \d+ [^:]+):\s*(.*?)\s+-\s+(.+)$/);
if (section) {
return truncateTargetLabel('[' + section[1] + '] ' + section[2] + ' - ' + section[3], 104);
}
return truncateOptionLabel(raw, 104);
}
function getChangeTargetFields() {
const fields = state.context && state.context.page && Array.isArray(state.context.page.fields)
? state.context.page.fields
: [];
return fields.filter(field => {
if (!field || !Array.isArray(field.operations)) return false;
if (!/^sections\./i.test(String(field.path || ''))) return false;
if (/^seo(?:\.|$)/i.test(String(field.path || ''))) return false;
return field.operations.includes('suggest')
|| field.operations.includes('format-html')
|| field.operations.includes('toggle-visibility')
|| field.operations.includes('toggleVisibility');
});
}
function findChangeTargetField(ref) {
const value = String(ref || '');
if (!value) return null;
return getChangeTargetFields().find(field => field && (field.id === value || field.path === value)) || null;
}
function markContextElementByPath(path) {
if (!path) {
clearSelectedElement();
return;
}
const selector = fieldSelector(path);
const element = selector ? document.querySelector(selector) : null;
if (element) {
markSelectedElement(element);
return;
}
clearSelectedElement();
}
function resolveSelectedFieldForInstruction(instruction) {
if (!state.selectedFieldPath) return state.selectedField;
const basePath = state.selectedFieldPath;
const text = String(instruction || '').toLowerCase();
if (state.selectedElement && state.selectedElement.matches('a[data-editable-link]')) {
const wantsText = /\b(text|label|caption|wording|title|beschriftung|linktext|buttontext|text ändern|umbenennen)\b/.test(text);
const wantsHref = /\b(url|href|link|target|ziel|adresse|weiterleitung|verlinkung)\b/.test(text);
const preferredPaths = wantsText && !wantsHref
? [basePath + '.text', basePath + '.label', basePath + '.title', basePath + '.href', basePath + '.url']
: [basePath + '.href', basePath + '.url', basePath + '.text', basePath + '.label', basePath + '.title'];
for (const path of preferredPaths) {
const field = findContextFieldByPath(path);
if (field) return field;
}
}
return state.selectedField || findContextField(basePath);
}
function resolveSelectedField() {
if (!state.selectedFieldPath) return;
state.selectedField = findContextField(state.selectedFieldPath);
if (!state.selectedField) {
clearSelectedElement();
}
}
function markSelectedElement(element) {
clearSelectedElement();
if (!document.body.classList.contains('visual-editing')) return;
state.selectedElement = element;
element.classList.add('rj-copilot-selected-field');
}
function clearSelectedElement() {
if (state.selectedElement && document.contains(state.selectedElement)) {
state.selectedElement.classList.remove('rj-copilot-selected-field');
}
state.selectedElement = null;
}
function getImageFields() {
const fields = state.context && state.context.page && Array.isArray(state.context.page.fields)
? state.context.page.fields
: [];
return fields.filter(field => {
return field && Array.isArray(field.operations) && field.operations.includes('generate-image');
});
}
function inferImageFieldSize(field) {
if (!field) return 'auto';
const src = String(field.preview || field.value || field.path || '').trim();
const lower = src.toLowerCase();
if (/\b(portrait|hochformat|vertical)\b/.test(lower)) return '1024x1536';
if (/\b(square|quadratisch)\b/.test(lower)) return '1024x1024';
if (/\b(landscape|querformat|wide|hero|banner)\b/.test(lower)) return '1536x1024';
return 'auto';
}
function imageSizeFromDimensions(width, height) {
width = Number(width || 0);
height = Number(height || 0);
if (!width || !height) return 'auto';
const ratio = width / height;
if (ratio >= 1.18) return '1536x1024';
if (ratio <= 0.85) return '1024x1536';
return '1024x1024';
}
function updateImageDraftSizeFromField(field) {
if (!state.imageDraft || !field || state.imageDraft.sizeTouched) return;
const fallback = inferImageFieldSize(field);
const src = imageFieldPreviewSrc(field);
if (!src || /^(api\.php\?|data:|blob:)/i.test(src)) {
if (fallback !== 'auto') {
state.imageDraft.options = Object.assign({}, state.imageDraft.options || {}, { size: fallback });
renderMessages();
}
return;
}
const img = new Image();
img.onload = function () {
if (!state.imageDraft || state.imageDraft.sizeTouched) return;
const active = state.imageDraft.fields.find(item => (item.id || item.path) === state.imageDraft.fieldId);
if (!active || (active.id || active.path) !== (field.id || field.path)) return;
const size = imageSizeFromDimensions(img.naturalWidth, img.naturalHeight);
if (size === 'auto') return;
state.imageDraft.options = Object.assign({}, state.imageDraft.options || {}, { size });
renderMessages();
};
img.onerror = function () {
if (!state.imageDraft || state.imageDraft.sizeTouched || fallback === 'auto') return;
const active = state.imageDraft.fields.find(item => (item.id || item.path) === state.imageDraft.fieldId);
if (!active || (active.id || active.path) !== (field.id || field.path)) return;
state.imageDraft.options = Object.assign({}, state.imageDraft.options || {}, { size: fallback });
renderMessages();
};
img.src = src;
}
function permissions() {
return state.context && state.context.user && state.context.user.permissions
? state.context.user.permissions
: {};
}
function hasPermission(name) {
return permissions()[name] === true;
}
function canDraftChanges() {
if (copilotMode === 'dashboard') return false;
return (hasPermission('suggestField') || hasPermission('toggleVisibility')) && getChangeTargetFields().length > 0;
}
function canDraftContent() {
const contentTypes = Array.isArray(state.context && state.context.contentTypes)
? state.context.contentTypes
: [];
const permissionByType = {
page: 'createPage',
news: 'createNews',
event: 'createEvent'
};
return contentTypes.some(type => {
return type && type.available && hasPermission(permissionByType[type.id] || '');
});
}
function canDraftImage() {
if (copilotMode === 'dashboard') return false;
const ai = state.context && state.context.ai ? state.context.ai : {};
const features = ai.features || {};
const models = ai.models || {};
return hasPermission('generateImage')
&& ai.enabled === true
&& (ai.imageUsable === undefined ? ai.hasApiKey === true : ai.imageUsable === true)
&& features.imageGeneration === true
&& Boolean(models.image)
&& getImageFields().length > 0;
}
function updateActionAvailability() {
const draftBtn = document.querySelector('.rj-copilot-draft');
const contentBtn = document.querySelector('.rj-copilot-content');
const imageBtn = document.querySelector('.rj-copilot-image');
const sendBtn = document.querySelector('.rj-copilot-send');
const input = document.querySelector('.rj-copilot-input');
const hasPrompt = input ? input.value.trim() !== '' : false;
const needsPrompt = label('copilot.shortcut_needs_prompt', 'Enter an instruction first.');
if (sendBtn) {
const agentBusy = !!(agentHook && agentHook.isActive() && agentHook.isBusy());
sendBtn.disabled = state.loading || state.sessionExpired || !hasPrompt || agentBusy;
sendBtn.title = hasPrompt ? '' : needsPrompt;
}
if (input) input.disabled = state.loading || state.sessionExpired;
if (draftBtn) {
const enabled = canDraftChanges();
draftBtn.disabled = state.sessionExpired || !enabled || state.loading;
draftBtn.title = enabled ? label('copilot.draft_changes_hint', 'Open a guided dialog to prepare a reviewed page change.') : label('copilot.no_safe_field', 'I could not map that request to a safe editable field on this page yet.');
}
if (contentBtn) {
const enabled = canDraftContent();
contentBtn.disabled = state.sessionExpired || !enabled || state.loading;
contentBtn.title = enabled ? label('copilot.draft_content_hint', 'Open a guided dialog to prepare a new page, news post, event, or appointment.') : label('copilot.content_unavailable', 'Content creation is not available for your role or this site configuration.');
}
if (imageBtn) {
const enabled = canDraftImage();
imageBtn.hidden = !enabled;
imageBtn.disabled = state.sessionExpired || !enabled || state.loading;
imageBtn.title = enabled ? label('copilot.draft_image_hint', 'Open image options and enter or refine the image prompt.') : label('copilot.image_unavailable', 'Image generation is not available for your role, this page, or the current AI settings.');
}
}
async function applyProposal(index, options) {
const proposal = state.proposals[index];
if (!proposal) return;
if (proposal.type === 'visibility' || proposal.action === 'toggleFieldVisibility') {
await applyVisibilityProposal(index, options);
return;
}
if (!await ensureVisualEditorForWrite()) return;
const confirmed = options && options.skipConfirm
? true
: await confirmCopilot({
title: label('copilot.apply_confirm_title', 'Apply AI draft?'),
body: label('copilot.apply_confirm', 'Apply this AI draft to "{field}"?', { field: proposal.label || proposal.path }),
items: [proposal.label || proposal.path],
confirmLabel: label('copilot.apply', 'Apply')
});
if (!confirmed) return;
setLoading(true, 'copilot.working_apply', 'Applying the confirmed change...');
renderMessages();
applyProposalObject(proposal).then(result => {
state.proposals.splice(index, 1);
state.lastApplied = result && result.undo ? result : null;
if (proposal.type === 'image') rememberAppliedImage(proposal, result);
state.messages.push({
role: 'assistant',
content: appliedMessageForProposal(proposal)
});
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
}).finally(() => {
setLoading(false);
renderMessages();
});
}
async function applyVisibilityProposal(index, options) {
const proposal = state.proposals[index];
if (!proposal) return;
if (!await ensureVisualEditorForWrite()) return;
const confirmed = options && options.skipConfirm
? true
: await confirmCopilot({
title: label('copilot.apply_confirm_title', 'Apply AI draft?'),
body: label('copilot.apply_confirm', 'Apply this AI draft to "{field}"?', { field: proposal.label || proposal.path }),
items: [proposal.label || proposal.path],
confirmLabel: label('copilot.apply', 'Apply')
});
if (!confirmed) return;
setLoading(true, 'copilot.working_apply', 'Applying the confirmed change...');
renderMessages();
applyProposalObject(proposal).then(result => {
state.proposals.splice(index, 1);
state.lastApplied = result && result.undo ? result : null;
if (proposal.type === 'image') rememberAppliedImage(proposal, result);
state.messages.push({
role: 'assistant',
content: appliedMessageForProposal(proposal)
});
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
}).finally(() => {
setLoading(false);
renderMessages();
});
}
async function applyAllProposals(options) {
if (!state.proposals.length) return;
if (!await ensureVisualEditorForWrite()) return;
const proposals = state.proposals.slice();
const confirmed = options && options.skipConfirm
? true
: await confirmCopilot({
title: label('copilot.apply_all_confirm_title', 'Apply all AI drafts?'),
body: label('copilot.apply_all_confirm_body', 'Apply these {count} AI drafts? A backup is created for every changed page state.', { count: proposals.length }),
items: proposals.map(proposal => (proposal.label || proposal.path) + ': ' + proposalSummaryText(proposal)),
confirmLabel: label('copilot.apply_all', 'Apply all')
});
if (!confirmed) return;
setLoading(true, 'copilot.working_apply', 'Applying the confirmed change...');
renderMessages();
const applied = [];
try {
for (const proposal of proposals) {
const result = await applyProposalObject(proposal);
applied.push({ proposal, result });
state.lastApplied = result && result.undo ? result : state.lastApplied;
}
state.proposals = state.proposals.filter(proposal => !proposals.includes(proposal));
state.messages.push({
role: 'assistant',
content: label('copilot.applied_all_message', 'Applied {count} AI drafts. A backup was created before each save.', { count: applied.length })
});
} catch (error) {
state.messages.push({ role: 'assistant', content: error.message });
} finally {
setLoading(false);
renderMessages();
}
}
function applyProposalObject(proposal) {
if (proposal.type === 'visibility' || proposal.action === 'toggleFieldVisibility') {
return post('ai-copilot-apply-visibility', {
contentPage,
path: proposal.path,
value: proposal.value,
currentHash: proposal.currentHash || '',
visibilitySignature: proposal.visibilitySignature || '',
confirmed: '1'
}).then(result => {
patchVisibility(result.path, result.hidden === true);
return result;
});
}
// Translation proposals target the same page in another language;
// their signatures are bound to that page, and the local DOM must
// not be patched for changes on a different page.
const proposalPage = proposal.contentPage || contentPage;
return post('ai-copilot-apply', {
contentPage: proposalPage,
path: proposal.path,
value: proposal.value,
altValue: proposal.altValue || '',
currentHash: proposal.currentHash || '',
allowedValueHashes: proposal.allowedValueHashes || [],
proposalSignature: proposal.proposalSignature || '',
confirmed: '1'
}).then(result => {
if (proposalPage === contentPage) {
patchPageField(result.path, result.value, result.altValue || '');
}
return result;
});
}
function rememberAppliedImage(proposal, result) {
const value = result && result.value ? result.value : (proposal.value || proposal.preview || '');
if (!value) return;
state.lastImageResult = {
path: value,
alt: result && result.altValue ? result.altValue : (proposal.altValue || ''),
prompt: proposal.reason || state.lastInstruction || '',
field: proposal.path || '',
label: proposal.label || proposal.path || ''
};
}
function appliedMessageForProposal(proposal) {
if (proposal && proposal.type === 'image') {
return label('copilot.applied_image_message', 'Applied the generated image to {field}. A backup was created before saving.', { field: proposal.label || proposal.path });
}
return label('copilot.applied_message', 'Applied the draft to {field}. A backup was created before saving.', { field: proposal.label || proposal.path });
}
function copyProposal(index) {
const proposal = state.proposals[index];
if (!proposal) return;
const value = proposal.type === 'image'
? (proposal.value || proposal.preview || '')
: (proposal.value != null ? proposal.value : proposal.preview || '');
const text = String(value || '');
if (!text) return;
copyText(text).then(() => {
state.messages.push({
role: 'assistant',
content: label('copilot.copied_message', 'Copied the draft value for {field}.', { field: proposal.label || proposal.path })
});
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
}).finally(renderMessages);
}
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
return new Promise((resolve, reject) => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy') ? resolve() : reject(new Error(label('copilot.copy_failed', 'Could not copy the draft value.')));
} catch (error) {
reject(error);
} finally {
textarea.remove();
}
});
}
function selectImageVariant(value) {
const parts = String(value || '').split(':');
const proposalIndex = Number(parts[0]);
const variantIndex = Number(parts[1]);
const proposal = Number.isInteger(proposalIndex) ? state.proposals[proposalIndex] : null;
if (!proposal || !Array.isArray(proposal.paths) || !proposal.paths[variantIndex]) return;
proposal.value = proposal.paths[variantIndex];
proposal.preview = proposal.paths[variantIndex];
renderMessages();
}
function selectImageDraftField(fieldId) {
if (!state.imageDraft || !fieldId) return;
const match = state.imageDraft.fields.find(field => (field.id || field.path) === fieldId);
if (!match) return;
state.imageDraft.fieldId = fieldId;
updateImageDraftSizeFromField(match);
renderMessages();
}
function selectImageDraftMode(mode) {
if (!state.imageDraft || !['generate', 'edit'].includes(mode)) return;
state.imageDraft.mode = mode;
renderMessages();
}
function updateImageDraftOption(name, value) {
if (!state.imageDraft || !name) return;
const allowed = {
size: ['auto', '1024x1024', '1536x1024', '1024x1536'],
count: ['1', '2', '3', '4'],
outputFormat: ['webp', 'png', 'jpeg'],
quality: ['auto', 'low', 'medium', 'high']
};
if (!allowed[name] || !allowed[name].includes(String(value))) return;
state.imageDraft.options = Object.assign({}, state.imageDraft.options || {}, { [name]: String(value) });
if (name === 'size') state.imageDraft.sizeTouched = true;
}
async function undoLastApply() {
const undo = state.lastApplied && state.lastApplied.undo;
if (!undo || !undo.backup) return;
const confirmed = await confirmCopilot({
title: label('copilot.undo_confirm_title', 'Undo last AI change?'),
body: label('copilot.undo_confirm', 'Undo the last AI change on this page? The current state will be backed up first.'),
confirmLabel: label('copilot.undo', 'Undo')
});
if (!confirmed) return;
setLoading(true, 'copilot.working_undo', 'Restoring the previous version...');
renderMessages();
post('ai-copilot-undo', {
contentPage: undo.contentPage || contentPage,
backup: undo.backup,
path: undo.path || state.lastApplied.path || '',
undoSignature: undo.undoSignature || '',
confirmed: '1'
}).then(() => {
state.lastApplied = null;
state.messages.push({
role: 'assistant',
content: label('copilot.undo_message', 'Restored the page from the backup. Reloading the page now.')
});
renderMessages();
window.setTimeout(() => window.location.reload(), 650);
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
}).finally(() => {
setLoading(false);
renderMessages();
});
}
async function createContentDraft(options) {
if (!state.contentDraft || !state.contentDraft.draft) return;
const draft = state.contentDraft.draft;
if (!draft.canCreate) return;
const confirmed = options && options.skipConfirm
? true
: await confirmCopilot({
title: label('copilot.create_confirm_title', 'Create draft?'),
body: label('copilot.create_confirm', 'Create this {type} draft?', { type: draft.contentType }),
items: Object.entries(draft.draft || {}).slice(0, 6).map(([key, value]) => key + ': ' + raijuShortSummary(value)),
confirmLabel: label('copilot.create_draft', 'Create draft')
});
if (!confirmed) return;
setLoading(true, 'copilot.working_create', 'Creating the confirmed draft...');
renderMessages();
post('ai-copilot-create-content', {
draft,
confirmed: '1'
}).then(result => {
state.contentDraft = null;
state.createdContent = result;
let createdMessage = label('copilot.created_message', 'Created {type} draft "{id}". It is not published publicly by default.', {
type: result.contentType,
id: result.id
});
if (result.contentType === 'page') {
createdMessage += '\n\n' + label('copilot.created_page_navigation_hint', 'Open the page in the Content Editor and set Page Settings / Navigation if it should appear in a menu.');
}
state.messages.push({
role: 'assistant',
content: createdMessage
});
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
}).finally(() => {
setLoading(false);
renderMessages();
});
}
async function publishCreatedContent(options) {
const created = state.createdContent;
if (!created || !created.id || !created.contentType) return;
const confirmed = options && options.skipConfirm
? true
: await confirmCopilot({
title: label('copilot.publish_confirm_title', 'Publish draft?'),
body: label('copilot.publish_confirm', 'Publish this draft now?'),
items: [created.contentType + ': ' + created.id],
confirmLabel: label('copilot.publish', 'Publish')
});
if (!confirmed) return;
setLoading(true, 'copilot.working_publish', 'Publishing the confirmed draft...');
renderMessages();
post('ai-copilot-publish-content', {
contentType: created.contentType,
id: created.id,
confirmed: '1'
}).then(result => {
state.createdContent = Object.assign({}, created, result, {
private: false,
hidden: false,
publishable: false
});
state.messages.push({
role: 'assistant',
content: label('copilot.published_message', 'Published {type} "{id}".', {
type: result.contentType || created.contentType,
id: result.id || created.id
})
});
}).catch(error => {
state.messages.push({ role: 'assistant', content: error.message });
}).finally(() => {
setLoading(false);
renderMessages();
});
}
function patchPageField(path, value, altValue) {
if (!path) return;
const selectors = [fieldSelector(path)];
if (path.endsWith('.href') || path.endsWith('.url') || path.endsWith('.text') || path.endsWith('.label') || path.endsWith('.title')) {
selectors.push(fieldSelector(path.replace(/\.(href|url|text|label|title)$/, '')));
}
if (path.endsWith('.src') || path.endsWith('.image')) {
selectors.push(fieldSelector(path.replace(/\.(src|image)$/, '')));
} else {
selectors.push(fieldSelector(path + '.src'), fieldSelector(path + '.image'));
}
document.querySelectorAll(unique(selectors).join(',')).forEach(element => {
if (element.matches('img[data-editable-image]')) {
element.setAttribute('src', value);
if (altValue) {
element.setAttribute('alt', altValue);
patchAltField(element.getAttribute('data-alt-field'), altValue);
}
} else if (element.matches('a[data-editable-link]')) {
if (path.endsWith('.text') || path.endsWith('.label') || path.endsWith('.title')) {
element.textContent = value;
} else {
element.setAttribute('href', value);
}
} else if (element.classList.contains('editable-field-html')) {
element.innerHTML = value;
} else {
element.textContent = value;
}
});
}
function patchVisibility(path, hidden) {
if (!path) return;
const selectors = [fieldSelector(path)];
if (path.endsWith('.href') || path.endsWith('.url') || path.endsWith('.text') || path.endsWith('.label') || path.endsWith('.title')) {
selectors.push(fieldSelector(path.replace(/\.(href|url|text|label|title)$/, '')));
}
if (path.endsWith('.src') || path.endsWith('.image')) {
selectors.push(fieldSelector(path.replace(/\.(src|image)$/, '')));
} else {
selectors.push(fieldSelector(path + '.src'), fieldSelector(path + '.image'));
}
document.querySelectorAll(unique(selectors).join(',')).forEach(element => {
if (hidden) {
element.setAttribute('data-hidden', 'true');
element.classList.add('editable-hidden');
} else {
element.removeAttribute('data-hidden');
element.classList.remove('editable-hidden');
}
});
}
function patchAltField(altField, altValue) {
if (!altField || !altValue) return;
document.querySelectorAll(fieldSelector(altField)).forEach(element => {
if (element.matches('input, textarea')) {
element.value = altValue;
} else if (element.classList.contains('editable-field-html')) {
element.innerHTML = altValue;
} else {
element.textContent = altValue;
}
});
}
function fieldSelector(fieldPath) {
return '[data-page="' + cssEscape(contentPage) + '"][data-field="' + cssEscape(fieldPath) + '"]';
}
function unique(values) {
return values.filter((value, index) => values.indexOf(value) === index);
}
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(value);
}
return String(value).replace(/["\\]/g, '\\$&');
}
function sessionKey() {
return 'raiju-ai-assistant:' + copilotMode + ':' + (contentPage || window.location.pathname || 'site');
}
function newSessionId() {
return 'chat-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
}
function restoreSession() {
try {
const raw = window.sessionStorage ? window.sessionStorage.getItem(sessionKey()) : '';
if (!raw) {
state.currentSessionId = newSessionId();
return;
}
const data = JSON.parse(raw);
state.currentSessionId = typeof data.currentSessionId === 'string' && data.currentSessionId
? data.currentSessionId.slice(0, 80)
: newSessionId();
if (data && Array.isArray(data.messages)) {
state.messages = data.messages
.filter(message => message && (message.role === 'assistant' || message.role === 'user') && typeof message.content === 'string')
.slice(-30)
.map(message => ({
role: message.role,
content: message.content.slice(0, 3000)
}));
}
state.lastInstruction = typeof data.lastInstruction === 'string' ? data.lastInstruction.slice(0, 3000) : '';
state.lastImageResult = data.lastImageResult && typeof data.lastImageResult.path === 'string'
? {
path: data.lastImageResult.path.slice(0, 500),
alt: String(data.lastImageResult.alt || '').slice(0, 500),
prompt: String(data.lastImageResult.prompt || '').slice(0, 1000),
field: String(data.lastImageResult.field || '').slice(0, 500),
label: String(data.lastImageResult.label || '').slice(0, 500)
}
: null;
state.open = data.open === true;
state.maximized = data.maximized === true;
} catch (error) {
// Ignore broken browser storage and continue with a fresh assistant session.
state.currentSessionId = newSessionId();
}
}
function persistSession() {
try {
if (!window.sessionStorage) return;
window.sessionStorage.setItem(sessionKey(), JSON.stringify({
messages: state.messages.slice(-30).map(message => ({
role: message.role,
content: String(message.content || '').slice(0, 3000)
})),
currentSessionId: state.currentSessionId || '',
lastInstruction: String(state.lastInstruction || '').slice(0, 3000),
lastImageResult: state.lastImageResult,
open: (state.embedded ? state.openBeforeEmbed : state.open) === true,
maximized: state.maximized === true
}));
archiveSessionDebounced();
} catch (error) {
// Storage limits or privacy settings should not break editing.
}
}
function archiveSessionDebounced() {
if (state.sessionExpired || state.historySaving || !state.currentSessionId) return;
if (!state.messages.some(message => message.role === 'user')) return;
if (state.historyTimer) window.clearTimeout(state.historyTimer);
state.historyTimer = window.setTimeout(saveChatHistoryNow, 1200);
}
function saveChatHistoryNow() {
if (state.sessionExpired || state.historySaving || !state.currentSessionId) return;
if (!state.messages.some(message => message.role === 'user')) return;
state.historySaving = true;
const page = state.context && state.context.page ? state.context.page : {};
const firstUserMessage = state.messages.find(message => message.role === 'user');
post('ai-copilot-history-save', {
id: state.currentSessionId,
contentPage,
pageTitle: page.title || document.title || '',
url: window.location.href,
title: firstUserMessage ? firstUserMessage.content : '',
messages: state.messages.map(message => ({ role: message.role, content: message.content })),
lastInstruction: state.lastInstruction || '',
lastImageResult: state.lastImageResult || null
}).then(result => {
const chat = result && result.chat ? result.chat : null;
if (chat && chat.id) {
state.currentSessionId = chat.id;
}
}).catch(() => {
// Archiving must never interrupt editing.
}).finally(() => {
state.historySaving = false;
});
}
// What the agent may use of the assistant: plain helpers and the chat list, nothing else.
function agentHost() {
return {
mode: copilotMode,
apiUrl,
csrfToken,
label,
escHtml,
post,
confirm: confirmCopilot,
pushMessage: (role, content) => state.messages.push({ role, content: String(content || '') }),
hasMessage: (role, content) => state.messages.some(message => message.role === role && message.content === String(content || '')),
render: renderMessages,
updateActions: updateActionAvailability,
setLimitWarnings,
contentPage: () => contentPage,
open: () => setOpen(true)
};
}
/**
* Put the chat into a container of the page (a card) instead of the floating bubble, or back (host null). The same
* chat and state move; the bubble and the close and maximize buttons are hidden while it sits in a card. Afterwards it is as
* open as before, or open when a conversation is going on.
*/
function embed(host) {
const root = document.querySelector('.rj-copilot');
if (!root) return false;
if (host) {
if (!state.embedded) state.openBeforeEmbed = state.open === true;
state.embedded = true;
host.appendChild(root);
root.classList.add('rj-copilot--embedded');
if (!state.open) setOpen(true);
else loadContext();
} else if (state.embedded) {
state.embedded = false;
document.body.appendChild(root);
root.classList.remove('rj-copilot--embedded');
// Leaving the card while a conversation is going on (a card that opens a settings page): the chat stays open as the floating panel.
setOpen(state.openBeforeEmbed === true || state.messages.some(message => message.role === 'user'));
}
return true;
}
function registerAgent(hook) {
if (agentHook || !hook) return false;
agentHook = hook;
const root = document.querySelector('.rj-copilot');
if (root) {
agentHook.mount(root, agentHost());
renderMessages({ keepScroll: true });
}
return true;
}
window.RaijuAiCopilot = {
open: () => setOpen(true),
close: () => setOpen(false),
maximize: () => setMaximized(true),
minimize: () => setMaximized(false),
embed,
registerAgent
};
window.RaijuAiAssistant = window.RaijuAiCopilot;
// Pages that embed the chat (the dashboard) wait for this: the chat exists from now on.
function announceReady() {
window.dispatchEvent(new CustomEvent('raiju:copilot-ready'));
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
restoreSession();
renderShell();
startImageJobPolling('');
if (state.open) setOpen(true);
announceReady();
});
} else {
restoreSession();
renderShell();
startImageJobPolling('');
if (state.open) setOpen(true);
announceReady();
}
})();