Improve AI limit UX in extension
This commit is contained in:
@@ -1016,6 +1016,12 @@ const aiCredentialCache = {
|
||||
timestamp: 0,
|
||||
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 moderationSettingsCache = {
|
||||
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) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return {
|
||||
@@ -6339,6 +6420,8 @@ async function generateAIComment(postText, profileNumber, options = {}) {
|
||||
error: message
|
||||
});
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
error.responseData = errorData;
|
||||
error.aiTrace = {
|
||||
traceId: responseTraceId,
|
||||
flowId: responseFlowId,
|
||||
@@ -6370,7 +6453,8 @@ async function generateAIComment(postText, profileNumber, options = {}) {
|
||||
traceId: effectiveTraceId,
|
||||
flowId: effectiveFlowId,
|
||||
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;
|
||||
}
|
||||
@@ -6395,7 +6479,7 @@ async function generateAIComment(postText, profileNumber, options = {}) {
|
||||
}
|
||||
|
||||
lastError = error;
|
||||
if (cancelled) {
|
||||
if (cancelled || (error && error.status === 429)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -6414,7 +6498,7 @@ async function generateAIComment(postText, profileNumber, options = {}) {
|
||||
requestAttempts: requestAttempts.slice()
|
||||
};
|
||||
}
|
||||
if (lastError.name === 'AbortError' || isCancellationError(lastError)) {
|
||||
if (lastError.name === 'AbortError' || isCancellationError(lastError) || lastError.status === 429) {
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
@@ -6473,11 +6557,7 @@ async function handleSelectionAIRequest(selectionText, sendResponse) {
|
||||
*/
|
||||
async function isAIEnabled() {
|
||||
try {
|
||||
const response = await backendFetch(`${API_URL}/ai-settings`);
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
const settings = await response.json();
|
||||
const settings = await fetchAISettings();
|
||||
return settings && settings.enabled === 1;
|
||||
} catch (error) {
|
||||
console.warn('[FB Tracker] Failed to check AI settings:', error);
|
||||
@@ -6621,6 +6701,7 @@ async function addAICommentButton(container, postElement) {
|
||||
|
||||
let notePreviewElement = null;
|
||||
let noteClearButton = null;
|
||||
let rateLimitRefreshPromise = null;
|
||||
|
||||
const truncateNoteForPreview = (note) => {
|
||||
if (!note) {
|
||||
@@ -6629,6 +6710,103 @@ async function addAICommentButton(container, postElement) {
|
||||
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 note = getAdditionalNote();
|
||||
const hasNote = note.trim().length > 0;
|
||||
@@ -6636,9 +6814,7 @@ async function addAICommentButton(container, postElement) {
|
||||
if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) {
|
||||
button.textContent = button.dataset.aiOriginalText;
|
||||
}
|
||||
button.title = hasNote
|
||||
? 'Generiere automatisch einen passenden Kommentar (mit Zusatzinfo)'
|
||||
: 'Generiere automatisch einen passenden Kommentar';
|
||||
applyAvailabilityState(button._aiAvailability || null);
|
||||
};
|
||||
|
||||
const updateNotePreview = () => {
|
||||
@@ -7072,6 +7248,11 @@ async function addAICommentButton(container, postElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.dataset.aiAvailability === 'blocked') {
|
||||
showBlockedToast(button._aiAvailability || null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropdownOpen) {
|
||||
closeDropdown();
|
||||
return;
|
||||
@@ -7104,6 +7285,7 @@ async function addAICommentButton(container, postElement) {
|
||||
|
||||
const originalText = button.dataset.aiOriginalText || '✨ AI';
|
||||
const currentState = button.dataset.aiState || 'idle';
|
||||
const currentAvailability = button._aiAvailability || null;
|
||||
|
||||
if (currentState === 'processing') {
|
||||
const runningContext = button._aiContext;
|
||||
@@ -7124,6 +7306,11 @@ async function addAICommentButton(container, postElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentAvailability && currentAvailability.blocked) {
|
||||
showBlockedToast(currentAvailability);
|
||||
return;
|
||||
}
|
||||
|
||||
const aiContext = {
|
||||
cancelled: false,
|
||||
abortController: new AbortController(),
|
||||
@@ -7228,11 +7415,13 @@ async function addAICommentButton(container, postElement) {
|
||||
dropdownButton.textContent = '▾';
|
||||
dropdownButton.setAttribute('aria-busy', 'false');
|
||||
button.textContent = text;
|
||||
applyAvailabilityState(button._aiAvailability || null, { preserveText: true });
|
||||
|
||||
if (revertDelay > 0) {
|
||||
setTimeout(() => {
|
||||
if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) {
|
||||
button.textContent = originalText;
|
||||
applyAvailabilityState(button._aiAvailability || null);
|
||||
}
|
||||
}, revertDelay);
|
||||
}
|
||||
@@ -7361,6 +7550,19 @@ async function addAICommentButton(container, postElement) {
|
||||
|
||||
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');
|
||||
const aiResult = await generateAIComment(postText, profileNumber, {
|
||||
signal: aiContext.abortController.signal,
|
||||
@@ -7374,6 +7576,11 @@ async function addAICommentButton(container, postElement) {
|
||||
requestAttempts: Array.isArray(aiResult.requestAttempts) ? aiResult.requestAttempts.length : 0
|
||||
});
|
||||
mergeTraceInfo(aiResult);
|
||||
if (aiResult.autoCommentRateLimitStatus) {
|
||||
applyAvailabilityState(aiResult.autoCommentRateLimitStatus);
|
||||
} else {
|
||||
void refreshAvailabilityState(true);
|
||||
}
|
||||
|
||||
throwIfCancelled();
|
||||
|
||||
@@ -7486,6 +7693,10 @@ async function addAICommentButton(container, postElement) {
|
||||
mergeTraceInfo(error.aiTrace);
|
||||
}
|
||||
|
||||
if (error && error.status === 429) {
|
||||
await refreshAvailabilityState(true);
|
||||
}
|
||||
|
||||
const cancelled = aiContext.cancelled || isCancellationError(error);
|
||||
if (cancelled) {
|
||||
console.log('[FB Tracker] AI comment operation cancelled');
|
||||
@@ -7535,9 +7746,14 @@ async function addAICommentButton(container, postElement) {
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (button.dataset.aiAvailability === 'blocked') {
|
||||
showBlockedToast(button._aiAvailability || null);
|
||||
return;
|
||||
}
|
||||
startAIFlow();
|
||||
});
|
||||
|
||||
void refreshAvailabilityState(false);
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user