(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, '$1'); html = html.replace(/\*\*([^*\n][\s\S]*?[^*\n])\*\*/g, '$1'); html = html.replace(/__([^_\n][\s\S]*?[^_\n])__/g, '$1'); html = html.replace(/(^|[^*])\*([^*\n][^*\n]*?[^*\n])\*(?!\*)/g, '$1$2'); html = html.replace(/(^|[^_])_([^_\n][^_\n]*?[^_\n])_(?!_)/g, '$1$2'); html = html.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); return html.replace(/\n/g, '
'); } 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 '
' + '' + escHtml(label('copilot.current_field_content', 'Current content')) + '' + '' + escHtml(body) + '

' + '
'; } function icon(name) { const icons = { history: '', newChat: '', trash: '', maximize: '', minimize: '', close: '' }; 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 '

' + escHtml(text) + '

'; }); return '
' + lines.join('') + '

' + escHtml(label('ai.limit_ask_admin', '')) + '

'; } 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 '
' + '
' + renderInlineMarkdown(message.content) + '
' + '
' + renderDashboardActions(message, messageIndex); }).join(''); const loading = state.loading ? '
' + escHtml(state.loadingMessage || label('copilot.working', 'AI Assistant is working...')) + '
' : ''; 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 ? '' + escHtml(label('copilot.dash_open_new_tab', 'Open in a new tab')) + '' : ''; } 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 ? '' + escHtml(action.detail) + '' : ''; let footer = ''; if (action.type === 'link') { footer = dashboardNewTabLink(action); } else if (action.type === 'noop') { footer = ''; } else if (action.status === 'working') { footer = '' + escHtml(label('copilot.dash_applying', 'Working...')) + ''; } else if (action.status === 'done') { footer = '' + escHtml(action.note || label('copilot.dash_done', 'Done.')) + ''; } else { const run = action.type === 'open' ? label('copilot.dash_open_btn', 'Open') : label('copilot.dash_apply', 'Apply'); footer = (action.status === 'failed' ? '' + escHtml(action.note || label('copilot.dash_failed', 'That did not work.')) + '' : '') + '' + (action.type === 'open' ? dashboardNewTabLink(action) : '') + ''; } return '
' + escHtml(action.title || '') + '' + detail + '
' + (footer ? '' : '') + '
'; }).join(''); return cards ? '
' + cards + '
' : ''; } 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 = '' + targetFields.map(field => { const ref = String(field.id || field.path || ''); const selectedAttr = ref && ref === selectedRef ? ' selected' : ''; return ''; }).join(''); return ''; } 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 '
' + escHtml(key) + '

' + escHtml(value == null ? '' : String(value)).replace(/\n/g, '
') + '

'; }).join(''); const missing = Array.isArray(draft.missing) && draft.missing.length ? '

' + escHtml(label('copilot.missing', 'Missing: {fields}', { fields: draft.missing.join(', ') })) + '

' : ''; const action = draft.canCreate ? '' : ''; return '
' + '
' + escHtml(label('copilot.content_draft_title', '{type} draft', { type: (draft.contentType || 'content').toUpperCase() })) + '' + escHtml(draft.canCreate ? label('copilot.ready_to_create', 'Ready to create') : label('copilot.needs_details', 'Needs details')) + '
' + '
' + fields + '
' + (draft.safetyNote ? '' : '') + missing + '' + '
'; } 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 ? '' + escHtml(label('copilot.open_dashboard', 'Open in dashboard')) + '' : ''; const publicLink = created.publicUrl ? '' + escHtml(label('copilot.open_preview', 'Open preview URL')) + '' : ''; const publishBtn = created.publishable && (created.private || created.hidden) ? '' : ''; return '
' + '
' + escHtml(label('copilot.created_title', '{type} created', { type: (created.contentType || 'content').toUpperCase() })) + '' + escHtml(status) + '
' + '

' + escHtml(created.id) + '

' + '' + '
'; } function renderLastImageResult() { const image = state.lastImageResult; if (!image || !image.path) return ''; const alt = image.alt ? '' + escHtml(label('copilot.alt_text', 'Alt text')) + ': ' + escHtml(image.alt) + '' : ''; const prompt = image.prompt ? '' + escHtml(label('copilot.image_prompt', 'Prompt')) + ': ' + escHtml(raijuShortSummary(image.prompt)) + '' : ''; const src = image.path.charAt(0) === '/' ? image.path : '/' + image.path; return '
' + '' + '
' + '' + escHtml(label('copilot.generated_image_applied', 'Generated image applied')) + '' + '' + escHtml(image.label || image.field || '') + '' + prompt + alt + '
' + '
'; } 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 ? '' : '' + escHtml(field.label || field.path) + ''; return ''; }).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 ''; }; 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 ''; }).join(''); return ''; }; const promptHtml = ''; const optionsHtml = '
' + 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') } ]) + '
'; return '
' + '
' + fieldButtons + '
' + '
' + escHtml(label('copilot.image_mode_label', 'Mode')) + '
' + modeButton('generate') + modeButton('edit') + '
' + promptHtml + optionsHtml + '' + '
'; } 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 '
' + '
' + escHtml(label('copilot.last_change', 'Last AI change applied')) + '' + escHtml(label('copilot.undo_detail', 'Changed: {field}', { field: undoDetail })) + '
' + '' + '
'; } function renderProposals() { if (!state.proposals.length) return ''; const summary = state.proposals.length > 1 ? '
' + '
' + escHtml(label('copilot.proposal_summary_title', 'Planned changes')) + '' + escHtml(label('copilot.proposal_summary_hint', '{count} changes will be reviewed together.', { count: state.proposals.length })) + '
' + '' + '' + '
' : ''; 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 ? '
' + proposal.paths.map((path, variantIndex) => { const active = path === proposal.value ? ' is-active' : ''; return ''; }).join('') + '
' : ''; const altHtml = isImage && proposal.altValue ? '' : ''; return '
' + '
' + escHtml(proposal.label || proposal.path) + '' + escHtml(proposal.type || 'field') + '
' + '
' + '
' + escHtml(label('copilot.current', 'Current')) + '

