// ============================================================ // SETTINGS MANAGEMENT // ============================================================ // Costs are kept with four decimals of a cent; they are rounded here, when shown. Amounts under one cent keep their decimals. function formatAiCents(cents) { var euro = (parseFloat(cents) || 0) / 100; return (euro > 0 && euro < 0.01 ? euro.toFixed(4) : euro.toFixed(2)) + ' EUR'; } function updateAiUsage(usage) { var el = document.getElementById('aiUsageSummary'); if (!AI_FEATURES_ENABLED || !aiServiceIsUsable(currentAiSettings || {})) { if (el) el.hidden = true; renderAiUsagePanel(null); return; } renderAiLimitWarnings(usage ? usage.warnings : []); if (!el || !usage) return; el.hidden = false; var today = usage.today || {}; var month = usage.month || {}; el.textContent = t('ai.usage_summary', { today: today.requests || 0, cost: formatAiCents(month.estimatedCostCents || 0) }); renderAiUsagePanel(usage); } // ---- Limit warnings --------------------------------------------------------------------------------------------- var AI_LIMIT_FIELDS = { month: 'aiMonthlyBudget', requests: 'aiDailyRequests', text: 'aiDailyTextRequests', images: 'aiDailyImageRequests' }; var aiLimitWarnings = []; function aiLimitValue(kind, value) { return kind === 'month' ? formatAiCents(value) : String(value); } function aiLimitDismissKey(warning) { return 'nb.aiLimitDismissed:' + warning.kind + ':' + warning.level + ':' + new Date().toISOString().slice(0, warning.kind === 'month' ? 7 : 10); } function aiLimitDismissed(warning) { if (warning.level === 'reached') return false; try { return window.sessionStorage.getItem(aiLimitDismissKey(warning)) === '1'; } catch (error) { return false; } } function renderAiLimitWarnings(warnings) { aiLimitWarnings = Array.isArray(warnings) ? warnings : []; document.querySelectorAll('[data-ai-limit-banner]').forEach(function(box) { var shown = aiLimitWarnings.filter(function(warning) { return !aiLimitDismissed(warning); }); box.replaceChildren(); box.hidden = !shown.length || !AI_FEATURES_ENABLED; if (box.hidden) return; var isAdmin = USER_ROLE === 'admin'; var inner = document.createElement('div'); inner.className = 'info-banner__inner'; shown.forEach(function(warning) { var line = document.createElement('p'); line.className = 'ai-limit-banner__line ai-limit-banner__line--' + warning.level; var params = { kind: t('ai.limit_kind_' + warning.kind), used: aiLimitValue(warning.kind, warning.used), limit: aiLimitValue(warning.kind, warning.limit), percent: warning.percent }; line.textContent = t(warning.level === 'reached' ? 'ai.limit_reached' : 'ai.limit_warning', params); if (warning.level !== 'reached') { var dismiss = document.createElement('button'); dismiss.type = 'button'; dismiss.className = 'btn btn-link btn-sm'; dismiss.textContent = t('ai.limit_dismiss'); dismiss.addEventListener('click', function() { try { window.sessionStorage.setItem(aiLimitDismissKey(warning), '1'); } catch (error) { /* only this view */ } renderAiLimitWarnings(aiLimitWarnings); }); line.append(' ', dismiss); } inner.appendChild(line); }); var actions = document.createElement('div'); actions.className = 'ai-limit-banner__actions'; if (!isAdmin) { var note = document.createElement('span'); note.textContent = t('ai.limit_ask_admin'); actions.appendChild(note); } else if (box.getAttribute('data-ai-limit-banner') === 'settings') { var raise = document.createElement('button'); raise.type = 'button'; raise.className = 'btn btn-secondary btn-sm'; raise.textContent = t('ai.limit_raise'); raise.addEventListener('click', function() { aiLimitWarnings.forEach(function(warning) { var field = document.getElementById(AI_LIMIT_FIELDS[warning.kind]); if (!field) return; // Fields are in cents (month) or counts; raise by half, at least by one. var current = parseInt(field.value || '0', 10) || 0; field.value = Math.max(current + 1, Math.ceil(current * 1.5)); }); showToast(t('ai.limit_raised'), 'info'); }); actions.appendChild(raise); } else { var adjust = document.createElement('button'); adjust.type = 'button'; adjust.className = 'btn btn-secondary btn-sm'; adjust.textContent = t('ai.limit_adjust'); adjust.addEventListener('click', function() { switchTab('settings', { settingsTab: 'ai' }); }); actions.appendChild(adjust); } inner.appendChild(actions); box.appendChild(inner); }); } function aiUsageStatTile(value, label, sub) { var tile = document.createElement('div'); tile.className = 'ai-usage-stat'; var valueEl = document.createElement('span'); valueEl.className = 'ai-usage-stat__value'; valueEl.textContent = value; var labelEl = document.createElement('span'); labelEl.className = 'ai-usage-stat__label'; labelEl.textContent = label; tile.appendChild(valueEl); tile.appendChild(labelEl); if (sub) { var subEl = document.createElement('span'); subEl.className = 'ai-usage-stat__sub'; subEl.textContent = sub; tile.appendChild(subEl); } return tile; } function renderAiUsagePanel(usage) { var panel = document.getElementById('aiUsagePanel'); var body = document.getElementById('aiUsagePanelBody'); if (!panel || !body) return; if (!usage) { panel.hidden = true; return; } panel.hidden = false; var today = usage.today || {}; var month = usage.month || {}; var budgetCents = parseInt((currentAiSettings && currentAiSettings.limits && currentAiSettings.limits.monthlyBudgetCents) || 0, 10); body.innerHTML = ''; var grid = document.createElement('div'); grid.className = 'ai-usage-grid'; grid.appendChild(aiUsageStatTile( String(today.requests || 0), t('ai.usage_requests_today'), t('ai.usage_text_image_split', { text: today.textRequests || 0, images: today.imageRequests || 0 }) )); grid.appendChild(aiUsageStatTile( formatAiCents(month.estimatedCostCents || 0), t('ai.usage_cost_month'), t('ai.usage_requests_month', { requests: month.requests || 0 }) )); grid.appendChild(aiUsageStatTile( ((month.inputTokens || 0) + (month.outputTokens || 0)).toLocaleString(), t('ai.usage_tokens_month'), t('ai.usage_tokens_split', { input: (month.inputTokens || 0).toLocaleString(), output: (month.outputTokens || 0).toLocaleString() }) )); const reservations = usage.reservations || {}; if (month.unpricedImageRequests > 0) { var unpricedImages = document.createElement('p'); unpricedImages.className = 'form-hint ai-unpriced-note'; unpricedImages.textContent = t('ai.usage_unpriced_images', { count: month.unpricedImageRequests }); body.appendChild(unpricedImages); } if (month.unpricedRequests > 0) { var unpriced = document.createElement('p'); unpriced.className = 'form-hint ai-unpriced-note'; unpriced.textContent = t('ai.usage_unpriced', { count: month.unpricedRequests }); body.appendChild(unpriced); } grid.appendChild(aiUsageStatTile(formatAiCents(reservations.reservedCents || 0), t('ai.reserved'), t('ai.pending_requests', { count: reservations.pendingRequests || 0 }))); body.appendChild(grid); const budgetHint = document.createElement('p'); budgetHint.className = 'form-hint'; budgetHint.textContent = t('ai.budget_explanation'); body.appendChild(budgetHint); if (budgetCents > 0) { var spent = (month.estimatedCostCents || 0) + (reservations.reservedCents || 0); var ratio = spent / budgetCents; var budget = document.createElement('div'); budget.className = 'ai-usage-budget'; var header = document.createElement('div'); header.className = 'ai-usage-budget__header'; var label = document.createElement('span'); label.className = 'ai-usage-budget__label'; label.textContent = t('ai.usage_budget'); var value = document.createElement('span'); value.className = 'ai-usage-budget__value'; value.textContent = t('ai.usage_budget_value', { spent: formatAiCents(spent), budget: formatAiCents(budgetCents), percent: Math.min(999, Math.round(ratio * 100)) }); header.appendChild(label); header.appendChild(value); var bar = document.createElement('div'); bar.className = 'ai-usage-budget__bar'; var fill = document.createElement('div'); fill.className = 'ai-usage-budget__fill' + (ratio >= 1 ? ' ai-usage-budget__fill--over' : (ratio >= 0.8 ? ' ai-usage-budget__fill--warn' : '')); fill.style.width = Math.min(100, Math.max(ratio > 0 ? 2 : 0, Math.round(ratio * 100))) + '%'; bar.appendChild(fill); budget.appendChild(header); budget.appendChild(bar); body.appendChild(budget); } } function switchAiToolTab(tool) { var requestedTool = ['image', 'text', 'audit'].indexOf(tool) !== -1 ? tool : 'text'; var availableTabs = Array.from(document.querySelectorAll('.ai-tool-tab')).filter(function(tab) { return !tab.hidden; }); if (!availableTabs.length) return; var activeTool = availableTabs.some(function(tab) { return tab.dataset.aiToolTab === requestedTool; }) ? requestedTool : availableTabs[0].dataset.aiToolTab; document.querySelectorAll('.ai-tool-tab').forEach(function(tab) { var isActive = tab.dataset.aiToolTab === activeTool; tab.classList.toggle('active', isActive); tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); }); document.querySelectorAll('.ai-tool-panel').forEach(function(panel) { panel.hidden = panel.dataset.aiToolPanel !== activeTool; panel.classList.toggle('active', panel.dataset.aiToolPanel === activeTool); }); // Opening the image tool under OpenRouter loads live image prices so // the estimated per-image cost can be shown. if (activeTool === 'image' && aiSavedProviderKey() === 'openrouter') { loadOpenRouterModels(); } } function openAiImageGenerator(promptText, aspectRatio) { if (!dashboardAiImageUsable) { showToast(t('ai.image_generator_disabled'), 'warning'); return; } switchTab('home'); switchAiToolTab('image'); var prompt = document.getElementById('aiImagePrompt'); if (prompt && promptText) { prompt.value = promptText; } if (aspectRatio) { setAiImageRatio(aspectRatio); } if (prompt) { prompt.focus(); } } function setAiImageRatio(aspectRatio) { var ratioInput = document.getElementById('aiImageRatio'); if (ratioInput) ratioInput.value = aspectRatio || 'auto'; if (typeof updateAiImageRatioIcon === 'function') { updateAiImageRatioIcon(); } } function getNewsImagePrompt() { var title = document.getElementById('newsTitle')?.value.trim() || ''; var excerpt = document.getElementById('newsExcerpt')?.value.trim() || ''; var content = document.getElementById('newsContentWysiwyg')?.innerText.trim() || ''; var language = document.getElementById('newsLang')?.value.trim() || ''; var parts = [title, excerpt, content].filter(Boolean).join('\n\n').slice(0, 900); return [ 'Create a 16:9 editorial cover image for a website news post.', title ? 'Article title: ' + title : '', language ? 'Content language: ' + language : '', parts ? 'Context:\n' + parts : '', 'Use the article context to choose a fitting subject, mood, and setting.', 'No text, no logo, no UI, no watermark. Natural composition with a clear subject and enough negative space for cropping.' ].filter(Boolean).join('\n\n'); } function getSeoOgImagePrompt() { var title = document.getElementById('seoOgTitle')?.value.trim() || document.getElementById('seoTitle')?.value.trim() || currentContent?.title || currentPage || ''; var description = document.getElementById('seoOgDescription')?.value.trim() || document.getElementById('seoDescription')?.value.trim() || currentContent?.description || ''; var pageText = extractContentText(currentContent || {}).slice(0, 700); return [ 'Create a 16:9 landscape Open Graph/social sharing image for this page. It should work well when cropped to a 1200x630 preview.', title ? 'Page title: ' + title : '', description ? 'Description: ' + description : '', pageText ? 'Page context: ' + pageText : '', 'No embedded text, no logos unless explicitly part of the brand, no UI mockup. Clean social-sharing composition with a strong visual focal point.' ].filter(Boolean).join('\n\n'); } function dashboardAnalyticsRangeLabel(period, count) { if (period === 'months') return t('dashboard_home.range_label_months', {count: count || 12}); if (period === 'years') return t('dashboard_home.range_label_years'); return t('dashboard_home.range_label_days', {count: count || 30}); } function updateDashboardAnalyticsRangeUi(period, count) { var label = dashboardAnalyticsRangeLabel(period, count); var labelMap = { dashboardViewsPeriodLabel: t('dashboard_home.views_period', {period: label}), dashboardVisitorsPeriodLabel: t('dashboard_home.visitors_period', {period: label}), dashboardVisitsPeriodLabel: t('dashboard_home.visits_period', {period: label}), dashboardChartRangeLabel: t('dashboard_home.views_period', {period: label}) }; Object.keys(labelMap).forEach(function(id) { var el = document.getElementById(id); if (el) el.textContent = labelMap[id]; }); document.querySelectorAll('.dashboard-range-tab').forEach(function(tab) { var active = tab.dataset.analyticsPeriod === period && parseInt(tab.dataset.analyticsCount || '0', 10) === count; tab.classList.toggle('active', active); }); } function setDashboardAnalyticsRange(period, count) { currentDashboardAnalyticsRange = { period: ['days', 'months', 'years'].includes(period) ? period : 'days', count: parseInt(count || '0', 10) }; updateDashboardAnalyticsRangeUi(currentDashboardAnalyticsRange.period, currentDashboardAnalyticsRange.count); loadDashboardOverview(); } function formatDashboardDate(value) { if (!value) return '—'; var date = typeof value === 'number' ? new Date(value * 1000) : new Date(value); if (isNaN(date.getTime())) return '—'; return rjFormatDate(date, 'datetime'); } async function openDashboardRecentEdit(item) { if (!item) return; if (item.type === 'news') { switchTab('news'); if (!newsLoaded) { newsLoaded = true; await loadNews(); } editPost(item.id); return; } if (item.type === 'page' && item.id) { window.location.href = '#page/' + item.id; applyDashboardRoute(true); } } function renderDashboardStatus(status) { var target = document.getElementById('dashboardStatusStrip'); if (!target || !status) return; var recent = (status.recentEdits || [])[0] || null; window.dashboardRecentStatusItems = status.recentEdits || []; var backup = status.backup || {}; var users = status.users || {}; var currentUser = users.current || {}; var chips = []; if (isDashboardModuleEnabled('mails')) { var unreadCount = status.messages?.unread || 0; chips.push(''); } var backupText = backup.newest ? formatDashboardDate(backup.newest) : t('dashboard_home.no_backup'); if (VALID_DASHBOARD_TABS.indexOf('backup') !== -1) { chips.push(''); chips.push(''); } if (recent) { var recentTitle = recent.title || recent.id || ''; chips.push(''); } if (currentUser.username) { chips.push(''); } target.innerHTML = chips.join(''); } async function loadDashboardOverview() { try { updateDashboardAnalyticsRangeUi(currentDashboardAnalyticsRange.period, currentDashboardAnalyticsRange.count); var params = new URLSearchParams({ action: 'dashboard-overview', analytics_period: currentDashboardAnalyticsRange.period, analytics_count: String(currentDashboardAnalyticsRange.count), _: String(Date.now()) }); var response = await fetch('api.php?' + params.toString()); var result = await response.json(); if (!result.success) throw new Error(result.message || 'Could not load dashboard overview'); var data = result.data || {}; var analytics = data.analytics || {}; var viewsToday = document.getElementById('dashboardViewsToday'); var viewsPeriod = document.getElementById('dashboardViewsPeriod'); var visitorsPeriod = document.getElementById('dashboardVisitorsPeriod'); var visitsPeriod = document.getElementById('dashboardVisitsPeriod'); var botCount = document.getElementById('dashboardBotCount'); var pageCount = document.getElementById('dashboardPageCount'); var newsCount = document.getElementById('dashboardNewsCount'); if (viewsToday) viewsToday.textContent = analytics.todayViews || 0; if (viewsPeriod) viewsPeriod.textContent = analytics.periodViews || 0; if (visitorsPeriod) visitorsPeriod.textContent = analytics.periodVisitors || 0; if (visitsPeriod) visitsPeriod.textContent = analytics.periodVisits || 0; if (botCount) botCount.textContent = analytics.botRequests || 0; if (pageCount) pageCount.textContent = data.pages || 0; if (newsCount) newsCount.textContent = data.news || 0; renderDashboardStatus(data.status || {}); renderDashboardTopPages(analytics.topPages || []); renderDashboardBreakdown('dashboardReferrers', analytics.referrers || []); renderDashboardBreakdown('dashboardDevices', analytics.devices || []); renderDashboardBreakdown('dashboardBrowsers', analytics.browsers || []); renderDashboardBreakdown('dashboardOs', analytics.os || []); const chart = document.getElementById('dashboardTrafficChart'); let state = document.getElementById('dashboardAnalyticsState'); if (!state && chart) { state = document.createElement('p'); state.id = 'dashboardAnalyticsState'; state.className = 'dashboard-empty'; state.setAttribute('role', 'status'); chart.before(state); } if (state) { state.hidden = analytics.state === 'ready'; state.textContent = t('analytics.' + (analytics.state || 'empty')); } if (chart) chart.hidden = analytics.state !== 'ready'; renderDashboardTrafficChart(analytics.series || []); renderDashboardHourlyChart(analytics.hourlyToday || []); if (AI_FEATURES_ENABLED) { currentAiSettings = (data.ai && data.ai.settings) || {}; populateAiSettings(currentAiSettings); updateAiUsage(data.ai ? data.ai.usage : null); updateDashboardAiPanel(currentAiSettings); } else { updateDashboardAiPanel({}); } } catch (error) { var target = document.getElementById('dashboardTopPages'); if (target) target.textContent = error.message; } } function renderDashboardTopPages(pages) { var target = document.getElementById('dashboardTopPages'); if (!target) return; if (!pages.length) { target.innerHTML = '

