// ============================================================
// 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('' + escapeHtml(t('dashboard_home.unread_messages')) + ' ' + unreadCount + ' ');
}
var backupText = backup.newest ? formatDashboardDate(backup.newest) : t('dashboard_home.no_backup');
if (VALID_DASHBOARD_TABS.indexOf('backup') !== -1) {
chips.push('' + escapeHtml(t('dashboard_home.backup_status')) + ' ' + escapeHtml(backupText) + ' ');
chips.push('' + escapeHtml(t('dashboard_home.auto_backup')) + ' ' + escapeHtml(backup.enabled ? t('dashboard_home.active') : t('dashboard_home.inactive')) + ' ');
}
if (recent) {
var recentTitle = recent.title || recent.id || '';
chips.push('' + escapeHtml(t('dashboard_home.recent_edit')) + ' ' + escapeHtml(recentTitle) + ' ');
}
if (currentUser.username) {
chips.push('' + escapeHtml(t('dashboard_home.current_user')) + ' ' + escapeHtml(currentUser.username) + ' ');
}
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 '' + escapeHtml(labels[value] || value) + ' ';
}).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 '' + escapeHtml(option.label) + ' ';
}).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 '' + option.label + ' ';
}).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: [] }
]
};
}