Improve AI limit UX in extension
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user