' + t('dashboard_home.no_views') + '

'; return; } target.innerHTML = pages.map(function(page) { var visitors = page.visitors || 0; var visits = page.visits || 0; return '
' + '' + escapeHtml(page.title || page.key || '') + '' + t('dashboard_home.views_detail', { views: page.views || 0, visitors: visitors, visits: visits }) + '' + '' + (page.views || 0) + '' + '
'; }).join(''); } function renderDashboardTrafficChart(series) { var target = document.getElementById('dashboardTrafficChart'); if (!target) return; if (!series.length) { target.innerHTML = '

' + t('dashboard_home.no_views') + '

'; return; } var width = 720; var height = 220; var pad = { top: 16, right: 18, bottom: 34, left: 42 }; var chartWidth = width - pad.left - pad.right; var chartHeight = height - pad.top - pad.bottom; var values = series.map(function(item) { return Math.max(0, parseInt(item.views || 0, 10)); }); var maxValue = Math.max.apply(null, values.concat([1])); // Four integer intervals keep low traffic from repeating rounded ticks. var yMax = Math.max(4, Math.ceil(maxValue * 1.2 / 4) * 4); var stepX = series.length > 1 ? chartWidth / (series.length - 1) : chartWidth; function x(index) { return pad.left + index * stepX; } function y(value) { return pad.top + chartHeight - (value / yMax) * chartHeight; } function point(index, value) { return x(index).toFixed(2) + ',' + y(value).toFixed(2); } var line = values.map(function(value, index) { return (index === 0 ? 'M ' : 'L ') + point(index, value); }).join(' '); var area = line + ' L ' + x(values.length - 1).toFixed(2) + ',' + (pad.top + chartHeight).toFixed(2) + ' L ' + pad.left + ',' + (pad.top + chartHeight).toFixed(2) + ' Z'; var grid = [0, 0.25, 0.5, 0.75, 1].map(function(factor) { var yy = pad.top + chartHeight - chartHeight * factor; var label = Math.round(yMax * factor); return '' + '' + label + ''; }).join(''); var labelIndexes = [0, Math.floor((series.length - 1) / 2), series.length - 1].filter(function(value, index, arr) { return arr.indexOf(value) === index; }); var xLabels = labelIndexes.map(function(index) { var label = series[index].label || ''; if (!label) { var date = new Date((series[index].date || '') + 'T00:00:00'); label = isNaN(date.getTime()) ? (series[index].date || '') : rjFormatDate(date, 'short'); } return '' + escapeHtml(label) + ''; }).join(''); var points = values.map(function(value, index) { var date = new Date((series[index].date || '') + 'T00:00:00'); var label = series[index].label || (isNaN(date.getTime()) ? (series[index].date || '') : rjFormatDate(date, 'date')); return '' + escapeHtml(label + ': ' + value + ' ' + t('dashboard_home.views_label')) + ''; }).join(''); target.innerHTML = '' + '' + grid + '' + '' + '' + '' + points + '' + '' + xLabels + '' + ''; } function renderDashboardHourlyChart(hours) { var target = document.getElementById('dashboardHourlyChart'); if (!target) return; if (!hours.length) { target.innerHTML = '

' + t('dashboard_home.no_views') + '

'; return; } var maxValue = Math.max.apply(null, hours.map(function(item) { return Math.max(0, parseInt(item.views || 0, 10)); }).concat([1])); target.innerHTML = hours.map(function(item, index) { var views = Math.max(0, parseInt(item.views || 0, 10)); var height = Math.max(3, Math.round((views / maxValue) * 100)); var label = item.label || String(index).padStart(2, '0') + ':00'; var showLabel = index % 3 === 0; return '
' + '' + '' + (showLabel ? escapeHtml(String(index).padStart(2, '0')) : '') + '' + '
'; }).join(''); } function renderDashboardBreakdown(targetId, items) { var target = document.getElementById(targetId); if (!target) return; if (!items.length) { target.innerHTML = '

' + t('dashboard_home.no_data') + '