' + currentHtml + '

' + '
' + escHtml(label('copilot.draft', 'Draft')) + '

' + draftHtml + '

' + '
' + variantsHtml + altHtml + (proposal.reason ? '

' + escHtml(proposal.reason) + '

' : '') + (proposal.safetyNote ? '' : '') + '' + '
'; }).join(''); return '
' + summary + cards + '
'; } 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 ''; } if (proposal.type === 'boolean') { const boolText = String(value).toLowerCase() === 'true' || value === true ? 'true' : 'false'; return '' + boolText + ''; } if (proposal.type === 'visibility') { const stateText = String(value || '').toLowerCase() === 'hidden' || String(value || '').toLowerCase() === 'hide' ? 'hidden' : 'visible'; return '' + escHtml(stateText) + ''; } if (proposal.type === 'select') { return '' + escHtml(value || '') + ''; } return escHtml(value || '').replace(/\n/g, '
'); } function contextIntro(context) { if (!context.ai || !context.ai.enabled || !context.ai.hasApiKey || !context.ai.features.backendAssistant) { return '
' + escHtml(label('copilot.not_ready', 'AI is not ready yet. Check AI Settings in the dashboard.')) + '
'; } 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 ? '' + escHtml(label('copilot.selected_field', 'Selected field: {field}', { field: state.selectedField.label || state.selectedField.path })) + '' : ''; return '
' + '' + escHtml(title) + '' + '' + 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 : '')) + '' + selected + '
'; } function renderShell() { const root = document.createElement('div'); root.className = 'rj-copilot'; root.innerHTML = '' + '' + '' + '
'; 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 ? '' : ''; host.innerHTML = ''; const confirm = host.querySelector('.rj-copilot-modal-confirm'); if (confirm) confirm.focus(); return; } if (state.imageDraft) { host.innerHTML = ''; 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 ''; } if (field.kind === 'lang') { const options = (languageOptions.length ? languageOptions : [['en', 'English']]).map(([code, name]) => { return ''; }).join(''); return ''; } return ''; }).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]) => '').join(''); return ''; } 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 '
' + '' + '' + '
'; }).join('') : '

' + escHtml(state.historyLoaded ? label('copilot.history_empty', 'No archived chats yet.') : label('copilot.history_loading', 'Loading chat history...')) + '

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