Improve AI limit UX in extension

This commit is contained in:
2026-04-07 16:04:03 +02:00
parent b5b44d1304
commit af3d4f3dc7

View File

@@ -1016,6 +1016,12 @@ const aiCredentialCache = {
timestamp: 0, timestamp: 0,
pending: null pending: null
}; };
const AI_SETTINGS_CACHE_TTL = 30 * 1000;
const aiSettingsCache = {
data: null,
timestamp: 0,
pending: null
};
const MODERATION_SETTINGS_CACHE_TTL = 5 * 60 * 1000; const MODERATION_SETTINGS_CACHE_TTL = 5 * 60 * 1000;
const moderationSettingsCache = { const moderationSettingsCache = {
data: null, data: null,
@@ -1177,6 +1183,81 @@ async function fetchActiveAICredentials(forceRefresh = false) {
} }
} }
async function fetchAISettings(forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && aiSettingsCache.data && (now - aiSettingsCache.timestamp < AI_SETTINGS_CACHE_TTL)) {
return aiSettingsCache.data;
}
if (aiSettingsCache.pending) {
try {
return await aiSettingsCache.pending;
} catch (error) {
// fall through to retry below
}
}
aiSettingsCache.pending = (async () => {
const response = await backendFetch(`${API_URL}/ai-settings`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'AI-Einstellungen konnten nicht geladen werden');
}
const settings = await response.json();
aiSettingsCache.data = settings;
aiSettingsCache.timestamp = Date.now();
return settings;
})();
try {
return await aiSettingsCache.pending;
} finally {
aiSettingsCache.pending = null;
}
}
function getAIAutoCommentRateLimitStatusFromSettings(settings, profileNumber) {
const normalizedProfile = parseInt(profileNumber, 10);
if (!settings || !Number.isInteger(normalizedProfile)) {
return null;
}
const statuses = Array.isArray(settings.rate_limit_statuses) ? settings.rate_limit_statuses : [];
return statuses.find((entry) => parseInt(entry?.profile_number, 10) === normalizedProfile) || null;
}
function formatAIAutoCommentRateLimitReason(status) {
if (!status || !status.blocked_reason) {
return 'Aktion aktuell gesperrt';
}
const reasonMap = {
cooldown: 'Cooldown aktiv',
active_hours: 'Außerhalb der Aktivzeiten',
min_delay: 'Mindestabstand noch nicht erreicht',
per_minute: 'Minutenlimit erreicht',
burst: 'Burst-Limit erreicht',
per_hour: 'Stundenlimit erreicht',
per_day: 'Tageslimit erreicht'
};
return reasonMap[status.blocked_reason] || 'Aktion aktuell gesperrt';
}
function formatAIAutoCommentRateLimitUntil(iso) {
if (!iso) {
return '';
}
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function normalizeModerationSettings(payload) { function normalizeModerationSettings(payload) {
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
return { return {
@@ -6339,6 +6420,8 @@ async function generateAIComment(postText, profileNumber, options = {}) {
error: message error: message
}); });
const error = new Error(message); const error = new Error(message);
error.status = response.status;
error.responseData = errorData;
error.aiTrace = { error.aiTrace = {
traceId: responseTraceId, traceId: responseTraceId,
flowId: responseFlowId, flowId: responseFlowId,
@@ -6370,7 +6453,8 @@ async function generateAIComment(postText, profileNumber, options = {}) {
traceId: effectiveTraceId, traceId: effectiveTraceId,
flowId: effectiveFlowId, flowId: effectiveFlowId,
requestAttempts, requestAttempts,
backendTimings: data.timings && data.timings.backend ? data.timings.backend : null backendTimings: data.timings && data.timings.backend ? data.timings.backend : null,
autoCommentRateLimitStatus: data.autoCommentRateLimitStatus || null
}; };
return returnMeta ? result : sanitizedComment; return returnMeta ? result : sanitizedComment;
} }
@@ -6395,7 +6479,7 @@ async function generateAIComment(postText, profileNumber, options = {}) {
} }
lastError = error; lastError = error;
if (cancelled) { if (cancelled || (error && error.status === 429)) {
break; break;
} }
} }
@@ -6414,7 +6498,7 @@ async function generateAIComment(postText, profileNumber, options = {}) {
requestAttempts: requestAttempts.slice() requestAttempts: requestAttempts.slice()
}; };
} }
if (lastError.name === 'AbortError' || isCancellationError(lastError)) { if (lastError.name === 'AbortError' || isCancellationError(lastError) || lastError.status === 429) {
throw lastError; throw lastError;
} }
} }
@@ -6473,11 +6557,7 @@ async function handleSelectionAIRequest(selectionText, sendResponse) {
*/ */
async function isAIEnabled() { async function isAIEnabled() {
try { try {
const response = await backendFetch(`${API_URL}/ai-settings`); const settings = await fetchAISettings();
if (!response.ok) {
return false;
}
const settings = await response.json();
return settings && settings.enabled === 1; return settings && settings.enabled === 1;
} catch (error) { } catch (error) {
console.warn('[FB Tracker] Failed to check AI settings:', error); console.warn('[FB Tracker] Failed to check AI settings:', error);
@@ -6621,6 +6701,7 @@ async function addAICommentButton(container, postElement) {
let notePreviewElement = null; let notePreviewElement = null;
let noteClearButton = null; let noteClearButton = null;
let rateLimitRefreshPromise = null;
const truncateNoteForPreview = (note) => { const truncateNoteForPreview = (note) => {
if (!note) { if (!note) {
@@ -6629,6 +6710,103 @@ async function addAICommentButton(container, postElement) {
return note.length > 120 ? `${note.slice(0, 117)}` : note; return note.length > 120 ? `${note.slice(0, 117)}` : note;
}; };
const getDefaultButtonTitle = () => {
const hasNote = getAdditionalNote().trim().length > 0;
return hasNote
? 'Generiere automatisch einen passenden Kommentar (mit Zusatzinfo)'
: 'Generiere automatisch einen passenden Kommentar';
};
const buildBlockedButtonTitle = (status) => {
if (!status || !status.blocked) {
return getDefaultButtonTitle();
}
const untilText = status.blocked_until
? ` Freigabe ab ${formatAIAutoCommentRateLimitUntil(status.blocked_until)}.`
: '';
return `${formatAIAutoCommentRateLimitReason(status)}.${untilText}`.trim();
};
const showBlockedToast = (status) => {
if (!status || !status.blocked) {
return;
}
const untilText = status.blocked_until
? ` Freigabe ab ${formatAIAutoCommentRateLimitUntil(status.blocked_until)}.`
: '';
showToast(`${status.profile_name || 'Profil'}: ${formatAIAutoCommentRateLimitReason(status)}.${untilText}`.trim(), 'info');
};
const applyAvailabilityState = (status = null, options = {}) => {
const { preserveText = false } = options;
const blocked = Boolean(status && status.blocked);
button._aiAvailability = status || null;
button.dataset.aiAvailability = blocked ? 'blocked' : 'available';
if (blocked) {
wrapper.style.opacity = '0.72';
wrapper.style.boxShadow = baseWrapperShadow;
wrapper.style.transform = 'translateY(0)';
wrapper.style.background = 'linear-gradient(135deg, #8b929e 0%, #5f6773 100%)';
button.style.cursor = 'not-allowed';
dropdownButton.style.cursor = 'not-allowed';
dropdownButton.style.opacity = '0.75';
button.title = buildBlockedButtonTitle(status);
dropdownButton.title = buildBlockedButtonTitle(status);
dropdownButton.setAttribute('aria-label', buildBlockedButtonTitle(status));
if (!preserveText && (button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) {
button.textContent = `${button.dataset.aiOriginalText || baseButtonText} 🔒`;
}
} else {
wrapper.style.opacity = '1';
wrapper.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
button.style.cursor = 'pointer';
dropdownButton.style.cursor = 'pointer';
dropdownButton.style.opacity = '1';
button.title = getDefaultButtonTitle();
dropdownButton.title = 'AI auswählen';
dropdownButton.setAttribute('aria-label', 'AI auswählen');
if (!preserveText && (button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) {
button.textContent = button.dataset.aiOriginalText || baseButtonText;
}
if (!wrapper.classList.contains('fb-tracker-ai-wrapper--open') && !(button.matches(':hover') || dropdownButton.matches(':hover'))) {
setHoverState(false);
}
}
};
const refreshAvailabilityState = async (forceRefresh = false, explicitProfileNumber = null) => {
if (rateLimitRefreshPromise) {
try {
return await rateLimitRefreshPromise;
} catch (error) {
return null;
}
}
rateLimitRefreshPromise = (async () => {
try {
const profileNumber = explicitProfileNumber || await fetchBackendProfileNumber();
if (!profileNumber) {
applyAvailabilityState(null);
return null;
}
const settings = await fetchAISettings(forceRefresh);
const status = getAIAutoCommentRateLimitStatusFromSettings(settings, profileNumber);
applyAvailabilityState(status);
return status;
} catch (error) {
console.warn('[FB Tracker] Failed to refresh AI rate limit status:', error);
applyAvailabilityState(null);
return null;
} finally {
rateLimitRefreshPromise = null;
}
})();
return await rateLimitRefreshPromise;
};
const updateNoteIndicator = () => { const updateNoteIndicator = () => {
const note = getAdditionalNote(); const note = getAdditionalNote();
const hasNote = note.trim().length > 0; const hasNote = note.trim().length > 0;
@@ -6636,9 +6814,7 @@ async function addAICommentButton(container, postElement) {
if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) { if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) {
button.textContent = button.dataset.aiOriginalText; button.textContent = button.dataset.aiOriginalText;
} }
button.title = hasNote applyAvailabilityState(button._aiAvailability || null);
? 'Generiere automatisch einen passenden Kommentar (mit Zusatzinfo)'
: 'Generiere automatisch einen passenden Kommentar';
}; };
const updateNotePreview = () => { const updateNotePreview = () => {
@@ -7072,6 +7248,11 @@ async function addAICommentButton(container, postElement) {
return; return;
} }
if (button.dataset.aiAvailability === 'blocked') {
showBlockedToast(button._aiAvailability || null);
return;
}
if (dropdownOpen) { if (dropdownOpen) {
closeDropdown(); closeDropdown();
return; return;
@@ -7104,6 +7285,7 @@ async function addAICommentButton(container, postElement) {
const originalText = button.dataset.aiOriginalText || '✨ AI'; const originalText = button.dataset.aiOriginalText || '✨ AI';
const currentState = button.dataset.aiState || 'idle'; const currentState = button.dataset.aiState || 'idle';
const currentAvailability = button._aiAvailability || null;
if (currentState === 'processing') { if (currentState === 'processing') {
const runningContext = button._aiContext; const runningContext = button._aiContext;
@@ -7124,6 +7306,11 @@ async function addAICommentButton(container, postElement) {
return; return;
} }
if (currentAvailability && currentAvailability.blocked) {
showBlockedToast(currentAvailability);
return;
}
const aiContext = { const aiContext = {
cancelled: false, cancelled: false,
abortController: new AbortController(), abortController: new AbortController(),
@@ -7228,11 +7415,13 @@ async function addAICommentButton(container, postElement) {
dropdownButton.textContent = '▾'; dropdownButton.textContent = '▾';
dropdownButton.setAttribute('aria-busy', 'false'); dropdownButton.setAttribute('aria-busy', 'false');
button.textContent = text; button.textContent = text;
applyAvailabilityState(button._aiAvailability || null, { preserveText: true });
if (revertDelay > 0) { if (revertDelay > 0) {
setTimeout(() => { setTimeout(() => {
if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) { if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) {
button.textContent = originalText; button.textContent = originalText;
applyAvailabilityState(button._aiAvailability || null);
} }
}, revertDelay); }, revertDelay);
} }
@@ -7361,6 +7550,19 @@ async function addAICommentButton(container, postElement) {
throwIfCancelled(); throwIfCancelled();
beginPhase('rateLimitStatusLookupMs');
const liveRateLimitStatus = await refreshAvailabilityState(true, profileNumber);
endPhase('rateLimitStatusLookupMs', {
blocked: Boolean(liveRateLimitStatus && liveRateLimitStatus.blocked)
});
if (liveRateLimitStatus && liveRateLimitStatus.blocked) {
flowTrace.status = 'blocked';
flowTrace.frontendError = liveRateLimitStatus.blocked_reason || 'AI_RATE_LIMIT_BLOCKED';
restoreIdle(`${originalText} 🔒`);
showBlockedToast(liveRateLimitStatus);
return;
}
beginPhase('aiRequestMs'); beginPhase('aiRequestMs');
const aiResult = await generateAIComment(postText, profileNumber, { const aiResult = await generateAIComment(postText, profileNumber, {
signal: aiContext.abortController.signal, signal: aiContext.abortController.signal,
@@ -7374,6 +7576,11 @@ async function addAICommentButton(container, postElement) {
requestAttempts: Array.isArray(aiResult.requestAttempts) ? aiResult.requestAttempts.length : 0 requestAttempts: Array.isArray(aiResult.requestAttempts) ? aiResult.requestAttempts.length : 0
}); });
mergeTraceInfo(aiResult); mergeTraceInfo(aiResult);
if (aiResult.autoCommentRateLimitStatus) {
applyAvailabilityState(aiResult.autoCommentRateLimitStatus);
} else {
void refreshAvailabilityState(true);
}
throwIfCancelled(); throwIfCancelled();
@@ -7486,6 +7693,10 @@ async function addAICommentButton(container, postElement) {
mergeTraceInfo(error.aiTrace); mergeTraceInfo(error.aiTrace);
} }
if (error && error.status === 429) {
await refreshAvailabilityState(true);
}
const cancelled = aiContext.cancelled || isCancellationError(error); const cancelled = aiContext.cancelled || isCancellationError(error);
if (cancelled) { if (cancelled) {
console.log('[FB Tracker] AI comment operation cancelled'); console.log('[FB Tracker] AI comment operation cancelled');
@@ -7535,9 +7746,14 @@ async function addAICommentButton(container, postElement) {
button.addEventListener('click', (event) => { button.addEventListener('click', (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (button.dataset.aiAvailability === 'blocked') {
showBlockedToast(button._aiAvailability || null);
return;
}
startAIFlow(); startAIFlow();
}); });
void refreshAvailabilityState(false);
container.appendChild(wrapper); container.appendChild(wrapper);
} }