'; return; } target.innerHTML = items.slice(0, 8).map(function(item) { return '
' + '' + escapeHtml(item.label || item.key || '') + '' + '' + (item.views || item.count || 0) + '' + '
'; }).join(''); } function updateDashboardAiStatus(settings) { var target = document.getElementById('dashboardAiStatus'); if (!target) return; if (!AI_FEATURES_ENABLED) { target.textContent = t('ai.module_disabled_status'); return; } var configured = aiProviderIsConfigured(settings); if (!settings.enabled) { target.textContent = t('ai.disabled_status'); } else if (!configured) { target.textContent = t('ai.not_configured_text'); } else { target.textContent = t('ai.configured_status'); } } function aiFeatureEnabled(settings, feature) { settings = settings || currentAiSettings || {}; var features = settings.features || {}; if (Object.prototype.hasOwnProperty.call(features, feature)) { return !!features[feature]; } return !!AI_FEATURE_DEFAULTS[feature]; } function aiServiceIsUsable(settings) { settings = settings || currentAiSettings || {}; return AI_FEATURES_ENABLED && !!settings.enabled && aiProviderIsConfigured(settings); } function aiUnavailableNoticeDismissed() { try { return localStorage.getItem(AI_NOTICE_DISMISSED_KEY) === '1'; } catch (e) { return false; } } function dismissAiUnavailableNotice() { try { localStorage.setItem(AI_NOTICE_DISMISSED_KEY, '1'); } catch (e) {} var section = document.getElementById('dashboardAiSection'); var usable = aiServiceIsUsable(currentAiSettings || {}); if (section && !usable) section.hidden = true; } function updateDashboardAiPanel(settings) { settings = settings || currentAiSettings || {}; var section = document.getElementById('dashboardAiSection'); if (!section) return; var configured = AI_FEATURES_ENABLED && aiProviderIsConfigured(settings); var serviceEnabled = AI_FEATURES_ENABLED && !!settings.enabled; var usable = configured && serviceEnabled; var unavailableDismissed = aiUnavailableNoticeDismissed(); var banner = document.getElementById('aiUnavailableBanner'); var bannerText = document.getElementById('aiUnavailableText'); var tools = document.getElementById('dashboardAiTools'); var usage = document.getElementById('aiUsageSummary'); var assistantEnabled = usable && aiFeatureEnabled(settings, 'backendAssistant'); var textEnabled = usable && aiFeatureEnabled(settings, 'seoTextGeneration'); // The tab stays while the feature is on, so a missing key of the image service is explained instead of the tab vanishing. var imageEnabled = usable && aiFeatureEnabled(settings, 'imageGeneration'); var imageReady = imageEnabled && aiImageServiceUsable(settings); var imageJobsPanel = document.getElementById('aiImageJobsPanel'); dashboardAiImageUsable = imageReady; if (window.NbImageManager && typeof NbImageManager.refresh === 'function') { NbImageManager.refresh(); } section.hidden = !AI_FEATURES_ENABLED || (!usable && unavailableDismissed); updateDashboardAiStatus(settings); if (usage && !usable) usage.hidden = true; if (banner) { banner.hidden = usable; } if (bannerText) { if (!AI_FEATURES_ENABLED) { bannerText.textContent = t('ai.module_disabled_status'); } else if (!serviceEnabled) { bannerText.textContent = t('ai.disabled_status'); } else { bannerText.textContent = t('ai.not_configured_text'); } } if (tools) tools.hidden = !usable || (!assistantEnabled && !textEnabled && !imageEnabled); if (imageJobsPanel) imageJobsPanel.hidden = !imageReady; var assistantCard = document.getElementById('aiAssistantCard'); if (assistantCard) assistantCard.hidden = !assistantEnabled; syncAssistantEmbed(); var toolsCard = document.getElementById('aiToolsCard'); if (toolsCard) toolsCard.hidden = !textEnabled && !imageEnabled; document.querySelectorAll('.ai-tool-tab, .ai-tool-panel').forEach(function(el) { var feature = el.dataset.aiFeature; var visible = feature === 'imageGeneration' ? imageEnabled : (feature === 'seoTextGeneration' ? textEnabled : true); el.hidden = !visible; if (!visible) { el.classList.remove('active'); if (el.classList.contains('ai-tool-tab')) el.setAttribute('aria-selected', 'false'); } }); if (textEnabled || imageEnabled) { var currentActive = document.querySelector('.ai-tool-tab.active:not([hidden])'); switchAiToolTab(currentActive ? currentActive.dataset.aiToolTab : (imageEnabled ? 'image' : 'text')); } } document.getElementById('aiUnavailableDismiss')?.addEventListener('click', dismissAiUnavailableNotice); // The assistant card holds the chat (Agent | Classic) on the home tab; on every other tab the chat floats as the bubble. // Without the chat script the card shows the classic chat form, labelled as such. function syncAssistantEmbed() { var card = document.getElementById('aiAssistantCard'); var host = document.getElementById('aiAssistantEmbed'); if (!card || !host) return; var classic = [document.getElementById('aiChatLog'), document.getElementById('aiChatForm')]; var badge = document.getElementById('aiAssistantBadge'); var homeTab = document.getElementById('homeTab'); var onHome = !homeTab || homeTab.style.display !== 'none'; var copilot = window.RaijuAiCopilot; var embedded = false; if (copilot && typeof copilot.embed === 'function' && window.RJ_AI_COPILOT_AVAILABLE !== false) { embedded = onHome && !card.hidden ? copilot.embed(host) : (copilot.embed(null), false); } host.hidden = !embedded; classic.forEach(function(el) { if (el) el.hidden = embedded; }); if (badge) { // Say which chat this is: the classic one (also the fallback form), or the agent alone when the classic mode is off. // With both available the mode switch inside the chat says it. var agentOnly = embedded && window.RJ_AI_AGENT && window.RJ_AI_AGENT.available === true && window.RJ_AI_AGENT.classic === false; var both = embedded && window.RJ_AI_AGENT && window.RJ_AI_AGENT.available === true && !agentOnly; badge.textContent = t(agentOnly ? 'agent.mode_agent' : 'agent.mode_classic'); badge.hidden = both; } } window.addEventListener('raiju:copilot-ready', syncAssistantEmbed); function aiIsLocalProviderUrl(baseUrl) { try { var host = new URL(baseUrl || '').hostname.toLowerCase(); return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.startsWith('192.168.') || host.startsWith('10.') || /^172\\.(1[6-9]|2\\d|3[0-1])\\./.test(host); } catch (e) { return false; } } function aiProviderIsConfigured(settings) { settings = settings || currentAiSettings || {}; var provider = document.getElementById('aiProvider')?.value || settings.provider || 'openai-compatible'; var credentials = aiProviderCredentials(settings, provider); var activeProvider = settings.provider || 'openai-compatible'; var hasKey = !!document.getElementById('aiApiKey')?.value.trim() || (provider === activeProvider ? !!settings.hasApiKey : false) || !!credentials.hasApiKey; var baseUrl = document.getElementById('aiBaseUrl')?.value || credentials.baseUrl || settings.baseUrl || ''; var allowLocal = !!settings.allowLocalProvider || !!document.getElementById('aiAllowLocalProvider')?.checked; return hasKey || (allowLocal && aiIsLocalProviderUrl(baseUrl)); } function aiProviderCredentials(settings, provider) { settings = settings || currentAiSettings || {}; provider = provider || settings.provider || document.getElementById('aiProvider')?.value || 'openai-compatible'; var defaults = { 'openai-compatible': { baseUrl: 'https://api.openai.com/v1', organization: '', hasApiKey: false }, openrouter: { baseUrl: 'https://openrouter.ai/api/v1', organization: '', hasApiKey: false }, anthropic: { baseUrl: 'https://api.anthropic.com/v1', organization: '', hasApiKey: false }, kie: { baseUrl: 'https://api.kie.ai', organization: '', hasApiKey: false } }; var credentials = settings.providerCredentials && settings.providerCredentials[provider] ? settings.providerCredentials[provider] : {}; return Object.assign({}, defaults[provider] || defaults['openai-compatible'], credentials); } function aiUsesOpenRouter(settings) { settings = settings || currentAiSettings || {}; var provider = String(document.getElementById('aiProvider')?.value || settings.provider || '').trim(); var credentials = aiProviderCredentials(settings, provider); var baseUrl = String(document.getElementById('aiBaseUrl')?.value || credentials.baseUrl || settings.baseUrl || '').trim(); return provider === 'openrouter' || baseUrl.indexOf('openrouter.ai') !== -1; } function aiUsesAnthropic(settings) { settings = settings || currentAiSettings || {}; var provider = String(document.getElementById('aiProvider')?.value || settings.provider || '').trim(); var credentials = aiProviderCredentials(settings, provider); var baseUrl = String(document.getElementById('aiBaseUrl')?.value || credentials.baseUrl || settings.baseUrl || '').trim(); return provider === 'anthropic' || baseUrl.indexOf('api.anthropic.com') !== -1; } function aiUsesKie(settings) { settings = settings || currentAiSettings || {}; var provider = String(document.getElementById('aiProvider')?.value || settings.provider || '').trim(); var credentials = aiProviderCredentials(settings, provider); var baseUrl = String(document.getElementById('aiBaseUrl')?.value || credentials.baseUrl || settings.baseUrl || '').trim(); return provider === 'kie' || baseUrl.indexOf('api.kie.ai') !== -1; } var AI_TEXT_MODEL_PRESETS = Object.fromEntries(Object.entries(window.RJ_AI_CATALOG).map(([key, provider]) => [key, provider.text])); var aiOpenRouterModelsCache = null; var aiOpenRouterModelsLoading = false; // Load the live OpenRouter catalog through the server cache; the static // preset list stays as fallback when the request fails or is pending. function loadOpenRouterModels() { if (aiOpenRouterModelsCache || aiOpenRouterModelsLoading) return; aiOpenRouterModelsLoading = true; var formData = new FormData(); formData.append('action', 'ai-openrouter-models'); formData.append('csrf_token', CSRF_TOKEN); fetch('api.php', { method: 'POST', body: formData, cache: 'no-store' }) .then(function(response) { return response.json(); }) .then(function(result) { if (!result.success || !result.data) return; aiOpenRouterModelsCache = result.data; applyOpenRouterModels(); }) .catch(function() { /* keep static fallback */ }) .finally(function() { aiOpenRouterModelsLoading = false; }); } function applyOpenRouterModels() { var data = aiOpenRouterModelsCache; if (!data) return; if (Array.isArray(data.textModels) && data.textModels.length) { AI_TEXT_MODEL_PRESETS.openrouter.suggestions = data.textModels.map(function(model) { return model.id; }); } if (Array.isArray(data.imageModels) && data.imageModels.length) { AI_IMAGE_MODEL_OPTIONS.openrouter = data.imageModels.map(function(model) { return { value: model.id, label: model.name || model.id }; }); } var provider = document.getElementById('aiProvider')?.value || (currentAiSettings && currentAiSettings.provider) || ''; if (provider === 'openrouter') { updateAiModelPlaceholders('openrouter'); } if (provider === 'openrouter' || aiImageServiceId() === 'openrouter') { updateAiImageModelControl(currentAiSettings || {}); if (aiImageServiceId() === 'openrouter') aiRenderImageService(false); } // Refresh the size/cost note now that live image prices are available. if (typeof updateAiImageRatioIcon === 'function') updateAiImageRatioIcon(); } var AI_CAPS = window.RJ_AI_CAPS || { models: [], levels: ['low', 'medium', 'high'] }; var AI_MASK = '••••••••'; function aiById(id) { return document.getElementById(id); } function aiHostOf(baseUrl) { try { return new URL(baseUrl || '').hostname.toLowerCase(); } catch (e) { return ''; } } function aiCurrentProvider() { return aiById('aiProvider')?.value || (currentAiSettings && currentAiSettings.provider) || 'openai-compatible'; } function aiCurrentBaseUrl() { return aiById('aiBaseUrl')?.value || aiProviderCredentials(currentAiSettings || {}, aiCurrentProvider()).baseUrl || ''; } // What the registry (includes/ai/model-capabilities.json) knows about a model on its provider; null when unknown. function aiModelCaps(provider, baseUrl, model) { var host = aiHostOf(baseUrl); model = String(model || '').trim().toLowerCase(); return AI_CAPS.models.find(function(entry) { return entry.provider === provider && entry.host.toLowerCase() === host && entry.model.toLowerCase() === model; }) || null; } // The preset models of a provider; a custom OpenAI-compatible endpoint has its own model ids, so it gets none. function aiProviderModelDefaults(provider, baseUrl) { var preset = AI_TEXT_MODEL_PRESETS[provider] || AI_TEXT_MODEL_PRESETS['openai-compatible']; if (provider === 'openai-compatible' && baseUrl && aiHostOf(baseUrl) !== 'api.openai.com') return { chat: '', text: '' }; return { chat: preset.chat, text: preset.text }; } // ---- Reasoning effort (per purpose) ------------------------------------------------------------------------- var aiReasoning = { chat: 'recommended', text: 'recommended' }; var aiLegacy = { provider: '', chat: '', text: '' }; // saved before the setting existed: nothing is sent while the model stays the same var aiProviderDrafts = {}; // unsaved model choices per provider while the page stays open var aiLastProvider = ''; function aiPurposeModelInput(purpose) { return aiById(purpose === 'chat' ? 'aiChatModel' : 'aiTextModel'); } function aiEffortLevelLabel(level) { return t('ai.reasoning_' + level); } function aiRenderEffort(purpose) { var P = purpose === 'chat' ? 'Chat' : 'Text'; var select = aiById('ai' + P + 'Effort'); var hint = aiById('ai' + P + 'EffortHint'); var model = String(aiPurposeModelInput(purpose)?.value || '').trim(); if (!select || !hint) return; var label = select.closest('.form-group').querySelector('label'); var caps = aiModelCaps(aiCurrentProvider(), aiCurrentBaseUrl(), model); var value = aiReasoning[purpose] || 'recommended'; var legacyHolds = value === 'legacy' && aiLegacy.provider === aiCurrentProvider() && aiLegacy[purpose] === model.toLowerCase(); var note = ''; if (!caps) { // Unknown model or endpoint: no parameter is sent, so there is nothing to choose. if (!legacyHolds) aiReasoning[purpose] = 'recommended'; select.innerHTML = ''; (select.closest('.rj-select') || select).hidden = true; if (label) label.hidden = true; hint.textContent = model ? t('ai.reasoning_unknown') : ''; return; } if (value === 'legacy' && !legacyHolds) value = 'recommended'; if (value !== 'legacy' && value !== 'recommended' && caps.levels.indexOf(value) === -1) { value = 'recommended'; note = t('ai.reasoning_reset'); } (select.closest('.rj-select') || select).hidden = false; if (label) label.hidden = false; select.innerHTML = ''; var options = [['recommended', t('ai.reasoning_recommended_with', { level: aiEffortLevelLabel((caps.recommended && caps.recommended[purpose]) || 'medium') })]]; caps.levels.forEach(function(level) { options.push([level, aiEffortLevelLabel(level)]); }); if (legacyHolds) options.push(['legacy', t('ai.reasoning_legacy')]); options.forEach(function(option) { var el = document.createElement('option'); el.value = option[0]; el.textContent = option[1]; select.appendChild(el); }); select.value = value; aiReasoning[purpose] = value; hint.textContent = note; } ['chat', 'text'].forEach(function(purpose) { var P = purpose === 'chat' ? 'Chat' : 'Text'; aiById('ai' + P + 'Effort')?.addEventListener('change', function() { aiReasoning[purpose] = this.value; }); ['input', 'change'].forEach(function(type) { aiPurposeModelInput(purpose)?.addEventListener(type, function() { aiRenderEffort(purpose); aiRenderPricing(); }); }); }); function aiStashProviderModels(provider) { if (!provider) return; aiProviderDrafts[provider] = { chatModel: String(aiById('aiChatModel')?.value || '').trim(), textModel: String(aiById('aiTextModel')?.value || '').trim(), reasoning: Object.assign({}, aiReasoning) }; } // Show the models and reasoning levels a provider had: unsaved choices of this page first, then what is saved, then its presets. function aiApplyProviderModels(provider, baseUrl) { var settings = currentAiSettings || {}; var saved = provider === settings.provider ? { chatModel: settings.chatModel, textModel: settings.textModel, reasoning: settings.reasoning } : (settings.providerModels && settings.providerModels[provider]) || null; var source = aiProviderDrafts[provider] || saved; var defaults = aiProviderModelDefaults(provider, baseUrl); var chatInput = aiById('aiChatModel'); var textInput = aiById('aiTextModel'); if (chatInput) chatInput.value = source ? (source.chatModel || '') : defaults.chat; if (textInput) textInput.value = source ? (source.textModel || source.chatModel || '') : defaults.text; var reasoning = (source && source.reasoning) || {}; aiReasoning = { chat: reasoning.chat || 'recommended', text: reasoning.text || 'recommended' }; if (provider === settings.provider && !aiProviderDrafts[provider]) { aiLegacy = { provider: provider, chat: aiReasoning.chat === 'legacy' ? String(chatInput?.value || '').trim().toLowerCase() : '', text: aiReasoning.text === 'legacy' ? String(textInput?.value || '').trim().toLowerCase() : '' }; } [chatInput, textInput].forEach(function(input) { if (input) updateClearButton(input); }); } // ---- Prices per model --------------------------------------------------------------------------------------- var aiPriceChanges = {}; // "provider|model" -> {input, output, currency} (set) or null (remove); only what was touched in this form function aiPriceKey(provider, model) { return provider + '|' + String(model || '').trim().toLowerCase(); } function aiFormatNumber(value) { var n = parseFloat(value); if (!Number.isFinite(n)) return ''; return String(parseFloat(n.toFixed(6))); } function aiSavedOverride(key) { var models = currentAiSettings && currentAiSettings.pricing && currentAiSettings.pricing.models; return (models && !Array.isArray(models) && models[key]) || null; } function aiStoredOverride(key) { return Object.prototype.hasOwnProperty.call(aiPriceChanges, key) ? aiPriceChanges[key] : aiSavedOverride(key); } function aiCatalogPrice(provider, baseUrl, model) { var host = aiHostOf(baseUrl); model = String(model || '').trim().toLowerCase(); var info = (currentAiSettings && currentAiSettings.pricingInfo) || { models: [] }; return (info.models || []).find(function(entry) { return entry.provider === provider && entry.host.toLowerCase() === host && entry.model.toLowerCase() === model; }) || null; } function aiPriceStatus(purpose) { var P = purpose; var provider = aiCurrentProvider(); var model = String(aiPurposeModelInput(purpose)?.value || '').trim(); var status = aiById('aiPriceRow_' + P)?.querySelector('[data-price-status]'); if (!status) return true; var override = aiStoredOverride(aiPriceKey(provider, model)); var catalog = aiCatalogPrice(provider, aiCurrentBaseUrl(), model); var known = true; status.classList.remove('ai-price-status--unknown'); if (override && override.input !== '' && override.output !== '') { status.textContent = t(override.migrated ? 'ai.price_status_migrated' : 'ai.price_status_override', { input: aiFormatNumber(override.input), output: aiFormatNumber(override.output), currency: override.currency || 'USD' }); } else if (catalog) { var text = t('ai.price_status_catalog', { input: aiFormatNumber(catalog.input), output: aiFormatNumber(catalog.output), date: catalog.updatedAt || '' }); (catalog.tiers || []).forEach(function(tier) { text += ' ' + (tier.input !== undefined ? t('ai.price_tier_absolute', { tokens: Number(tier.aboveInputTokens).toLocaleString(), input: aiFormatNumber(tier.input), output: aiFormatNumber(tier.output) }) : t('ai.price_tier_multiplier', { tokens: Number(tier.aboveInputTokens).toLocaleString(), input: aiFormatNumber(tier.inputMultiplier || 1), output: aiFormatNumber(tier.outputMultiplier || 1) })); }); status.textContent = text; } else { known = false; status.textContent = t('ai.price_unknown'); status.classList.add('ai-price-status--unknown'); } return known; } function aiRenderPricing() { if (!aiById('aiPricingSection')) return; var provider = aiCurrentProvider(); var baseUrl = aiCurrentBaseUrl(); var chatModel = String(aiById('aiChatModel')?.value || '').trim(); var textModel = String(aiById('aiTextModel')?.value || '').trim(); var shared = chatModel.toLowerCase() === textModel.toLowerCase(); var anyUnknown = false; ['chat', 'text'].forEach(function(purpose) { var row = aiById('aiPriceRow_' + purpose); var model = purpose === 'chat' ? chatModel : textModel; if (!row) return; if (!model || (purpose === 'text' && shared)) { row.hidden = true; return; } row.hidden = false; var titleKey = purpose === 'chat' ? (shared ? 'ai.model_group_both' : 'ai.model_group_chat') : 'ai.model_group_text'; row.querySelector('[data-price-title]').textContent = t(titleKey) + ' · ' + model; var key = aiPriceKey(provider, model); var override = aiStoredOverride(key); var catalog = aiCatalogPrice(provider, baseUrl, model); var inputEl = aiById('aiPriceIn_' + purpose); var outputEl = aiById('aiPriceOut_' + purpose); var currencyEl = aiById('aiPriceCur_' + purpose); var removeEl = aiById('aiPriceRemove_' + purpose); inputEl.value = override ? aiFormatNumber(override.input) : ''; outputEl.value = override ? aiFormatNumber(override.output) : ''; inputEl.placeholder = catalog ? aiFormatNumber(catalog.input) : ''; outputEl.placeholder = catalog ? aiFormatNumber(catalog.output) : ''; currencyEl.value = override ? (override.currency || 'USD') : 'USD'; removeEl.hidden = !override; if (!aiPriceStatus(purpose)) anyUnknown = true; }); if (aiRenderImagePrice()) anyUnknown = true; var notice = aiById('aiUnpricedNotice'); if (notice) notice.hidden = !anyUnknown; var fx = ((currentAiSettings && currentAiSettings.pricingInfo) || {}).fx; var fxInput = aiById('aiFxRate'); if (fx && fxInput) { fxInput.placeholder = aiFormatNumber((currentAiSettings.pricingInfo.catalogFx || fx).usdPerEur); aiById('aiFxHint').textContent = t('ai.fx_hint', { rate: aiFormatNumber(fx.usdPerEur), date: fx.asOf || '', source: fx.source === 'settings' ? t('ai.fx_own') : fx.source }); } } ['chat', 'text'].forEach(function(purpose) { ['aiPriceIn_', 'aiPriceOut_', 'aiPriceCur_'].forEach(function(prefix) { aiById(prefix + purpose)?.addEventListener('input', function() { var key = aiPriceKey(aiCurrentProvider(), aiPurposeModelInput(purpose)?.value); var input = aiById('aiPriceIn_' + purpose).value.trim(); var output = aiById('aiPriceOut_' + purpose).value.trim(); if (input === '' && output === '') { if (aiSavedOverride(key)) aiPriceChanges[key] = null; else delete aiPriceChanges[key]; } else { aiPriceChanges[key] = { input: input, output: output, currency: aiById('aiPriceCur_' + purpose).value }; } aiById('aiPriceRemove_' + purpose).hidden = !(input !== '' || output !== ''); aiPriceStatus(purpose); }); }); aiById('aiPriceRemove_' + purpose)?.addEventListener('click', function() { aiPriceChanges[aiPriceKey(aiCurrentProvider(), aiPurposeModelInput(purpose)?.value)] = null; aiRenderPricing(); }); }); // Only complete entries (input and output) are sent; incomplete ones leave the stored price as it is. function aiCollectPriceChanges() { var out = {}; Object.keys(aiPriceChanges).forEach(function(key) { var entry = aiPriceChanges[key]; if (entry === null) { out[key] = null; return; } if (entry.input !== '' && entry.output !== '') out[key] = { input: parseFloat(entry.input), output: parseFloat(entry.output), currency: entry.currency }; }); return out; } function maybeAutofillOpenRouterPricing(event) { var provider = aiCurrentProvider(); if (provider !== 'openrouter' || !aiOpenRouterModelsCache) return; var model = String(event && event.target ? event.target.value : '').trim(); var match = (aiOpenRouterModelsCache.textModels || []).find(function(entry) { return entry.id === model; }); if (!match || (!match.promptCentsPerMillion && !match.completionCentsPerMillion)) return; var key = aiPriceKey(provider, model); if (aiStoredOverride(key)) return; // OpenRouter lists USD per token; the cents per million it gives are USD per million times 100. aiPriceChanges[key] = { input: String(match.promptCentsPerMillion / 100), output: String(match.completionCentsPerMillion / 100), currency: 'USD' }; aiRenderPricing(); if (typeof showToast === 'function') showToast(t('ai.pricing_autofilled'), 'info'); } // ---- Stored keys: never shown, only whether one exists -------------------------------------------------------- function aiSetSecretField(inputId, removeId, hintId, hasKey) { var input = aiById(inputId); if (input) { input.value = ''; input.placeholder = hasKey ? AI_MASK : ''; } var hint = aiById(hintId); if (hint) hint.textContent = hasKey ? t('ai.api_key_saved') : t('ai.api_key_missing'); var remove = aiById(removeId); if (remove) remove.hidden = !hasKey; } // A mask (or anything made only of bullets) is never a key. function aiTypedSecret(id) { var value = String(aiById(id)?.value || '').trim(); return /^[•●·*\s]*$/.test(value) ? '' : value; } function updateAiModelPlaceholders(provider) { var preset = aiProviderModelDefaults(provider, aiById('aiBaseUrl')?.value || ''); var chatInput = aiById('aiChatModel'); var textInput = aiById('aiTextModel'); if (chatInput) { chatInput.placeholder = preset.chat; updateClearButton(chatInput); } if (textInput) { textInput.placeholder = preset.text; updateClearButton(textInput); } aiModelComboboxes.forEach(function(combobox) { combobox.refresh(); }); aiRenderEffort('chat'); aiRenderEffort('text'); aiRenderPricing(); if (provider === 'openrouter') { loadOpenRouterModels(); } else { loadProviderModels(false); } } function aiModelSuggestions() { var provider = document.getElementById('aiProvider')?.value || (currentAiSettings && currentAiSettings.provider) || 'openai-compatible'; var preset = AI_TEXT_MODEL_PRESETS[provider] || AI_TEXT_MODEL_PRESETS['openai-compatible']; // The provider's own list wins once it is loaded; the presets are the fallback. if (provider !== 'openrouter' && aiLiveModels[provider] && aiLiveModels[provider].length) return aiLiveModels[provider].slice(); return preset.suggestions.slice(); } // Models the saved provider offers (GET /models through the server, admins only). var aiLiveModels = {}; var aiLiveModelsLoading = {}; function loadProviderModels(force) { var provider = document.getElementById('aiProvider')?.value || (currentAiSettings && currentAiSettings.provider) || ''; var hint = document.getElementById('aiModelsHint'); if (!provider || provider === 'openrouter' || provider === 'kie' || aiLiveModelsLoading[provider]) return; var credentials = aiProviderCredentials(currentAiSettings || {}, provider); if (!credentials.hasApiKey && !(currentAiSettings && currentAiSettings.allowLocalProvider)) { if (hint) hint.textContent = ''; return; } if (aiLiveModels[provider] && !force) return; aiLiveModelsLoading[provider] = true; var formData = new FormData(); formData.append('action', 'ai-list-models'); formData.append('csrf_token', CSRF_TOKEN); if (hint) hint.textContent = t('ai.models_loading'); fetch('api.php', { method: 'POST', body: formData, cache: 'no-store' }) .then(function(response) { return response.json(); }) .then(function(result) { if (!result.success) throw new Error(result.message); var models = Array.isArray(result.data.models) ? result.data.models : []; aiLiveModels[result.data.provider || provider] = models; if (hint) hint.textContent = models.length ? t('ai.models_loaded', { count: models.length }) : ''; aiModelComboboxes.forEach(function(combobox) { combobox.refresh(); }); }) .catch(function(error) { if (hint) hint.textContent = t('ai.models_failed', { message: error.message }); }) .finally(function() { delete aiLiveModelsLoading[provider]; }); } // Custom combobox: identical rendering in every browser (incl. Safari and // embedded webviews) instead of the unstylable native datalist popup. var aiModelComboboxes = []; function setupModelCombobox(root) { var input = root.querySelector('input'); var toggle = root.querySelector('.rj-combobox__toggle'); var list = root.querySelector('.rj-combobox__list'); if (!input || !list) return null; var activeIndex = -1; var items = []; function close() { list.hidden = true; input.setAttribute('aria-expanded', 'false'); activeIndex = -1; } function select(value) { input.value = value; close(); updateClearButton(input); input.dispatchEvent(new Event('change', { bubbles: true })); input.focus(); } function highlight(index) { activeIndex = index; Array.from(list.children).forEach(function(item, i) { item.classList.toggle('is-active', i === index); item.setAttribute('aria-selected', i === index ? 'true' : 'false'); if (i === index) item.scrollIntoView({ block: 'nearest' }); }); } function open(filterText) { var all = root.hasAttribute('data-image-combobox') ? aiImageServiceSuggestions() : aiModelSuggestions(); var filter = String(filterText || '').trim().toLowerCase(); items = filter && all.indexOf(input.value.trim()) === -1 ? all.filter(function(model) { return model.toLowerCase().indexOf(filter) !== -1; }) : all; if (!items.length) { close(); return; } list.innerHTML = ''; items.forEach(function(model, index) { var item = document.createElement('div'); item.className = 'rj-combobox__option'; item.setAttribute('role', 'option'); item.textContent = model; item.addEventListener('mousedown', function(event) { event.preventDefault(); select(model); }); list.appendChild(item); }); list.hidden = false; input.setAttribute('aria-expanded', 'true'); highlight(-1); } input.addEventListener('input', function() { updateClearButton(input); open(input.value); }); input.addEventListener('keydown', function(event) { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); if (list.hidden) { open(input.value); return; } var count = list.children.length; if (!count) return; var next = event.key === 'ArrowDown' ? (activeIndex + 1) % count : (activeIndex - 1 + count) % count; highlight(next); } else if (event.key === 'Enter') { if (!list.hidden && activeIndex >= 0 && items[activeIndex] !== undefined) { event.preventDefault(); select(items[activeIndex]); } } else if (event.key === 'Escape' && !list.hidden) { event.stopPropagation(); close(); } }); input.addEventListener('blur', function() { window.setTimeout(close, 120); }); if (toggle) { toggle.addEventListener('mousedown', function(event) { event.preventDefault(); loadProviderModels(false); if (list.hidden) { input.focus(); open(''); } else { close(); } }); } return { refresh: function() { if (!list.hidden) open(input.value); } }; } document.querySelectorAll('[data-model-combobox]').forEach(function(root) { var combobox = setupModelCombobox(root); if (combobox) aiModelComboboxes.push(combobox); }); ['aiChatModel', 'aiTextModel'].forEach(function(id) { document.getElementById(id)?.addEventListener('change', maybeAutofillOpenRouterPricing); }); var AI_IMAGE_MODEL_OPTIONS = Object.fromEntries(Object.entries(window.RJ_AI_CATALOG).map(([key, provider]) => [key, provider.images.map(image => ({ value: image.value, label: image.label.startsWith('ai.') ? t(image.label) : image.label }))])); function normalizeOpenRouterImageModel(model) { model = String(model || '').trim(); var aliases = window.RJ_AI_CATALOG.openrouter.imageAliases; var valid = AI_IMAGE_MODEL_OPTIONS.openrouter.map(function(option) { return option.value; }); if (valid.indexOf(model) !== -1) { return model; } return aliases[model] || 'openai/gpt-5.4-image-2'; } function normalizeOpenAiImageModel(model) { model = String(model || '').trim(); return model === 'gpt-image-2' ? model : 'gpt-image-2'; } function normalizeKieImageModel(model) { model = String(model || '').trim(); var aliases = window.RJ_AI_CATALOG.kie.imageAliases; model = aliases[model] || model; return AI_IMAGE_MODEL_OPTIONS.kie.some(function(option) { return option.value === model; }) ? model : AI_IMAGE_MODEL_OPTIONS.kie[0].value; } function currentAiProviderKey(settings) { settings = settings || currentAiSettings || {}; var provider = document.getElementById('aiProvider')?.value || settings.provider || 'openai-compatible'; if (provider === 'kie' || aiUsesKie(settings)) return 'kie'; return provider === 'openrouter' || aiUsesOpenRouter(settings) ? 'openrouter' : 'openai-compatible'; } // ---- Image service: chosen apart from the chat provider ---------------------------------------------------- var AI_IMG = window.RJ_AI_IMAGE_SERVICES || { services: {}, ids: [] }; var aiImagePriceChanges = {}; function aiImageServiceId(settings) { settings = settings || currentAiSettings || {}; return aiById('aiImageService')?.value || (settings.imageService && settings.imageService.provider) || 'inherit'; } function aiImageServiceStatusOf(id, settings) { settings = settings || currentAiSettings || {}; return ((settings.imageServiceStatus || {}).services || {})[id] || {}; } // The model in effect for an explicit service: typed, otherwise the default of the service. function aiImageServiceModelValue(id) { id = id || aiImageServiceId(); var typed = String(aiById('aiImageServiceModel')?.value || '').trim(); if (typed) return typed; var entry = AI_IMG.services[id] || {}; if (entry.catalogModels) { var options = AI_IMAGE_MODEL_OPTIONS[id] || []; return options[0] ? options[0].value : ''; } return entry.defaultModel || ''; } // Models the services list themselves (fal.ai, Comet): loaded through the server, kept for this page view. var aiLiveImageModels = {}; var aiLiveImageLoading = {}; function aiImageServiceSuggestions() { var id = aiImageServiceId(); var entry = AI_IMG.services[id]; if (!entry) return []; if (entry.catalogModels) return (AI_IMAGE_MODEL_OPTIONS[id] || []).map(function(option) { return option.value; }); var ids = (entry.models || []).map(function(model) { return model.id; }); ((aiLiveImageModels[id] || {}).models || []).forEach(function(model) { if (ids.indexOf(model.id) === -1) ids.push(model.id); }); return ids; } function aiLoadLiveImageModels(id, refresh) { if (id !== 'fal' && id !== 'comet') return; if ((aiLiveImageModels[id] && !refresh) || aiLiveImageLoading[id]) return; aiLiveImageLoading[id] = true; var hint = aiById('aiImageServiceModelHint'); if (hint && aiImageServiceId() === id) hint.textContent = t('ai.models_loading'); var formData = new FormData(); formData.append('action', 'ai-image-models'); formData.append('service', id); if (refresh) formData.append('refresh', '1'); formData.append('csrf_token', CSRF_TOKEN); fetch('api.php', { method: 'POST', body: formData, cache: 'no-store' }) .then(function(response) { return response.json(); }) .then(function(result) { if (!result.success) throw new Error(result.message || ''); aiLiveImageModels[id] = result.data; }) .catch(function(error) { aiLiveImageModels[id] = { models: [], failed: String(error.message || '') }; }) .finally(function() { aiLiveImageLoading[id] = false; if (aiImageServiceId() === id) { aiRenderImageService(false); updateAiImageModelControl(currentAiSettings || {}); } }); } // What the registry knows about a model: a listed model, or the defaults of the service (not verified). function aiImageModelInfo(id, model) { var entry = AI_IMG.services[id]; if (!entry || entry.catalogModels) return null; var found = (entry.models || []).find(function(item) { return item.id.toLowerCase() === String(model || '').toLowerCase(); }); return found || { id: model, verified: (entry.defaults || {}).verified, note: (entry.defaults || {}).note, known: false, price: null, resolutions: (entry.defaults || {}).resolutions || [] }; } // Whether images can be generated with the service as it is chosen in the form (a typed key counts). function aiImageServiceUsable(settings) { settings = settings || currentAiSettings || {}; var id = aiImageServiceId(settings); if (id === 'inherit') return !aiUsesAnthropic(settings); var status = aiImageServiceStatusOf(id, settings); var hasKey = !!aiTypedSecret('aiImageApiKey') || !!status.hasApiKey || !!status.chatKeyAvailable; var base = id === 'custom' ? String(aiById('aiImageCustomBaseUrl')?.value || ((settings.imageService || {}).custom || {}).baseUrl || '').trim() : 'x'; var local = !!settings.allowLocalProvider && id === 'custom' && aiIsLocalProviderUrl(base); return (hasKey || local) && base !== '' && aiImageServiceModelValue(id) !== ''; } // Why images cannot be generated with the service as chosen in the form ('' when they can). Shown in the image tool. function aiImageUnavailableMessage(settings) { settings = settings || currentAiSettings || {}; var id = aiImageServiceId(settings); if (id === 'inherit') return aiUsesAnthropic(settings) ? t('ai.image_service_inherit_anthropic') : ''; var entry = AI_IMG.services[id] || {}; var name = entry.label ? t(entry.label) : id; var status = aiImageServiceStatusOf(id, settings); var base = id === 'custom' ? String(aiById('aiImageCustomBaseUrl')?.value || ((settings.imageService || {}).custom || {}).baseUrl || '').trim() : 'x'; var local = !!settings.allowLocalProvider && id === 'custom' && aiIsLocalProviderUrl(base); if (id === 'custom' && base === '') return t('ai.image_service_custom_base_url_missing'); if (!aiTypedSecret('aiImageApiKey') && !status.hasApiKey && !status.chatKeyAvailable && !local) return t('ai.image_service_unusable_key', { service: name }); if (aiImageServiceModelValue(id) === '') return t('ai.image_service_unusable_model', { service: name }); return ''; } // The service and model the image price belongs to; null when no image can be generated. function aiImagePriceContext() { var id = aiImageServiceId(); if (id === 'inherit') { if (aiUsesAnthropic()) return null; id = currentAiProviderKey(); var model = String(aiById('aiImageModelPicker')?.value || aiById('aiImageModel')?.value || '').trim(); return model ? { service: id, model: model, inherit: true } : null; } var chosen = aiImageServiceModelValue(id); return chosen ? { service: id, model: chosen, inherit: false } : null; } function aiImagePriceKey(service, model) { return service + '|' + String(model || '').trim().toLowerCase(); } function aiStoredImagePrice(key) { return (((currentAiSettings || {}).pricing || {}).imageModels || {})[key] || null; } var AI_IMG_RESOLUTIONS = ['1K', '2K', '4K']; var AI_IMG_QUALITIES = ['low', 'medium', 'high']; // The inputs of the image price row as one entry: a price for any image and optional tables by resolution and quality. function aiReadImagePriceInputs() { var entry = { perImage: aiById('aiPriceImage').value.trim(), currency: aiById('aiPriceCur_image').value, byResolution: {}, byQuality: {} }; AI_IMG_RESOLUTIONS.forEach(function(step) { entry.byResolution[step] = aiById('aiPriceRes_' + step).value.trim(); }); AI_IMG_QUALITIES.forEach(function(step) { entry.byQuality[step] = aiById('aiPriceQual_' + step).value.trim(); }); return entry; } function aiImagePriceEntryEmpty(entry) { if (!entry) return true; var any = String(entry.perImage || '') !== ''; Object.keys(entry.byResolution || {}).forEach(function(step) { if (String(entry.byResolution[step]) !== '') any = true; }); Object.keys(entry.byQuality || {}).forEach(function(step) { if (String(entry.byQuality[step]) !== '') any = true; }); return !any; } function aiFillImagePriceInputs(entry, placeholder) { entry = entry || {}; aiById('aiPriceImage').value = entry.perImage !== undefined ? entry.perImage : ''; aiById('aiPriceImage').placeholder = placeholder && placeholder.perImage !== undefined ? aiFormatNumber(placeholder.perImage) : ''; AI_IMG_RESOLUTIONS.forEach(function(step) { aiById('aiPriceRes_' + step).value = entry.byResolution && entry.byResolution[step] !== undefined ? entry.byResolution[step] : ''; aiById('aiPriceRes_' + step).placeholder = placeholder && placeholder.byResolution && placeholder.byResolution[step] !== undefined ? aiFormatNumber(placeholder.byResolution[step]) : ''; }); AI_IMG_QUALITIES.forEach(function(step) { aiById('aiPriceQual_' + step).value = entry.byQuality && entry.byQuality[step] !== undefined ? entry.byQuality[step] : ''; aiById('aiPriceQual_' + step).placeholder = placeholder && placeholder.byQuality && placeholder.byQuality[step] !== undefined ? aiFormatNumber(placeholder.byQuality[step]) : ''; }); } function aiRenderImagePrice() { var row = aiById('aiPriceRow_image'); if (!row) return false; var context = aiImagePriceContext(); if (!context) { row.hidden = true; return false; } row.hidden = false; row.querySelector('[data-price-title]').textContent = t('ai.image_price_model') + ' · ' + context.model; var key = aiImagePriceKey(context.service, context.model); var pending = Object.prototype.hasOwnProperty.call(aiImagePriceChanges, key) ? aiImagePriceChanges[key] : undefined; var stored = aiStoredImagePrice(key); var shown = pending === undefined ? stored : pending; if (shown && aiImagePriceEntryEmpty(shown)) shown = null; var info = context.inherit ? null : aiImageModelInfo(context.service, context.model); var catalog = info && info.price ? info.price : null; // Typing keeps its own inputs: refill only when the model or the stored state changed if (row.getAttribute('data-for') !== key + '|' + (pending === undefined ? 's' : 'p')) { aiFillImagePriceInputs(shown, catalog); aiById('aiPriceCur_image').value = shown ? (shown.currency || 'USD') : (catalog ? (catalog.currency || 'USD') : 'USD'); row.setAttribute('data-for', key + '|' + (pending === undefined ? 's' : 'p')); } aiById('aiPriceRemove_image').hidden = !shown; var tables = function(entry) { return !!entry && (Object.keys(entry.byResolution || {}).some(function(k) { return String(entry.byResolution[k]) !== ''; }) || Object.keys(entry.byQuality || {}).some(function(k) { return String(entry.byQuality[k]) !== ''; })); }; var details = aiById('aiPriceImageDetails'); if (details && (tables(shown) || (catalog && (catalog.byResolution || catalog.byQuality)))) details.open = true; var defaultCents = parseFloat(aiById('aiImagePrice')?.value || '0') || 0; var status = row.querySelector('[data-price-status]'); var unknown = false; if (shown && String(shown.perImage || '') !== '') status.textContent = t('ai.image_price_status_override', { price: aiFormatNumber(shown.perImage), currency: shown.currency || 'USD' }) + (tables(shown) ? ' ' + t('ai.image_price_status_tables') : ''); else if (tables(shown)) status.textContent = t('ai.image_price_status_tables_only'); else if (catalog && catalog.perImage === undefined && (catalog.byResolution || catalog.byQuality)) status.textContent = t('ai.image_price_status_catalog_tables', { date: catalog.updatedAt || '' }); else if (catalog && catalog.perImage !== undefined) status.textContent = t('ai.image_price_status_catalog', { price: aiFormatNumber(catalog.perImage), currency: catalog.currency || 'USD', date: catalog.updatedAt || '' }); else if (defaultCents > 0) status.textContent = t('ai.image_price_status_default', { cents: aiFormatNumber(defaultCents) }); else { status.textContent = t('ai.price_unknown'); unknown = true; } return unknown; } { ['aiPriceImage', 'aiPriceCur_image'].concat(AI_IMG_RESOLUTIONS.map(function(step) { return 'aiPriceRes_' + step; }), AI_IMG_QUALITIES.map(function(step) { return 'aiPriceQual_' + step; })).forEach(function(id) { aiById(id)?.addEventListener('input', function() { var context = aiImagePriceContext(); if (!context) return; var key = aiImagePriceKey(context.service, context.model); var entry = aiReadImagePriceInputs(); if (aiImagePriceEntryEmpty(entry)) { if (aiStoredImagePrice(key)) aiImagePriceChanges[key] = null; else delete aiImagePriceChanges[key]; } else { aiImagePriceChanges[key] = entry; } aiById('aiPriceRow_image').setAttribute('data-for', key + '|' + (Object.prototype.hasOwnProperty.call(aiImagePriceChanges, key) ? 'p' : 's')); aiById('aiPriceRemove_image').hidden = aiImagePriceEntryEmpty(entry); aiRenderImagePrice(); }); }); } aiById('aiPriceRemove_image')?.addEventListener('click', function() { var context = aiImagePriceContext(); if (context) aiImagePriceChanges[aiImagePriceKey(context.service, context.model)] = null; aiById('aiPriceRow_image')?.removeAttribute('data-for'); aiRenderImagePrice(); }); aiById('aiImagePrice')?.addEventListener('input', aiRenderImagePrice); function aiCollectImagePriceChanges() { var out = {}; var number = function(value) { return String(value) !== '' && !isNaN(parseFloat(value)) ? parseFloat(value) : null; }; Object.keys(aiImagePriceChanges).forEach(function(key) { var entry = aiImagePriceChanges[key]; if (entry === null) { out[key] = null; return; } var clean = { currency: entry.currency }; if (number(entry.perImage) !== null) clean.perImage = number(entry.perImage); ['byResolution', 'byQuality'].forEach(function(field) { Object.keys(entry[field] || {}).forEach(function(step) { if (number(entry[field][step]) !== null) { clean[field] = clean[field] || {}; clean[field][step] = number(entry[field][step]); } }); }); out[key] = Object.keys(clean).length > 1 ? clean : null; }); return out; } // Key field: the hint says where the key in use comes from. resetField clears what was typed. function aiRenderImageKey(resetField) { var id = aiImageServiceId(); var status = aiImageServiceStatusOf(id); if (resetField) aiSetSecretField('aiImageApiKey', 'aiRemoveImageApiKey', 'aiImageApiKeyHint', !!status.hasApiKey); var input = aiById('aiImageApiKey'); var hint = aiById('aiImageApiKeyHint'); if (!input || !hint) return; var source = status.hasApiKey ? 'own' : (status.chatKeyAvailable ? 'chat' : 'none'); input.placeholder = status.hasApiKey ? AI_MASK : ''; hint.textContent = t('ai.image_service_key_' + source); var remove = aiById('aiRemoveImageApiKey'); if (remove) remove.hidden = !status.hasApiKey; } function aiRenderImageService(resetKey) { if (!aiById('aiImageServiceSection')) return; var id = aiImageServiceId(); var explicit = id !== 'inherit'; aiById('aiImageServiceModelGroup').hidden = !explicit; aiById('aiImageServiceKeyGroup').hidden = !explicit; aiById('aiImageCustomGroup').hidden = id !== 'custom'; aiById('aiImageExtraGroup').hidden = !(id === 'fal' || id === 'comet' || id === 'custom'); var entry = AI_IMG.services[id] || {}; var modelInput = aiById('aiImageServiceModel'); if (modelInput) { modelInput.placeholder = entry.defaultModel || ''; updateClearButton(modelInput); } if (id === 'openrouter') loadOpenRouterModels(); // the live list of image models, also under another chat provider aiRenderImageKey(resetKey); var notice = aiById('aiImageServiceNotice'); var hint = aiById('aiImageServiceHint'); var modelHint = aiById('aiImageServiceModelHint'); var text = ''; var kind = 'warning'; if (!explicit) { hint.textContent = t('ai.image_service_inherit_hint'); if (aiUsesAnthropic()) text = t('ai.image_service_inherit_anthropic'); modelHint.textContent = ''; } else { hint.textContent = ''; var model = aiImageServiceModelValue(id); var info = aiImageModelInfo(id, model); modelHint.textContent = ''; if (info && info.verified === false) { text = t('ai.image_service_unverified'); kind = 'info'; modelHint.textContent = info.note || ''; } else if (info && info.note) modelHint.textContent = info.note; if (id === 'fal' || id === 'comet') { var live = aiLiveImageModels[id]; if (!live && !aiLiveImageLoading[id]) aiLoadLiveImageModels(id, false); else if (aiLiveImageLoading[id]) modelHint.textContent = t('ai.models_loading'); else if (live && live.failed !== undefined) modelHint.textContent = t('ai.models_failed', { message: live.failed }); else if (live) modelHint.textContent = t('ai.image_service_models_loaded', { count: (live.models || []).length }); } if (id === 'custom' && !String(aiById('aiImageCustomBaseUrl')?.value || '').trim()) { text = t('ai.image_service_custom_base_url_missing'); kind = 'warning'; } } if (notice) { notice.hidden = text === ''; notice.textContent = text; notice.classList.toggle('info-banner--warning', kind === 'warning'); } aiRenderImagePrice(); } function aiPopulateImageService(settings) { var image = (settings && settings.imageService) || {}; var custom = image.custom || {}; var provider = image.provider || 'inherit'; if (aiById('aiImageService')) aiById('aiImageService').value = provider; if (aiById('aiImageServiceModel')) aiById('aiImageServiceModel').value = image.model || (image.models || {})[provider] || ''; if (aiById('aiImageCustomBaseUrl')) aiById('aiImageCustomBaseUrl').value = custom.baseUrl || ''; if (aiById('aiImageCustomSizeMode')) aiById('aiImageCustomSizeMode').value = custom.sizeMode || 'size'; if (aiById('aiImageCustomAuth')) aiById('aiImageCustomAuth').value = custom.authStyle || 'bearer'; var params = custom.extraParams; if (aiById('aiImageCustomParams')) aiById('aiImageCustomParams').value = params && Object.keys(params).length ? JSON.stringify(params, null, 2) : ''; aiImagePriceChanges = {}; aiRenderImageService(true); } function aiCollectImageService() { var id = aiImageServiceId(); var typedKey = aiTypedSecret('aiImageApiKey'); var keys = {}; if (typedKey && id !== 'inherit') keys[id] = { apiKey: typedKey }; return { imageService: { provider: id, model: String(aiById('aiImageServiceModel')?.value || '').trim(), custom: { baseUrl: String(aiById('aiImageCustomBaseUrl')?.value || '').trim(), sizeMode: aiById('aiImageCustomSizeMode')?.value || 'size', authStyle: aiById('aiImageCustomAuth')?.value || 'bearer', extraParams: String(aiById('aiImageCustomParams')?.value || '').trim() } }, imageServices: keys }; } function aiImageServiceChanged() { var id = aiImageServiceId(); var remembered = ((currentAiSettings || {}).imageService || {}).models || {}; var modelInput = aiById('aiImageServiceModel'); if (modelInput) modelInput.value = remembered[id] || ''; var keyInput = aiById('aiImageApiKey'); if (keyInput) keyInput.value = ''; aiRenderImageService(true); updateAiImageModelControl(currentAiSettings || {}); var draft = Object.assign({}, currentAiSettings || {}, collectAiSettingsForm()); draft.hasApiKey = !!(currentAiSettings && currentAiSettings.hasApiKey) || !!aiById('aiApiKey')?.value.trim(); updateAiAvailability(draft); } function updateAiImageModelControl(settings) { settings = settings || currentAiSettings || {}; var providerKey = currentAiProviderKey(settings); var input = document.getElementById('aiImageModel'); var picker = document.getElementById('aiImageModelPicker'); if (!input) return; var serviceId = aiImageServiceId(settings); if (serviceId !== 'inherit') { // An own image service: the picker lists its models and keeps the model of the settings form in step. if (picker) { var current = aiImageServiceModelValue(serviceId); var suggestions = aiImageServiceSuggestions().slice(); if (current && suggestions.indexOf(current) === -1) suggestions.unshift(current); var labels = {}; var entry = AI_IMG.services[serviceId] || {}; (entry.models || []).forEach(function(model) { labels[model.id] = model.label; }); (AI_IMAGE_MODEL_OPTIONS[serviceId] || []).forEach(function(option) { labels[option.value] = option.label; }); picker.innerHTML = suggestions.map(function(value) { return ''; }).join(''); picker.value = current; } updateAiImageScaleOptions(); return; } var options = AI_IMAGE_MODEL_OPTIONS[providerKey] || AI_IMAGE_MODEL_OPTIONS['openai-compatible']; var selected = providerKey === 'openrouter' ? normalizeOpenRouterImageModel(settings.imageModel || input.value || '') : (providerKey === 'kie' ? normalizeKieImageModel(settings.imageModel || input.value || '') : normalizeOpenAiImageModel(settings.imageModel || input.value || '')); if (picker) { picker.innerHTML = options.map(function(option) { return ''; }).join(''); if (!options.some(function(option) { return option.value === selected; })) { selected = options[0] ? options[0].value : ''; } picker.value = selected; } input.value = selected; updateAiImageScaleOptions(); } function aiSelectedImageModelSupports4K() { var model = String(document.getElementById('aiImageModelPicker')?.value || document.getElementById('aiImageModel')?.value || '').trim(); var serviceId = aiImageServiceId(); if (serviceId !== 'inherit') { var info = aiImageModelInfo(serviceId, model); if (info) return (info.resolutions || []).indexOf('4K') !== -1; } return !/(^|\/)gpt-5\.4-image-2(?:$|-)|^gpt-image-2(?:$|-)/i.test(model); } function updateAiImageScaleOptions() { var scale = document.getElementById('aiImageScale'); if (!scale) return; var current = parseInt(scale.value || '2048', 10) || 2048; var supports4K = aiSelectedImageModelSupports4K(); var options = [ { value: '1024', label: '1K' }, { value: '2048', label: '2K' } ]; if (supports4K) { options.push({ value: '3072', label: '3K' }, { value: '3840', label: '4K' }); } scale.innerHTML = options.map(function(option) { return ''; }).join(''); var maxScale = supports4K ? 3840 : 2048; scale.value = String(Math.min(current, maxScale)); if (!scale.value) { scale.value = supports4K && current > 2048 ? '3840' : '2048'; } if (typeof updateAiImageRatioIcon === 'function') updateAiImageRatioIcon(); } function updateAiAvailability(settings) { settings = settings || currentAiSettings || {}; updateDashboardAiPanel(settings); if (!AI_FEATURES_ENABLED) return; updateAiImageModelControl(settings); // Refresh the size note so the estimated per-image cost reflects the // current pricing once settings have loaded. if (typeof updateAiImageRatioIcon === 'function') updateAiImageRatioIcon(); var configured = aiProviderIsConfigured(settings); var enabled = !!settings.enabled; var usable = configured && enabled; var assistantUsable = usable && aiFeatureEnabled(settings, 'backendAssistant'); var textUsable = usable && aiFeatureEnabled(settings, 'seoTextGeneration'); var imageUsable = usable && aiFeatureEnabled(settings, 'imageGeneration') && aiImageServiceUsable(settings); dashboardAiImageUsable = imageUsable; var status = document.getElementById('aiProviderStatus'); if (status) { status.className = 'ai-status-box ' + (usable ? 'ai-status-box--ok' : 'ai-status-box--warning'); status.textContent = usable ? t('ai.configured_status') : (configured ? t('ai.disabled_status') : t('ai.not_configured_text')); } var banner = document.getElementById('aiUnavailableBanner'); if (banner) banner.hidden = usable; document.querySelectorAll('#aiChatForm textarea, #aiChatForm button').forEach(function(el) { el.disabled = !assistantUsable; }); document.querySelectorAll('#aiTextForm textarea, #aiTextForm input, #aiTextForm button').forEach(function(el) { el.disabled = !textUsable; }); document.querySelectorAll('#aiImageForm textarea, #aiImageForm select, #aiImageForm input, #aiImageForm button').forEach(function(el) { el.disabled = !imageUsable; }); var imageModel = String(document.getElementById('aiImageModelPicker')?.value || (aiImageServiceId(settings) === 'inherit' ? settings.imageModel : aiImageServiceModelValue()) || document.getElementById('aiImageModel')?.value || '').trim(); var imageModelMissing = imageUsable && !imageModel; var imageModelNote = document.getElementById('aiImageModelNote'); var imageButton = document.getElementById('aiGenerateImageButton'); if (imageButton) { imageButton.disabled = !imageUsable || imageModelMissing; } if (imageModelNote) { var imageUnavailable = usable && aiFeatureEnabled(settings, 'imageGeneration') ? aiImageUnavailableMessage(settings) : ''; if (imageUnavailable) { imageModelNote.textContent = imageUnavailable; imageModelNote.hidden = false; } else { imageModelNote.textContent = aiUsesOpenRouter(settings) && aiImageServiceId(settings) === 'inherit' ? t('ai.image_openrouter_note') : t('ai.image_model_missing_note'); imageModelNote.hidden = !imageUsable || (!imageModelMissing && !(aiUsesOpenRouter(settings) && aiImageServiceId(settings) === 'inherit')); } } document.querySelectorAll('#aiFeatureAssistant, #aiFeatureSeo, #aiFeatureImages').forEach(function(el) { el.disabled = !configured; }); refreshSeoAiButtons(); } async function loadAiSettings() { if (!AI_FEATURES_ENABLED) return; try { var response = await fetch('api.php?action=load-ai-settings'); var result = await response.json(); if (!result.success) throw new Error(result.message || 'Could not load AI settings'); currentAiSettings = result.data.settings || {}; populateAiSettings(currentAiSettings); updateAiUsage(result.data.usage); updateDashboardAiPanel(currentAiSettings); } catch (error) { var usage = document.getElementById('aiUsageSummary'); if (usage) usage.textContent = error.message; } } function populateAiSettings(settings) { settings = settings || {}; var provider = settings.provider || 'openai-compatible'; var providerCredentials = aiProviderCredentials(settings, provider); var fields = { aiEnabled: !!settings.enabled, aiAllowLocalProvider: !!settings.allowLocalProvider, aiAssistantForceEnglish: !!settings.assistantForceEnglish, aiAssistantSurfaceVisualEditor: !settings.assistantSurfaces || settings.assistantSurfaces.visualEditor !== false, aiAssistantSurfaceDashboard: !settings.assistantSurfaces || settings.assistantSurfaces.dashboard !== false, aiFeatureAssistant: !!(settings.features && settings.features.backendAssistant), aiFeatureSeo: !!(settings.features && settings.features.seoTextGeneration), aiFeatureImages: !!(settings.features && settings.features.imageGeneration) }; Object.keys(fields).forEach(function(id) { var el = document.getElementById(id); if (el) el.checked = fields[id]; }); var values = { aiProvider: provider, aiBaseUrl: Object.prototype.hasOwnProperty.call(providerCredentials, 'baseUrl') ? (providerCredentials.baseUrl || '') : (settings.baseUrl || ''), aiOrganization: Object.prototype.hasOwnProperty.call(providerCredentials, 'organization') ? (providerCredentials.organization || '') : (settings.organization || ''), aiImageModel: Object.prototype.hasOwnProperty.call(settings, 'imageModel') ? (settings.imageModel || '') : 'gpt-image-2', aiMonthlyBudget: settings.limits?.monthlyBudgetCents ?? 1000, aiDailyRequests: settings.limits?.dailyRequests ?? 300, aiDailyTextRequests: settings.limits?.dailyTextRequests ?? 250, aiDailyImageRequests: settings.limits?.dailyImageRequests ?? 10, aiMaxInputTokens: settings.limits?.maxInputTokens ?? 64000, aiMaxOutputTokens: settings.limits?.maxOutputTokens ?? 16000, aiRequestTimeout: settings.limits?.requestTimeoutSeconds ?? 300, aiImagePrice: settings.pricing?.imageCentsPerRequest ?? 5, aiFxRate: settings.pricing?.usdPerEur > 0 ? settings.pricing.usdPerEur : '' }; Object.keys(values).forEach(function(id) { var el = document.getElementById(id); if (el) el.value = values[id]; }); updateAiImageModelControl(settings); aiPopulateImageService(settings); updateAiImageModelControl(settings); populateAgentSettings(settings); // Models and reasoning levels come from the saved settings of the active provider; presets fill what is empty. aiProviderDrafts = {}; aiPriceChanges = {}; aiLastProvider = provider; var defaults = aiProviderModelDefaults(provider, values.aiBaseUrl); var reasoning = settings.reasoning || {}; aiReasoning = { chat: reasoning.chat || 'recommended', text: reasoning.text || 'recommended' }; var chatModel = Object.prototype.hasOwnProperty.call(settings, 'chatModel') ? (settings.chatModel || '') : defaults.chat; var textModel = Object.prototype.hasOwnProperty.call(settings, 'textModel') ? (settings.textModel || chatModel) : defaults.text; aiById('aiChatModel').value = chatModel; aiById('aiTextModel').value = textModel; aiLegacy = { provider: provider, chat: aiReasoning.chat === 'legacy' ? chatModel.toLowerCase() : '', text: aiReasoning.text === 'legacy' ? textModel.toLowerCase() : '' }; aiUpdateOrganizationVisibility(provider); updateAiApiKeyHint(provider); updateAiModelPlaceholders(provider); updateAiAvailability(settings); aiSyncDashboardCopilot(settings); } function updateAiApiKeyHint(provider) { var credentials = aiProviderCredentials(currentAiSettings || {}, provider || document.getElementById('aiProvider')?.value || 'openai-compatible'); aiSetSecretField('aiApiKey', 'aiRemoveApiKey', 'aiApiKeyHint', !!credentials.hasApiKey); } // The organization header only goes to providers that take it. function aiUpdateOrganizationVisibility(provider) { var group = document.getElementById('aiOrganizationGroup'); if (group) group.hidden = provider === 'anthropic' || provider === 'kie'; } function applyAiDefaultsForNewApiKey() { var keyInput = document.getElementById('aiApiKey'); if (!keyInput || !aiTypedSecret('aiApiKey')) return; var provider = document.getElementById('aiProvider')?.value || (currentAiSettings && currentAiSettings.provider) || 'openai-compatible'; var credentials = aiProviderCredentials(currentAiSettings || {}, provider); if ((currentAiSettings && currentAiSettings.hasApiKey) || credentials.hasApiKey) return; [ 'aiEnabled', 'aiAssistantSurfaceVisualEditor', 'aiAssistantSurfaceDashboard', 'aiFeatureAssistant', 'aiFeatureSeo', 'aiFeatureImages' ].forEach(function(id) { var el = document.getElementById(id); if (el) el.checked = true; }); } function collectAiSettingsForm() { applyAiDefaultsForNewApiKey(); var provider = document.getElementById('aiProvider').value; var imagePicker = document.getElementById('aiImageModelPicker'); if (imagePicker && aiImageServiceId() === 'inherit') { document.getElementById('aiImageModel').value = imagePicker.value; } var imagePart = aiCollectImageService(); var agentPart = collectAgentSettings(); return { enabled: document.getElementById('aiEnabled').checked, provider: provider, baseUrl: document.getElementById('aiBaseUrl').value.trim(), apiKey: aiTypedSecret('aiApiKey'), organization: document.getElementById('aiOrganization').value.trim(), allowLocalProvider: document.getElementById('aiAllowLocalProvider').checked, assistantForceEnglish: document.getElementById('aiAssistantForceEnglish').checked, assistantSurfaces: { visualEditor: document.getElementById('aiAssistantSurfaceVisualEditor').checked, dashboard: document.getElementById('aiAssistantSurfaceDashboard').checked }, chatModel: document.getElementById('aiChatModel').value.trim(), textModel: document.getElementById('aiTextModel').value.trim(), reasoning: { chat: aiReasoning.chat, text: aiReasoning.text }, imageModel: document.getElementById('aiImageModel').value.trim(), imageService: imagePart.imageService, imageServices: imagePart.imageServices, features: { backendAssistant: document.getElementById('aiFeatureAssistant').checked, seoTextGeneration: document.getElementById('aiFeatureSeo').checked, imageGeneration: document.getElementById('aiFeatureImages').checked }, limits: { monthlyBudgetCents: parseInt(document.getElementById('aiMonthlyBudget').value || '0', 10), dailyRequests: parseInt(document.getElementById('aiDailyRequests').value || '0', 10), dailyTextRequests: parseInt(document.getElementById('aiDailyTextRequests').value || '0', 10), dailyImageRequests: parseInt(document.getElementById('aiDailyImageRequests').value || '0', 10), maxInputTokens: parseInt(document.getElementById('aiMaxInputTokens').value || '0', 10), maxOutputTokens: parseInt(document.getElementById('aiMaxOutputTokens').value || '0', 10), requestTimeoutSeconds: parseInt(document.getElementById('aiRequestTimeout').value || '0', 10) }, pricing: { models: aiCollectPriceChanges(), imageModels: aiCollectImagePriceChanges(), usdPerEur: parseFloat(document.getElementById('aiFxRate').value || '0') || 0, imageCentsPerRequest: parseInt(document.getElementById('aiImagePrice').value || '0', 10) }, agent: agentPart.agent, research: agentPart.research, stock: agentPart.stock }; } // ---- AI agent section ---------------------------------------------------------------------------------- var agentRightIds = ['publish', 'stockImages', 'uploadImages', 'generateImages', 'research', 'structural', 'createPages', 'createNews', 'createEvents', 'editSharedContent']; function agentInt(id, fallback) { var value = parseInt(document.getElementById(id)?.value || '', 10); return Number.isFinite(value) ? value : fallback; } function collectAgentSettings() { var roles = {}; agentRightIds.forEach(function(right) { var el = document.getElementById('aiAgentRight_' + right); if (el) roles[right] = el.checked; }); return { agent: { enabled: !!document.getElementById('aiAgentEnabled')?.checked, classicMode: document.getElementById('aiAgentClassicMode') ? !!document.getElementById('aiAgentClassicMode').checked : true, limits: { runsPerHour: agentInt('aiAgentRunsPerHour', 10), maxTurns: agentInt('aiAgentMaxTurns', 14), maxToolCalls: agentInt('aiAgentMaxToolCalls', 40), maxCostCentsPerRun: agentInt('aiAgentMaxCost', 100), maxRuntimeSeconds: agentInt('aiAgentMaxRuntime', 15) * 60, uploadsPer10Minutes: agentInt('aiAgentUploads', 10) }, roles: { editor: roles }, mediaFolder: (document.getElementById('aiAgentMediaFolder')?.value || '').trim() || 'agent', creditInNews: !!document.getElementById('aiAgentCreditInNews')?.checked, sourcesInNews: !!document.getElementById('aiAgentSourcesInNews')?.checked }, research: { braveApiKey: aiTypedSecret('aiBraveApiKey'), allowDomains: document.getElementById('aiAgentAllowDomains')?.value || '', denyDomains: document.getElementById('aiAgentDenyDomains')?.value || '', respectRobots: !!document.getElementById('aiAgentRespectRobots')?.checked }, stock: { pexelsApiKey: aiTypedSecret('aiPexelsApiKey') } }; } function populateAgentSettings(settings) { var agent = settings.agent || {}; var limits = agent.limits || {}; var editor = (agent.roles && agent.roles.editor) || {}; var research = settings.research || {}; var stock = settings.stock || {}; var checks = { aiAgentEnabled: !!agent.enabled, aiAgentClassicMode: agent.classicMode !== false, aiAgentCreditInNews: agent.creditInNews !== false, aiAgentSourcesInNews: agent.sourcesInNews !== false, aiAgentRespectRobots: research.respectRobots !== false }; Object.keys(checks).forEach(function(id) { var el = document.getElementById(id); if (el) el.checked = checks[id]; }); agentRightIds.forEach(function(right) { var el = document.getElementById('aiAgentRight_' + right); if (el) el.checked = editor[right] === true; }); var values = { aiAgentRunsPerHour: limits.runsPerHour ?? 10, aiAgentMaxTurns: limits.maxTurns ?? 14, aiAgentMaxToolCalls: limits.maxToolCalls ?? 40, aiAgentMaxCost: limits.maxCostCentsPerRun ?? 100, aiAgentMaxRuntime: Math.max(1, Math.round((limits.maxRuntimeSeconds ?? 900) / 60)), aiAgentUploads: limits.uploadsPer10Minutes ?? 10, aiAgentMediaFolder: agent.mediaFolder || 'agent', aiAgentAllowDomains: (research.allowDomains || []).join('\n'), aiAgentDenyDomains: (research.denyDomains || []).join('\n'), // Keys are never sent back: the fields stay empty and the hints say whether a key is saved. aiBraveApiKey: '', aiPexelsApiKey: '' }; Object.keys(values).forEach(function(id) { var el = document.getElementById(id); if (el) el.value = values[id]; }); aiSetSecretField('aiBraveApiKey', 'aiRemoveBraveApiKey', 'aiBraveApiKeyHint', !!research.hasBraveApiKey); aiSetSecretField('aiPexelsApiKey', 'aiRemovePexelsApiKey', 'aiPexelsApiKeyHint', !!stock.hasPexelsApiKey); renderAgentStatus(settings); } function renderAgentStatus(settings) { var box = document.getElementById('aiAgentStatus'); if (!box) return; var status = settings.agentStatus; if (!status) { // The dashboard overview carries the settings without the status; keep what // the full AI settings showed instead of blanking the box until they return if (!box.textContent) box.hidden = true; return; } var messages = []; if (!(settings.agent && settings.agent.enabled)) { messages.push(t('ai.agent_status_off')); } else if (!settings.enabled || !settings.hasApiKey) { messages.push(t('ai.agent_status_ai_off')); } else if (!status.toolCalling) { messages.push(t('ai.agent_status_no_tools')); } else { messages.push(t('ai.agent_status_ready')); } if (!status.gd) messages.push(t('ai.agent_status_no_gd')); else if (!status.webp) messages.push(t('ai.agent_status_no_webp')); if (settings.agent && settings.agent.enabled && status.limits && (status.limits.maxInputTokens < 60000 || (status.limits.dailyRequests > 0 && status.limits.dailyRequests < 200))) { messages.push(t('ai.agent_status_limits', { requests: status.limits.dailyRequests, tokens: status.limits.maxInputTokens })); } box.textContent = messages.join(' '); box.hidden = false; } // What the agent needs to work (model turns carry the page content and tool results). var AI_AGENT_RECOMMENDED_LIMITS = { aiMaxInputTokens: 64000, aiMaxOutputTokens: 16000, aiDailyRequests: 300, aiDailyTextRequests: 250 }; function raiseAgentLimits() { var raised = false; Object.keys(AI_AGENT_RECOMMENDED_LIMITS).forEach(function(id) { var el = document.getElementById(id); if (el && (parseInt(el.value || '0', 10) || 0) < AI_AGENT_RECOMMENDED_LIMITS[id]) { el.value = AI_AGENT_RECOMMENDED_LIMITS[id]; raised = true; } }); return raised; } document.getElementById('aiAgentRecommendedLimits')?.addEventListener('click', function() { showToast(t(raiseAgentLimits() ? 'ai.agent_limits_raised' : 'ai.agent_limits_fine'), 'info'); }); document.getElementById('aiAgentEnabled')?.addEventListener('change', function() { if (this.checked && raiseAgentLimits()) showToast(t('ai.agent_limits_raised'), 'info'); }); // After a save that adds a key or puts a powerful chat model in place, offer the agent while it is off. // A key that was just added always gets the question; a changed model only once per model. function maybeOfferAiAgent(previous, saved) { if (!saved || !saved.enabled || !saved.hasApiKey || (saved.agent && saved.agent.enabled)) return; if (!saved.agentStatus || !saved.agentStatus.toolCalling) return; var caps = aiModelCaps(saved.provider, saved.baseUrl, saved.chatModel); if (!caps || caps.tier !== 'power') return; var before = previous && previous.providerCredentials && previous.providerCredentials[saved.provider]; var keyAdded = !before || !before.hasApiKey; var modelChanged = !previous || previous.provider !== saved.provider || previous.chatModel !== saved.chatModel; if (!keyAdded && !modelChanged) return; var memo = 'rjAgentOffer:' + saved.provider + '|' + String(saved.chatModel).toLowerCase(); try { if (!keyAdded && localStorage.getItem(memo)) return; localStorage.setItem(memo, '1'); } catch (e) { /* without storage the question may come again */ } showModal(t('ai.agent_offer_title'), t('ai.agent_offer_text', { model: caps.label }), function() { closeModal(); var toggle = document.getElementById('aiAgentEnabled'); if (!toggle) return; toggle.checked = true; raiseAgentLimits(); saveAiSettingsForm(); }, { confirmText: t('ai.agent_offer_confirm'), confirmClass: 'btn btn-primary' }); } // The assistant button (bottom right) belongs to the page only when the assistant is usable. The page loads its script // and styles only then, so after the first key is saved they are loaded here, and the button goes away when it stops being usable. var aiCopilotLoading = false; function aiSyncDashboardCopilot(settings) { if (!AI_FEATURES_ENABLED || !settings) return; var available = !!settings.enabled && !!settings.hasApiKey && !!(settings.features && settings.features.backendAssistant) && !(settings.assistantSurfaces && settings.assistantSurfaces.dashboard === false); var root = document.querySelector('.rj-copilot'); if (root) { root.style.display = available ? '' : 'none'; if (!available && window.RaijuAiCopilot) window.RaijuAiCopilot.close(); return; } if (!available || window.RaijuAiCopilot || aiCopilotLoading) return; aiCopilotLoading = true; window.RJ_AI_COPILOT_AVAILABLE = true; var style = document.createElement('link'); style.rel = 'stylesheet'; style.href = '../css/ai-copilot.css?v='; document.head.appendChild(style); var script = document.createElement('script'); script.src = '../js/ai-copilot.js?v='; script.onerror = function() { aiCopilotLoading = false; }; document.body.appendChild(script); } document.getElementById('aiAgentClearCache')?.addEventListener('click', async function() { var button = this; button.disabled = true; try { var formData = new FormData(); formData.append('action', 'ai-agent-clear-cache'); formData.append('csrf_token', CSRF_TOKEN); var response = await fetch('api.php', { method: 'POST', body: formData }); var result = await response.json(); if (!result.success) throw new Error(result.message); showToast(t('ai.agent_cache_cleared', { count: result.data.removed }), 'success'); } catch (error) { showToast(error.message, 'error'); } finally { button.disabled = false; } }); document.getElementById('aiProvider')?.addEventListener('change', function() { // The models and levels typed for the provider left behind are kept for this visit; the typed key is dropped. aiStashProviderModels(aiLastProvider); aiLastProvider = this.value; var baseUrl = document.getElementById('aiBaseUrl'); var credentials = aiProviderCredentials(currentAiSettings || {}, this.value); if (baseUrl) baseUrl.value = credentials.baseUrl || ''; var organization = document.getElementById('aiOrganization'); if (organization) organization.value = credentials.organization || ''; aiUpdateOrganizationVisibility(this.value); updateAiApiKeyHint(this.value); aiApplyProviderModels(this.value, baseUrl ? baseUrl.value : ''); updateAiModelPlaceholders(this.value); updateAiImageModelControl({ provider: this.value, baseUrl: baseUrl ? baseUrl.value : '', imageModel: document.getElementById('aiImageModel')?.value || '' }); var draft = Object.assign({}, currentAiSettings || {}, collectAiSettingsForm()); draft.hasApiKey = !!(currentAiSettings && currentAiSettings.hasApiKey) || !!document.getElementById('aiApiKey').value.trim(); updateAiAvailability(draft); }); document.getElementById('aiImageModelPicker')?.addEventListener('change', function() { var imageInput = document.getElementById('aiImageModel'); if (aiImageServiceId() !== 'inherit') { var serviceModel = document.getElementById('aiImageServiceModel'); if (serviceModel) { serviceModel.value = this.value; updateClearButton(serviceModel); } aiRenderImageService(false); } else if (imageInput) { imageInput.value = this.value; aiRenderImagePrice(); } updateAiImageScaleOptions(); var draft = Object.assign({}, currentAiSettings || {}, collectAiSettingsForm()); draft.hasApiKey = !!(currentAiSettings && currentAiSettings.hasApiKey) || !!document.getElementById('aiApiKey').value.trim(); updateAiAvailability(draft); }); ['aiApiKey', 'aiBaseUrl', 'aiAllowLocalProvider', 'aiEnabled', 'aiAssistantForceEnglish', 'aiAssistantSurfaceVisualEditor', 'aiAssistantSurfaceDashboard', 'aiFeatureAssistant', 'aiFeatureSeo', 'aiFeatureImages', 'aiImageModel', 'aiImageModelPicker'].forEach(function(id) { document.addEventListener('input', function(e) { if (e.target && e.target.id === id) { if (id === 'aiApiKey') applyAiDefaultsForNewApiKey(); var draft = Object.assign({}, currentAiSettings || {}, collectAiSettingsForm()); draft.hasApiKey = !!(currentAiSettings && currentAiSettings.hasApiKey) || !!document.getElementById('aiApiKey').value.trim(); updateAiAvailability(draft); } }); document.addEventListener('change', function(e) { if (e.target && e.target.id === id) { if (id === 'aiApiKey') applyAiDefaultsForNewApiKey(); var draft = Object.assign({}, currentAiSettings || {}, collectAiSettingsForm()); draft.hasApiKey = !!(currentAiSettings && currentAiSettings.hasApiKey) || !!document.getElementById('aiApiKey').value.trim(); updateAiAvailability(draft); } }); }); aiById('aiImageService')?.addEventListener('change', aiImageServiceChanged); ['input', 'change'].forEach(function(type) { aiById('aiImageServiceModel')?.addEventListener(type, function() { aiRenderImageService(false); updateAiImageModelControl(currentAiSettings || {}); var draft = Object.assign({}, currentAiSettings || {}, collectAiSettingsForm()); draft.hasApiKey = !!(currentAiSettings && currentAiSettings.hasApiKey) || !!aiById('aiApiKey')?.value.trim(); updateAiAvailability(draft); }); }); ['aiImageApiKey', 'aiImageCustomBaseUrl'].forEach(function(id) { aiById(id)?.addEventListener('input', function() { aiRenderImageService(false); var draft = Object.assign({}, currentAiSettings || {}, collectAiSettingsForm()); draft.hasApiKey = !!(currentAiSettings && currentAiSettings.hasApiKey) || !!aiById('aiApiKey')?.value.trim(); updateAiAvailability(draft); }); }); document.getElementById('aiBaseUrl')?.addEventListener('input', function() { aiRenderEffort('chat'); aiRenderEffort('text'); aiRenderPricing(); }); // Remove a stored key right away, after asking. Unsaved edits in the form stay as they are. document.querySelectorAll('[data-secret-target]').forEach(function(button) { button.addEventListener('click', function() { var kind = button.getAttribute('data-secret-target'); var target = kind === 'provider' ? 'provider:' + aiCurrentProvider() : (kind === 'image' ? 'image:' + aiImageServiceId() : kind); showModal(t('ai.remove_key_title'), t('ai.remove_key_confirm'), async function() { closeModal(); try { var formData = new FormData(); formData.append('action', 'ai-clear-secret'); formData.append('target', target); formData.append('csrf_token', CSRF_TOKEN); var response = await fetch('api.php', { method: 'POST', body: formData }); var result = await response.json(); if (!result.success) throw new Error(result.message); var fresh = result.data.settings || {}; currentAiSettings = Object.assign({}, currentAiSettings, { hasApiKey: fresh.hasApiKey, providerCredentials: fresh.providerCredentials, research: Object.assign({}, (currentAiSettings || {}).research, { hasBraveApiKey: !!(fresh.research && fresh.research.hasBraveApiKey) }), stock: Object.assign({}, (currentAiSettings || {}).stock, { hasPexelsApiKey: !!(fresh.stock && fresh.stock.hasPexelsApiKey) }), agentStatus: fresh.agentStatus, imageServiceStatus: fresh.imageServiceStatus }); aiLiveModels = {}; updateAiApiKeyHint(aiCurrentProvider()); aiRenderImageService(true); aiSetSecretField('aiBraveApiKey', 'aiRemoveBraveApiKey', 'aiBraveApiKeyHint', !!currentAiSettings.research.hasBraveApiKey); aiSetSecretField('aiPexelsApiKey', 'aiRemovePexelsApiKey', 'aiPexelsApiKeyHint', !!currentAiSettings.stock.hasPexelsApiKey); renderAgentStatus(currentAiSettings); aiSyncDashboardCopilot(currentAiSettings); updateAiAvailability(Object.assign({}, currentAiSettings, collectAiSettingsForm(), { hasApiKey: !!currentAiSettings.hasApiKey })); if (result.data.usage) updateAiUsage(result.data.usage); showToast(t('ai.key_removed'), 'success'); } catch (error) { showToast(error.message, 'error'); } }, { confirmText: t('ai.remove_key_button'), confirmClass: 'btn btn-danger' }); }); }); // The "thinking" bubble at the end of the chat while an answer is on its way; the caller removes it. function appendAiThinking() { var log = document.getElementById('aiChatLog'); if (!log) return null; var item = document.createElement('div'); item.className = 'ai-chat-message ai-chat-message--thinking'; item.setAttribute('role', 'status'); item.innerHTML = '' + escapeHtml(t('ai.answering')) + ''; log.appendChild(item); log.scrollTop = log.scrollHeight; return item; } function appendAiChat(role, text) { var log = document.getElementById('aiChatLog'); if (!log) return; var item = document.createElement('div'); item.className = 'ai-chat-message ai-chat-message--' + role; if (role === 'assistant') { item.innerHTML = window.renderSimpleMarkup(text); } else { item.textContent = text; } log.appendChild(item); log.scrollTop = log.scrollHeight; } window.renderSimpleMarkup = function(text) { var escaped = escapeHtml(String(text || '')); return escaped .replace(/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+|mailto:[^)\s]+)\)/g, function(match, label, url) { var safeUrl = url.replace(/&/g, '&'); return '' + label + ''; }) .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/(^|[^*])\*([^*\\n]+)\*/g, '$1$2') .replace(/`([^`]+)`/g, '$1'); }; // Form definitions let formsAdminData = []; let currentFormEditorId = ''; function defaultFormDefinition(id) { return { id: id || 'kontakt', label: t('forms.contact_default'), description: '', enabled: true, submit: { store: true, email: true, subject: '{form}: {name}', successText: t('forms.default_success') }, fields: [ { type: 'text', key: 'name', label: t('mails.name').replace(':', ''), placeholder: '', required: true, width: 6, options: [] }, { type: 'email', key: 'email', label: t('mails.email').replace(':', ''), placeholder: '', required: true, width: 6, options: [] }, { type: 'textarea', key: 'message', label: t('mails.message').replace(':', ''), placeholder: '', required: true, width: 12, options: [] } ] }; }