From 3bcc7b08b4ebf8354fbfd3fa6acc259f24a650d5 Mon Sep 17 00:00:00 2001 From: MDeeApp <6595194+MDeeApp@users.noreply.github.com> Date: Sun, 5 Oct 2025 20:12:13 +0200 Subject: [PATCH] api usage cooldown --- backend/server.js | 927 ++++++++++++++++++++++++++++++++++++++----- extension/content.js | 280 +++++++++++-- web/settings.css | 62 +++ web/settings.js | 138 +++++++ 4 files changed, 1266 insertions(+), 141 deletions(-) diff --git a/backend/server.js b/backend/server.js index 0e28192..c02feb4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -63,6 +63,7 @@ app.use(ensureProfileScope); // Database setup const dbPath = path.join(__dirname, 'data', 'tracker.db'); const db = new Database(dbPath); +db.pragma('foreign_keys = ON'); function parseCookies(header) { if (!header || typeof header !== 'string') { @@ -484,7 +485,37 @@ ensureColumn('ai_settings', 'enabled', 'enabled INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'is_active', 'is_active INTEGER DEFAULT 1'); ensureColumn('ai_credentials', 'priority', 'priority INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'base_url', 'base_url TEXT'); +ensureColumn('ai_credentials', 'last_used_at', 'last_used_at DATETIME'); +ensureColumn('ai_credentials', 'last_success_at', 'last_success_at DATETIME'); +ensureColumn('ai_credentials', 'last_error_message', 'last_error_message TEXT'); +ensureColumn('ai_credentials', 'last_error_at', 'last_error_at DATETIME'); +ensureColumn('ai_credentials', 'last_status_code', 'last_status_code INTEGER'); +ensureColumn('ai_credentials', 'last_rate_limit_remaining', 'last_rate_limit_remaining TEXT'); +ensureColumn('ai_credentials', 'rate_limit_reset_at', 'rate_limit_reset_at DATETIME'); +ensureColumn('ai_credentials', 'auto_disabled', 'auto_disabled INTEGER DEFAULT 0'); +ensureColumn('ai_credentials', 'auto_disabled_reason', 'auto_disabled_reason TEXT'); +ensureColumn('ai_credentials', 'auto_disabled_until', 'auto_disabled_until DATETIME'); +ensureColumn('ai_credentials', 'usage_24h_count', 'usage_24h_count INTEGER DEFAULT 0'); +ensureColumn('ai_credentials', 'usage_24h_reset_at', 'usage_24h_reset_at DATETIME'); ensureColumn('search_seen_posts', 'manually_hidden', 'manually_hidden INTEGER NOT NULL DEFAULT 0'); + +db.exec(` + CREATE TABLE IF NOT EXISTS ai_usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + credential_id INTEGER NOT NULL, + event_type TEXT NOT NULL, + status_code INTEGER, + message TEXT, + metadata TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (credential_id) REFERENCES ai_credentials(id) ON DELETE CASCADE + ); +`); + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_ai_usage_events_credential_created + ON ai_usage_events(credential_id, created_at DESC); +`); db.prepare(` UPDATE posts SET last_change = COALESCE( @@ -542,6 +573,553 @@ function normalizeExistingPostUrls() { normalizeExistingPostUrls(); +function truncateString(value, maxLength) { + if (typeof value !== 'string') { + return value; + } + if (!Number.isFinite(maxLength) || maxLength <= 0) { + return value; + } + return value.length > maxLength + ? `${value.slice(0, maxLength - 3)}...` + : value; +} + +function ensureIsoDate(value) { + if (!value) { + return null; + } + if (value instanceof Date) { + const time = value.getTime(); + return Number.isNaN(time) ? null : value.toISOString(); + } + if (typeof value === 'number') { + return ensureIsoDate(new Date(value)); + } + const date = new Date(value); + const time = date.getTime(); + return Number.isNaN(time) ? null : date.toISOString(); +} + +function parseRetryAfter(value) { + if (!value && value !== 0) { + return null; + } + if (typeof value === 'number') { + return value >= 0 ? Math.round(value) : null; + } + const numeric = Number(value); + if (!Number.isNaN(numeric)) { + return numeric >= 0 ? Math.round(numeric) : null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + const diffSeconds = Math.round((date.getTime() - Date.now()) / 1000); + return diffSeconds >= 0 ? diffSeconds : null; +} + +function parseRateLimitReset(value) { + if (!value && value !== 0) { + return null; + } + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value; + } + + if (typeof value === 'number') { + if (value > 1e12) { + return new Date(value); + } + if (value > 1e9) { + return new Date(value * 1000); + } + return new Date(Date.now() + value * 1000); + } + + const numeric = Number(value); + if (!Number.isNaN(numeric)) { + if (numeric > 1e12) { + return new Date(numeric); + } + if (numeric > 1e9) { + return new Date(numeric * 1000); + } + return new Date(Date.now() + numeric * 1000); + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function extractRateLimitInfo(response, provider) { + const info = { provider: provider || null }; + if (!response || !response.headers || typeof response.headers.get !== 'function') { + return info; + } + + const headersOfInterest = {}; + const captureKeys = new Set([ + 'retry-after', + 'x-ratelimit-remaining', + 'x-ratelimit-remaining-requests', + 'x-ratelimit-reset', + 'x-ratelimit-reset-requests', + 'x-ratelimit-limit', + 'x-rate-limit-reset', + 'x-rate-limit-remaining' + ]); + + try { + response.headers.forEach((value, key) => { + const normalizedKey = key.toLowerCase(); + if (captureKeys.has(normalizedKey)) { + headersOfInterest[normalizedKey] = value; + } + }); + } catch (error) { + console.warn('Failed to iterate rate limit headers:', error.message); + } + + if (Object.keys(headersOfInterest).length) { + info.headers = headersOfInterest; + } + + const retryAfterHeader = response.headers.get('retry-after'); + const retryAfterSeconds = parseRetryAfter(retryAfterHeader); + if (retryAfterSeconds !== null) { + info.retryAfterSeconds = retryAfterSeconds; + } + + const remainingHeader = response.headers.get('x-ratelimit-remaining-requests') + || response.headers.get('x-ratelimit-remaining') + || response.headers.get('x-rate-limit-remaining'); + if (remainingHeader !== null && remainingHeader !== undefined) { + info.rateLimitRemaining = remainingHeader; + } + + const resetHeader = response.headers.get('x-ratelimit-reset-requests') + || response.headers.get('x-ratelimit-reset') + || response.headers.get('x-rate-limit-reset'); + const resetDate = parseRateLimitReset(resetHeader); + if (resetDate) { + info.rateLimitResetAt = resetDate.toISOString(); + } + + return info; +} + +const RATE_LIMIT_KEYWORDS = ['rate limit', 'ratelimit', 'quota', 'limit', 'too many requests', 'insufficient_quota', 'billing', 'exceeded', 'exceed']; + +function determineAutoDisable(error) { + if (!error) { + return null; + } + + const status = error.status || error.statusCode || null; + const baseMessage = typeof error.message === 'string' ? error.message : ''; + const errorDetails = error.apiError && typeof error.apiError === 'object' + ? (error.apiError.error?.message || error.apiError.error || error.apiError.message || '') + : ''; + const combinedMessage = `${baseMessage} ${errorDetails}`.toLowerCase(); + + let isRateLimit = status === 429; + if (!isRateLimit && status === 403) { + isRateLimit = RATE_LIMIT_KEYWORDS.some(keyword => combinedMessage.includes(keyword)); + } + if (!isRateLimit && combinedMessage) { + isRateLimit = RATE_LIMIT_KEYWORDS.some(keyword => combinedMessage.includes(keyword)); + } + + if (!isRateLimit) { + return null; + } + + let retryAfterSeconds = typeof error.retryAfterSeconds === 'number' + ? error.retryAfterSeconds + : null; + + if ((!retryAfterSeconds || retryAfterSeconds <= 0) && error.rateLimitResetAt) { + const resetDate = new Date(error.rateLimitResetAt); + if (!Number.isNaN(resetDate.getTime())) { + retryAfterSeconds = Math.round((resetDate.getTime() - Date.now()) / 1000); + } + } + + if (!retryAfterSeconds || retryAfterSeconds < 0) { + retryAfterSeconds = 900; // 15 minutes fallback + } + + if (retryAfterSeconds < 10) { + return null; + } + + const untilDate = new Date(Date.now() + retryAfterSeconds * 1000); + const reason = status + ? `Rate limit erreicht (HTTP ${status})` + : 'Rate limit erreicht'; + + return { + reason, + seconds: retryAfterSeconds, + until: untilDate + }; +} + +function recordAIUsageEvent(credentialId, eventType, options = {}) { + if (!credentialId || !eventType) { + return; + } + + try { + const { statusCode = null, message = null, metadata = null } = options; + let metadataJson = null; + + if (metadata && typeof metadata === 'object') { + try { + metadataJson = JSON.stringify(metadata); + } catch (error) { + metadataJson = null; + } + } else if (typeof metadata === 'string') { + metadataJson = metadata; + } + + db.prepare(` + INSERT INTO ai_usage_events (credential_id, event_type, status_code, message, metadata) + VALUES (?, ?, ?, ?, ?) + `).run( + credentialId, + eventType, + statusCode !== undefined ? statusCode : null, + message ? truncateString(message, 512) : null, + metadataJson + ); + } catch (error) { + console.warn('Failed to record AI usage event:', error.message); + } +} + +function updateCredentialUsageOnSuccess(credentialId, info = {}) { + if (!credentialId) { + return; + } + + try { + const row = db.prepare(` + SELECT usage_24h_count, usage_24h_reset_at, auto_disabled, auto_disabled_reason, auto_disabled_until, rate_limit_reset_at + FROM ai_credentials + WHERE id = ? + `).get(credentialId) || {}; + + const now = new Date(); + const nowIso = now.toISOString(); + + let usageCount = Number(row.usage_24h_count) || 0; + let usageResetDate = row.usage_24h_reset_at ? new Date(row.usage_24h_reset_at) : null; + + if (usageResetDate && usageResetDate <= now) { + usageCount = 0; + usageResetDate = null; + } + + let rateLimitResetIso = ensureIsoDate(info.rateLimitResetAt || row.rate_limit_reset_at); + + if (rateLimitResetIso) { + const rateReset = new Date(rateLimitResetIso); + if (rateReset <= now) { + rateLimitResetIso = null; + } + } + + if (!usageResetDate) { + if (rateLimitResetIso) { + usageResetDate = new Date(rateLimitResetIso); + } else { + usageResetDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); + } + } else if (rateLimitResetIso) { + const rateReset = new Date(rateLimitResetIso); + if (rateReset < usageResetDate) { + usageResetDate = rateReset; + } + } + + usageCount += 1; + + const shouldClearAutoDisable = row.auto_disabled === 1 + && (!row.auto_disabled_reason || String(row.auto_disabled_reason).startsWith('AUTO:')); + + const data = { + id: credentialId, + last_used_at: nowIso, + last_success_at: nowIso, + last_status_code: 200, + last_rate_limit_remaining: info.rateLimitRemaining || null, + rate_limit_reset_at: rateLimitResetIso, + usage_24h_count: usageCount, + usage_24h_reset_at: usageResetDate ? usageResetDate.toISOString() : null, + auto_disabled: shouldClearAutoDisable ? 0 : row.auto_disabled || 0, + auto_disabled_reason: shouldClearAutoDisable ? null : row.auto_disabled_reason || null, + auto_disabled_until: shouldClearAutoDisable ? null : row.auto_disabled_until || null + }; + + db.prepare(` + UPDATE ai_credentials + SET last_used_at = @last_used_at, + last_success_at = @last_success_at, + last_status_code = @last_status_code, + last_rate_limit_remaining = @last_rate_limit_remaining, + rate_limit_reset_at = @rate_limit_reset_at, + usage_24h_count = @usage_24h_count, + usage_24h_reset_at = @usage_24h_reset_at, + auto_disabled = @auto_disabled, + auto_disabled_reason = @auto_disabled_reason, + auto_disabled_until = @auto_disabled_until, + updated_at = CURRENT_TIMESTAMP + WHERE id = @id + `).run(data); + + recordAIUsageEvent(credentialId, 'success', { + statusCode: 200, + message: 'Kommentar erfolgreich generiert', + metadata: { + rateLimitRemaining: info.rateLimitRemaining || null, + rateLimitResetAt: rateLimitResetIso, + usage24hCount: usageCount + } + }); + } catch (error) { + console.warn('Failed to update credential success stats:', error.message); + } +} + +function updateCredentialUsageOnError(credentialId, error) { + if (!credentialId || !error) { + return { autoDisabled: false, autoDisabledUntil: null }; + } + + let decision = null; + try { + decision = determineAutoDisable(error); + + const now = new Date(); + const nowIso = now.toISOString(); + const rateLimitResetIso = ensureIsoDate(error.rateLimitResetAt); + const rateLimitRemaining = error.rateLimitRemaining !== undefined && error.rateLimitRemaining !== null + ? String(error.rateLimitRemaining) + : null; + + const updateData = { + id: credentialId, + last_used_at: nowIso, + last_error_message: truncateString(error.message || 'Unbekannter Fehler', 512), + last_error_at: nowIso, + last_status_code: error.status || error.statusCode || null, + last_rate_limit_remaining: rateLimitRemaining, + rate_limit_reset_at: rateLimitResetIso, + auto_disabled: decision ? 1 : null, + auto_disabled_reason: decision ? `AUTO:${decision.reason}` : null, + auto_disabled_until: decision ? decision.until.toISOString() : null + }; + + db.prepare(` + UPDATE ai_credentials + SET last_used_at = @last_used_at, + last_error_message = @last_error_message, + last_error_at = @last_error_at, + last_status_code = @last_status_code, + last_rate_limit_remaining = @last_rate_limit_remaining, + rate_limit_reset_at = @rate_limit_reset_at, + auto_disabled = CASE WHEN @auto_disabled IS NULL THEN auto_disabled ELSE @auto_disabled END, + auto_disabled_reason = CASE WHEN @auto_disabled_reason IS NULL THEN auto_disabled_reason ELSE @auto_disabled_reason END, + auto_disabled_until = CASE WHEN @auto_disabled_until IS NULL THEN auto_disabled_until ELSE @auto_disabled_until END, + updated_at = CURRENT_TIMESTAMP + WHERE id = @id + `).run(updateData); + + const eventMetadata = { + provider: error.provider || null, + retryAfterSeconds: error.retryAfterSeconds || null, + rateLimitResetAt: rateLimitResetIso, + rateLimitRemaining, + autoDisabled: Boolean(decision) + }; + + recordAIUsageEvent(credentialId, 'error', { + statusCode: error.status || error.statusCode || null, + message: error.message || 'Unbekannter Fehler', + metadata: eventMetadata + }); + + if (decision) { + recordAIUsageEvent(credentialId, 'auto_disabled', { + statusCode: error.status || error.statusCode || null, + message: decision.reason, + metadata: { + autoDisabledUntil: decision.until.toISOString(), + retryAfterSeconds: decision.seconds + } + }); + } + } catch (updateError) { + console.warn('Failed to update credential error stats:', updateError.message); + } + + return { + autoDisabled: Boolean(decision), + autoDisabledUntil: decision ? decision.until.toISOString() : null + }; +} + +function reactivateExpiredCredentials() { + try { + const nowIso = new Date().toISOString(); + const rows = db.prepare(` + SELECT id, auto_disabled_until + FROM ai_credentials + WHERE auto_disabled = 1 + AND auto_disabled_until IS NOT NULL + AND auto_disabled_until <= ? + `).all(nowIso); + + for (const row of rows) { + db.prepare(` + UPDATE ai_credentials + SET auto_disabled = 0, + auto_disabled_reason = NULL, + auto_disabled_until = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(row.id); + + recordAIUsageEvent(row.id, 'auto_reenabled', { + message: 'Automatisch wieder aktiviert', + metadata: { + previousUntil: row.auto_disabled_until + } + }); + } + } catch (error) { + console.warn('Failed to reactivate credentials:', error.message); + } +} + +const CREDENTIAL_SELECT_BASE = ` + SELECT + c.id, + c.name, + c.provider, + c.model, + c.base_url, + c.is_active, + c.priority, + c.auto_disabled, + c.auto_disabled_reason, + c.auto_disabled_until, + c.last_used_at, + c.last_success_at, + c.last_error_message, + c.last_error_at, + c.last_status_code, + c.last_rate_limit_remaining, + c.rate_limit_reset_at, + c.usage_24h_count, + c.usage_24h_reset_at, + c.created_at, + c.updated_at, + e.event_type AS latest_event_type, + e.status_code AS latest_event_status_code, + e.message AS latest_event_message, + e.metadata AS latest_event_metadata, + e.created_at AS latest_event_at + FROM ai_credentials c + LEFT JOIN ( + SELECT e1.* + FROM ai_usage_events e1 + INNER JOIN ( + SELECT credential_id, MAX(id) AS latest_id + FROM ai_usage_events + GROUP BY credential_id + ) latest ON latest.credential_id = e1.credential_id AND latest.latest_id = e1.id + ) e ON e.credential_id = c.id +`; + +function fetchCredentialRows(options = {}) { + const { where = '', params = [], orderBy = 'ORDER BY c.priority ASC, c.id ASC' } = options; + const query = `${CREDENTIAL_SELECT_BASE} ${where ? where : ''} ${orderBy}`; + return db.prepare(query).all(...params); +} + +function formatCredentialRow(row) { + if (!row) { + return null; + } + + const now = Date.now(); + const formatted = { ...row }; + + formatted.is_active = Number(row.is_active) === 1 ? 1 : 0; + formatted.auto_disabled = Number(row.auto_disabled) === 1; + + let cooldownSeconds = null; + if (row.auto_disabled_until) { + const until = Date.parse(row.auto_disabled_until); + if (!Number.isNaN(until)) { + const diffSeconds = Math.round((until - now) / 1000); + cooldownSeconds = diffSeconds > 0 ? diffSeconds : 0; + } + } + + formatted.cooldown_remaining_seconds = cooldownSeconds; + + if (row.latest_event_type) { + let metadata = null; + if (row.latest_event_metadata) { + try { + metadata = JSON.parse(row.latest_event_metadata); + } catch (error) { + metadata = row.latest_event_metadata; + } + } + formatted.latest_event = { + type: row.latest_event_type, + status_code: row.latest_event_status_code, + message: row.latest_event_message, + metadata, + created_at: row.latest_event_at + }; + } else { + formatted.latest_event = null; + } + + formatted.status = formatted.is_active + ? (formatted.auto_disabled ? 'cooldown' : 'active') + : 'inactive'; + + delete formatted.latest_event_type; + delete formatted.latest_event_status_code; + delete formatted.latest_event_message; + delete formatted.latest_event_metadata; + delete formatted.latest_event_at; + + return formatted; +} + +function getAllCredentialsFormatted() { + return fetchCredentialRows().map(formatCredentialRow); +} + +function getFormattedCredentialById(id) { + const row = fetchCredentialRows({ where: 'WHERE c.id = ?', params: [id], orderBy: '' }); + if (!row || !row.length) { + return null; + } + return formatCredentialRow(row[0]); +} + function cleanupExpiredSearchPosts() { try { const threshold = `-${SEARCH_POST_RETENTION_DAYS} day`; @@ -1463,7 +2041,8 @@ app.delete('/api/posts/:postId', (req, res) => { // AI Credentials endpoints app.get('/api/ai-credentials', (req, res) => { try { - const credentials = db.prepare('SELECT id, name, provider, model, base_url, is_active, priority, created_at, updated_at FROM ai_credentials ORDER BY priority ASC, id ASC').all(); + reactivateExpiredCredentials(); + const credentials = getAllCredentialsFormatted(); res.json(credentials); } catch (error) { res.status(500).json({ error: error.message }); @@ -1511,7 +2090,7 @@ app.post('/api/ai-credentials', (req, res) => { normalizedBaseUrl || null ); - const credential = db.prepare('SELECT id, name, provider, model, base_url, is_active, created_at, updated_at FROM ai_credentials WHERE id = ?').get(result.lastInsertRowid); + const credential = getFormattedCredentialById(result.lastInsertRowid); res.json(credential); } catch (error) { res.status(500).json({ error: error.message }); @@ -1571,7 +2150,7 @@ app.put('/api/ai-credentials/:id', (req, res) => { credentialId ); - const credential = db.prepare('SELECT id, name, provider, model, base_url, is_active, created_at, updated_at FROM ai_credentials WHERE id = ?').get(credentialId); + const credential = getFormattedCredentialById(credentialId); res.json(credential); } catch (error) { res.status(500).json({ error: error.message }); @@ -1587,13 +2166,19 @@ app.patch('/api/ai-credentials/:id', (req, res) => { return res.status(400).json({ error: 'is_active is required' }); } + const isActiveInt = is_active ? 1 : 0; + db.prepare(` UPDATE ai_credentials - SET is_active = ?, updated_at = CURRENT_TIMESTAMP + SET is_active = ?, + updated_at = CURRENT_TIMESTAMP, + auto_disabled = CASE WHEN ? = 1 THEN 0 ELSE auto_disabled END, + auto_disabled_reason = CASE WHEN ? = 1 THEN NULL ELSE auto_disabled_reason END, + auto_disabled_until = CASE WHEN ? = 1 THEN NULL ELSE auto_disabled_until END WHERE id = ? - `).run(is_active, id); + `).run(isActiveInt, isActiveInt, isActiveInt, isActiveInt, id); - const credential = db.prepare('SELECT id, name, provider, model, base_url, is_active, priority, created_at, updated_at FROM ai_credentials WHERE id = ?').get(id); + const credential = getFormattedCredentialById(id); res.json(credential); } catch (error) { res.status(500).json({ error: error.message }); @@ -1613,7 +2198,7 @@ app.post('/api/ai-credentials/reorder', (req, res) => { db.prepare('UPDATE ai_credentials SET priority = ? WHERE id = ?').run(index, id); }); - const credentials = db.prepare('SELECT id, name, provider, model, base_url, is_active, priority, created_at, updated_at FROM ai_credentials ORDER BY priority ASC, id ASC').all(); + const credentials = getAllCredentialsFormatted(); res.json(credentials); } catch (error) { res.status(500).json({ error: error.message }); @@ -1655,7 +2240,7 @@ app.get('/api/ai-settings', (req, res) => { // Get active credential if set let activeCredential = null; if (settings.active_credential_id) { - activeCredential = db.prepare('SELECT id, name, provider, model FROM ai_credentials WHERE id = ?').get(settings.active_credential_id); + activeCredential = getFormattedCredentialById(settings.active_credential_id); } res.json({ @@ -1690,7 +2275,7 @@ app.put('/api/ai-settings', (req, res) => { let activeCredential = null; if (updated.active_credential_id) { - activeCredential = db.prepare('SELECT id, name, provider, model FROM ai_credentials WHERE id = ?').get(updated.active_credential_id); + activeCredential = getFormattedCredentialById(updated.active_credential_id); } res.json({ @@ -1719,99 +2304,205 @@ async function tryGenerateComment(credential, promptPrefix, postText) { const model = credential.model; let comment = ''; + let lastResponse = null; - if (provider === 'gemini') { - // Gemini API - const modelName = model || 'gemini-2.0-flash-exp'; - const prompt = promptPrefix + postText; + try { + if (provider === 'gemini') { + const modelName = model || 'gemini-2.0-flash-exp'; + const prompt = promptPrefix + postText; - const response = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - contents: [{ - parts: [{ text: prompt }] - }] - }) + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ + parts: [{ text: prompt }] + }] + }) + } + ); + + lastResponse = response; + + if (!response.ok) { + let errorPayload = null; + let message = response.statusText; + try { + errorPayload = await response.json(); + message = errorPayload?.error?.message || message; + } catch (jsonError) { + try { + const textBody = await response.text(); + if (textBody) { + message = textBody; + } + } catch (textError) { + // ignore + } + } + + const rateInfo = extractRateLimitInfo(response, provider); + const error = new Error(`Gemini API error: ${message}`); + error.status = response.status; + error.provider = provider; + error.apiError = errorPayload; + if (rateInfo.retryAfterSeconds !== undefined) { + error.retryAfterSeconds = rateInfo.retryAfterSeconds; + } + if (rateInfo.rateLimitResetAt) { + error.rateLimitResetAt = rateInfo.rateLimitResetAt; + } + if (rateInfo.rateLimitRemaining !== undefined) { + error.rateLimitRemaining = rateInfo.rateLimitRemaining; + } + error.rateLimitHeaders = rateInfo.headers; + throw error; } - ); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(`Gemini API error: ${errorData.error?.message || response.statusText}`); + const data = await response.json(); + comment = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; + + } else if (provider === 'openai') { + const modelName = model || 'gpt-3.5-turbo'; + const prompt = promptPrefix + postText; + + const baseUrl = (credential.base_url || 'https://api.openai.com/v1').trim().replace(/\/+$/, ''); + const endpoint = baseUrl.endsWith('/chat/completions') ? baseUrl : `${baseUrl}/chat/completions`; + + const headers = { + 'Content-Type': 'application/json' + }; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const response = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify({ + model: modelName, + messages: [{ role: 'user', content: prompt }], + max_tokens: 150 + }) + }); + + lastResponse = response; + + if (!response.ok) { + let errorPayload = null; + let message = response.statusText; + try { + errorPayload = await response.json(); + message = errorPayload?.error?.message || message; + } catch (jsonError) { + try { + const textBody = await response.text(); + if (textBody) { + message = textBody; + } + } catch (textError) { + // ignore + } + } + + const rateInfo = extractRateLimitInfo(response, provider); + const error = new Error(`OpenAI API error: ${message}`); + error.status = response.status; + error.provider = provider; + error.apiError = errorPayload; + if (rateInfo.retryAfterSeconds !== undefined) { + error.retryAfterSeconds = rateInfo.retryAfterSeconds; + } + if (rateInfo.rateLimitResetAt) { + error.rateLimitResetAt = rateInfo.rateLimitResetAt; + } + if (rateInfo.rateLimitRemaining !== undefined) { + error.rateLimitRemaining = rateInfo.rateLimitRemaining; + } + error.rateLimitHeaders = rateInfo.headers; + throw error; + } + + const data = await response.json(); + comment = data.choices?.[0]?.message?.content || ''; + + } else if (provider === 'claude') { + const modelName = model || 'claude-3-5-haiku-20241022'; + const prompt = promptPrefix + postText; + + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model: modelName, + max_tokens: 150, + messages: [{ role: 'user', content: prompt }] + }) + }); + + lastResponse = response; + + if (!response.ok) { + let errorPayload = null; + let message = response.statusText; + try { + errorPayload = await response.json(); + message = errorPayload?.error?.message || message; + } catch (jsonError) { + try { + const textBody = await response.text(); + if (textBody) { + message = textBody; + } + } catch (textError) { + // ignore + } + } + + const rateInfo = extractRateLimitInfo(response, provider); + const error = new Error(`Claude API error: ${message}`); + error.status = response.status; + error.provider = provider; + error.apiError = errorPayload; + if (rateInfo.retryAfterSeconds !== undefined) { + error.retryAfterSeconds = rateInfo.retryAfterSeconds; + } + if (rateInfo.rateLimitResetAt) { + error.rateLimitResetAt = rateInfo.rateLimitResetAt; + } + if (rateInfo.rateLimitRemaining !== undefined) { + error.rateLimitRemaining = rateInfo.rateLimitRemaining; + } + error.rateLimitHeaders = rateInfo.headers; + throw error; + } + + const data = await response.json(); + comment = data.content?.[0]?.text || ''; + + } else { + throw new Error(`Unsupported AI provider: ${provider}`); } - const data = await response.json(); - comment = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; + const rateInfo = extractRateLimitInfo(lastResponse, provider); + rateInfo.status = lastResponse ? lastResponse.status : null; - } else if (provider === 'openai') { - // OpenAI/ChatGPT API - const modelName = model || 'gpt-3.5-turbo'; - const prompt = promptPrefix + postText; - - const baseUrl = (credential.base_url || 'https://api.openai.com/v1').trim().replace(/\/+$/, ''); - const endpoint = baseUrl.endsWith('/chat/completions') ? baseUrl : `${baseUrl}/chat/completions`; - - const headers = { - 'Content-Type': 'application/json' + return { + comment: sanitizeAIComment(comment), + rateInfo }; - if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey}`; + } catch (error) { + if (error && !error.provider) { + error.provider = provider; } - - const response = await fetch(endpoint, { - method: 'POST', - headers, - body: JSON.stringify({ - model: modelName, - messages: [{ role: 'user', content: prompt }], - max_tokens: 150 - }) - }); - - if (!response.ok) { - const errorData = await response.json(); - const message = errorData.error?.message || response.statusText; - throw new Error(`OpenAI API error: ${message}`); - } - - const data = await response.json(); - comment = data.choices?.[0]?.message?.content || ''; - - } else if (provider === 'claude') { - // Anthropic Claude API - const modelName = model || 'claude-3-5-haiku-20241022'; - const prompt = promptPrefix + postText; - - const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01' - }, - body: JSON.stringify({ - model: modelName, - max_tokens: 150, - messages: [{ role: 'user', content: prompt }] - }) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(`Claude API error: ${errorData.error?.message || response.statusText}`); - } - - const data = await response.json(); - comment = data.content?.[0]?.text || ''; - - } else { - throw new Error(`Unsupported AI provider: ${provider}`); + throw error; } - - return sanitizeAIComment(comment); } app.post('/api/ai/generate-comment', async (req, res) => { @@ -1828,8 +2519,16 @@ app.post('/api/ai/generate-comment', async (req, res) => { return res.status(400).json({ error: 'AI comment generation is not enabled' }); } + reactivateExpiredCredentials(); + // Get all active credentials, ordered by priority - const credentials = db.prepare('SELECT * FROM ai_credentials WHERE is_active = 1 ORDER BY priority ASC, id ASC').all(); + const credentials = db.prepare(` + SELECT * + FROM ai_credentials + WHERE is_active = 1 + AND COALESCE(auto_disabled, 0) = 0 + ORDER BY priority ASC, id ASC + `).all(); if (!credentials || credentials.length === 0) { return res.status(400).json({ error: 'No active AI credentials available' }); @@ -1863,25 +2562,59 @@ app.post('/api/ai/generate-comment', async (req, res) => { // Try each active credential until one succeeds let lastError = null; + const attemptDetails = []; for (const credential of orderedCredentials) { try { console.log(`Trying credential: ${credential.name} (ID: ${credential.id})`); - const comment = await tryGenerateComment(credential, promptPrefix, postText); + const { comment, rateInfo } = await tryGenerateComment(credential, promptPrefix, postText); console.log(`Success with credential: ${credential.name}`); - return res.json({ comment, usedCredential: credential.name }); + + updateCredentialUsageOnSuccess(credential.id, rateInfo || {}); + + attemptDetails.push({ + credentialId: credential.id, + credentialName: credential.name, + status: 'success', + rateLimitRemaining: rateInfo?.rateLimitRemaining ?? null, + rateLimitResetAt: rateInfo?.rateLimitResetAt ?? null + }); + + return res.json({ + comment, + usedCredential: credential.name, + usedCredentialId: credential.id, + attempts: attemptDetails, + rateLimitInfo: rateInfo || null + }); } catch (error) { console.error(`Failed with credential ${credential.name}:`, error.message); lastError = error; + const errorUpdate = updateCredentialUsageOnError(credential.id, error); + attemptDetails.push({ + credentialId: credential.id, + credentialName: credential.name, + status: 'error', + message: error.message, + statusCode: error.status || error.statusCode || null, + autoDisabled: Boolean(errorUpdate.autoDisabled), + autoDisabledUntil: errorUpdate.autoDisabledUntil || null + }); // Continue to next credential } } // If we get here, all credentials failed - throw lastError || new Error('All AI credentials failed'); + const finalError = lastError || new Error('All AI credentials failed'); + finalError.attempts = attemptDetails; + throw finalError; } catch (error) { console.error('AI comment generation error:', error); - res.status(500).json({ error: error.message }); + if (error && error.attempts) { + res.status(500).json({ error: error.message, attempts: error.attempts }); + } else { + res.status(500).json({ error: error.message }); + } } }); diff --git a/extension/content.js b/extension/content.js index 6de4ed2..1ec7b70 100644 --- a/extension/content.js +++ b/extension/content.js @@ -17,6 +17,54 @@ const FEED_HOME_PATHS = ['/', '/home.php']; const sessionSearchRecordedUrls = new Set(); const sessionSearchInfoCache = new Map(); +const trackerElementsByPost = new WeakMap(); + +function getTrackerElementForPost(postElement) { + if (!postElement) { + return null; + } + + const tracker = trackerElementsByPost.get(postElement); + if (tracker && tracker.isConnected) { + return tracker; + } + + if (tracker) { + trackerElementsByPost.delete(postElement); + } + + return null; +} + +function setTrackerElementForPost(postElement, trackerElement) { + if (!postElement) { + return; + } + + if (trackerElement && trackerElement.isConnected) { + trackerElementsByPost.set(postElement, trackerElement); + } else { + trackerElementsByPost.delete(postElement); + } +} + +function clearTrackerElementForPost(postElement, trackerElement = null) { + if (!postElement) { + return; + } + + if (!trackerElementsByPost.has(postElement)) { + return; + } + + const current = trackerElementsByPost.get(postElement); + if (trackerElement && current && current !== trackerElement) { + return; + } + + trackerElementsByPost.delete(postElement); +} + const AI_CREDENTIAL_CACHE_TTL = 60 * 1000; // 1 minute cache const aiCredentialCache = { data: null, @@ -1600,9 +1648,22 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = // Normalize to top-level post container if nested element passed postElement = ensurePrimaryPostElement(postElement); - const existingUI = postElement.querySelector('.fb-tracker-ui'); + let existingUI = getTrackerElementForPost(postElement); + if (!existingUI) { + existingUI = postElement.querySelector('.fb-tracker-ui'); + if (existingUI && existingUI.isConnected) { + setTrackerElementForPost(postElement, existingUI); + } + } + + if (existingUI && !existingUI.isConnected) { + clearTrackerElementForPost(postElement, existingUI); + existingUI = null; + } + if (existingUI) { console.log('[FB Tracker] Post #' + postNum + ' - UI already exists on normalized element'); + postElement.setAttribute(PROCESSED_ATTR, '1'); return; } @@ -1617,6 +1678,7 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = if (!postUrlData.url) { console.log('[FB Tracker] Post #' + postNum + ' - No URL found:', postElement); postElement.removeAttribute(PROCESSED_ATTR); + clearTrackerElementForPost(postElement); return; } @@ -1628,6 +1690,7 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = if (existingEntry && existingEntry.element && existingEntry.element !== postElement) { if (document.body.contains(existingEntry.element)) { existingEntry.element.removeAttribute(PROCESSED_ATTR); + clearTrackerElementForPost(existingEntry.element); } else { processedPostUrls.delete(encodedUrl); } @@ -2149,6 +2212,7 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = : null, hidden: false }); + setTrackerElementForPost(postElement, container); } // Monitor if the UI gets removed and re-insert it @@ -2163,6 +2227,9 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = } else if (postElement.parentElement) { postElement.parentElement.appendChild(container); } + if (container.isConnected) { + setTrackerElementForPost(postElement, container); + } } }); @@ -2271,10 +2338,29 @@ function findPosts() { continue; } - const existingTracker = container.querySelector('.fb-tracker-ui'); + let existingTracker = getTrackerElementForPost(container); + if (!existingTracker) { + existingTracker = container.querySelector('.fb-tracker-ui'); + if (existingTracker && existingTracker.isConnected) { + setTrackerElementForPost(container, existingTracker); + } + } + + if (existingTracker && !existingTracker.isConnected) { + clearTrackerElementForPost(container, existingTracker); + existingTracker = null; + } + const alreadyProcessed = container.getAttribute(PROCESSED_ATTR) === '1'; const trackerDialogRoot = existingTracker ? existingTracker.closest(DIALOG_ROOT_SELECTOR) : null; - const trackerInSameDialog = Boolean(existingTracker && existingTracker.isConnected && trackerDialogRoot === dialogRoot); + const trackerInSameDialog = Boolean( + existingTracker + && existingTracker.isConnected + && ( + trackerDialogRoot === dialogRoot + || (dialogRoot && dialogRoot.contains(existingTracker)) + ) + ); if (isInDialog) { if (trackerInSameDialog) { @@ -2284,6 +2370,7 @@ function findPosts() { if (existingTracker && existingTracker.isConnected) { existingTracker.remove(); + clearTrackerElementForPost(container, existingTracker); } if (alreadyProcessed) { @@ -2477,6 +2564,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { const existingUI = postContainer.querySelector('.fb-tracker-ui'); if (existingUI) { existingUI.remove(); + clearTrackerElementForPost(postContainer, existingUI); console.log('[FB Tracker] Removed existing UI'); } @@ -2805,12 +2893,16 @@ function findAndClickCommentButton(postElement) { /** * Find comment input field on current page */ -function findCommentInput(postElement) { - if (!postElement) { +function findCommentInput(postElement, options = {}) { + const { + preferredRoot = null, + includeParents = true + } = options; + + if (!postElement && !preferredRoot) { return null; } - // Try multiple selectors for comment input const selectors = [ 'div[contenteditable="true"][role="textbox"]', 'div[aria-label*="Kommentar"][contenteditable="true"]', @@ -2818,24 +2910,48 @@ function findCommentInput(postElement) { 'div[aria-label*="Write a comment"][contenteditable="true"]' ]; - for (const selector of selectors) { - const input = postElement.querySelector(selector); + const searchInRoot = (root) => { + if (!root) { + return null; + } + for (const selector of selectors) { + const input = root.querySelector(selector); + if (input && isElementVisible(input)) { + return input; + } + } + return null; + }; + + const roots = []; + + if (postElement) { + roots.push(postElement); + } + + if (preferredRoot && preferredRoot.isConnected && !roots.includes(preferredRoot)) { + roots.push(preferredRoot); + } + + for (const root of roots) { + const input = searchInRoot(root); if (input) { return input; } } - // Search in parent containers - let parent = postElement; - for (let i = 0; i < 3; i++) { - parent = parent.parentElement; - if (!parent) break; - - for (const selector of selectors) { - const input = parent.querySelector(selector); + if (includeParents && postElement) { + let parent = postElement.parentElement; + for (let i = 0; i < 3 && parent; i++) { + if (preferredRoot && !preferredRoot.contains(parent)) { + parent = parent.parentElement; + continue; + } + const input = searchInRoot(parent); if (input) { return input; } + parent = parent.parentElement; } } @@ -2879,11 +2995,15 @@ async function waitForCommentInput(postElement, options = {}) { encodedPostUrl = null, timeout = 6000, interval = 200, - context = null + context = null, + preferredRoot: rawPreferredRoot = null } = options; const deadline = Date.now() + Math.max(timeout, 0); let attempts = 0; + const preferredRoot = rawPreferredRoot && rawPreferredRoot.isConnected + ? rawPreferredRoot + : null; const findByEncodedUrl = () => { if (context && context.cancelled) { @@ -2900,15 +3020,19 @@ async function waitForCommentInput(postElement, options = {}) { continue; } + if (preferredRoot && !preferredRoot.contains(tracker)) { + continue; + } + const trackerContainer = tracker.closest('div[aria-posinset], article[role="article"], article'); if (trackerContainer) { - const input = findCommentInput(trackerContainer); + const input = findCommentInput(trackerContainer, { preferredRoot }); if (isElementVisible(input)) { return input; } } - const dialogRoot = tracker.closest(DIALOG_ROOT_SELECTOR); + const dialogRoot = preferredRoot || tracker.closest(DIALOG_ROOT_SELECTOR); if (dialogRoot) { const dialogInput = dialogRoot.querySelector('div[contenteditable="true"][role="textbox"]'); if (isElementVisible(dialogInput)) { @@ -2927,7 +3051,7 @@ async function waitForCommentInput(postElement, options = {}) { attempts++; - let input = findCommentInput(postElement); + let input = findCommentInput(postElement, { preferredRoot }); if (isElementVisible(input)) { if (attempts > 1) { console.log('[FB Tracker] Comment input located after', attempts, 'attempts (post context)'); @@ -2943,7 +3067,8 @@ async function waitForCommentInput(postElement, options = {}) { return input; } - const dialogRootFromPost = postElement ? postElement.closest(DIALOG_ROOT_SELECTOR) : null; + const dialogRootFromPost = preferredRoot + || (postElement ? postElement.closest(DIALOG_ROOT_SELECTOR) : null); if (dialogRootFromPost) { const dialogInput = dialogRootFromPost.querySelector('div[contenteditable="true"][role="textbox"]'); if (isElementVisible(dialogInput)) { @@ -2954,18 +3079,32 @@ async function waitForCommentInput(postElement, options = {}) { } } - const fallbackDialog = document.querySelector(DIALOG_ROOT_SELECTOR); - if (fallbackDialog && fallbackDialog !== dialogRootFromPost) { - const dialogInput = fallbackDialog.querySelector('div[contenteditable="true"][role="textbox"]'); - if (isElementVisible(dialogInput)) { - if (attempts > 1) { - console.log('[FB Tracker] Comment input located after', attempts, 'attempts (fallback dialog)'); + if (!preferredRoot) { + const fallbackDialog = document.querySelector(DIALOG_ROOT_SELECTOR); + if (fallbackDialog && fallbackDialog !== dialogRootFromPost) { + const dialogInput = fallbackDialog.querySelector('div[contenteditable="true"][role="textbox"]'); + if (isElementVisible(dialogInput)) { + if (attempts > 1) { + console.log('[FB Tracker] Comment input located after', attempts, 'attempts (fallback dialog)'); + } + return dialogInput; } - return dialogInput; } } const globalInputs = Array.from(document.querySelectorAll('div[contenteditable="true"][role="textbox"]')).filter(isElementVisible); + if (preferredRoot) { + const scopedInputs = globalInputs.filter(input => preferredRoot.contains(input)); + if (scopedInputs.length > 0) { + const lastInput = scopedInputs[scopedInputs.length - 1]; + if (lastInput) { + if (attempts > 1) { + console.log('[FB Tracker] Comment input located after', attempts, 'attempts (preferred root fallback)'); + } + return lastInput; + } + } + } if (globalInputs.length > 0) { const lastInput = globalInputs[globalInputs.length - 1]; if (lastInput) { @@ -3235,7 +3374,11 @@ async function addAICommentButton(container, postElement) { }); button.addEventListener('pointerdown', () => { - cacheSelectionForPost(postElement); + const contextElement = container.closest('div[aria-posinset], article[role="article"], article'); + const normalized = contextElement ? ensurePrimaryPostElement(contextElement) : null; + const fallbackNormalized = postElement ? ensurePrimaryPostElement(postElement) : null; + const target = normalized || fallbackNormalized || contextElement || postElement || container; + cacheSelectionForPost(target); }); button.dataset.aiState = 'idle'; @@ -3469,9 +3612,38 @@ async function addAICommentButton(container, postElement) { button.textContent = '⏳ Generiere...'; try { + const contextCandidate = container.closest('div[aria-posinset], article[role="article"], article'); + const normalizedContext = contextCandidate ? ensurePrimaryPostElement(contextCandidate) : null; + const fallbackContext = postElement ? ensurePrimaryPostElement(postElement) : null; + const postContext = normalizedContext || fallbackContext || contextCandidate || postElement || container; + + const selectionKeys = []; + if (postContext) { + selectionKeys.push(postContext); + } + if (postElement && postElement !== postContext) { + selectionKeys.push(postElement); + } + if (contextCandidate && contextCandidate !== postContext && contextCandidate !== postElement) { + selectionKeys.push(contextCandidate); + } + + const resolveRecentSelection = () => { + for (const key of selectionKeys) { + if (!key) { + continue; + } + const entry = postSelectionCache.get(key); + if (entry && Date.now() - entry.timestamp < LAST_SELECTION_MAX_AGE) { + return entry; + } + } + return null; + }; + let postText = ''; - const cachedSelection = postSelectionCache.get(postElement); - if (cachedSelection && Date.now() - cachedSelection.timestamp < LAST_SELECTION_MAX_AGE) { + const cachedSelection = resolveRecentSelection(); + if (cachedSelection) { console.log('[FB Tracker] Using cached selection text'); postText = cachedSelection.text; } @@ -3479,22 +3651,25 @@ async function addAICommentButton(container, postElement) { throwIfCancelled(); if (!postText) { - postText = getSelectedTextFromPost(postElement); - if (postText) { - console.log('[FB Tracker] Using active selection text'); + const selectionSource = postContext || postElement; + if (selectionSource) { + postText = getSelectedTextFromPost(selectionSource); + if (postText) { + console.log('[FB Tracker] Using active selection text'); + } } } if (!postText) { - const latestCached = postSelectionCache.get(postElement); - if (latestCached && Date.now() - latestCached.timestamp < LAST_SELECTION_MAX_AGE) { + const latestCached = resolveRecentSelection(); + if (latestCached) { console.log('[FB Tracker] Using latest cached selection after check'); postText = latestCached.text; } } if (!postText) { - postText = extractPostText(postElement); + postText = extractPostText(postContext); if (postText) { console.log('[FB Tracker] Fallback to DOM extraction'); } @@ -3504,7 +3679,11 @@ async function addAICommentButton(container, postElement) { throw new Error('Konnte Post-Text nicht extrahieren'); } - postSelectionCache.delete(postElement); + selectionKeys.forEach((key) => { + if (key) { + postSelectionCache.delete(key); + } + }); throwIfCancelled(); @@ -3523,32 +3702,45 @@ async function addAICommentButton(container, postElement) { console.log('[FB Tracker] Generated comment:', comment); - let commentInput = findCommentInput(postElement); + const dialogRoot = container.closest(DIALOG_ROOT_SELECTOR) + || (postContext && postContext.closest(DIALOG_ROOT_SELECTOR)); + + let commentInput = findCommentInput(postContext, { preferredRoot: dialogRoot }); let waitedForInput = false; if (!commentInput) { console.log('[FB Tracker] Comment input not found, trying to click comment button'); - const buttonClicked = findAndClickCommentButton(postElement); + let buttonClicked = findAndClickCommentButton(postContext); + + if (!buttonClicked && dialogRoot) { + const dialogCommentButton = dialogRoot.querySelector('[data-ad-rendering-role="comment_button"], [aria-label*="Kommentieren"], [aria-label*="Comment"]'); + if (dialogCommentButton && isElementVisible(dialogCommentButton)) { + dialogCommentButton.click(); + buttonClicked = true; + } + } updateProcessingText(buttonClicked ? '⏳ Öffne Kommentare...' : '⏳ Suche Kommentarfeld...'); waitedForInput = true; - commentInput = await waitForCommentInput(postElement, { + commentInput = await waitForCommentInput(postContext, { encodedPostUrl, timeout: buttonClicked ? 8000 : 5000, interval: 250, - context: aiContext + context: aiContext, + preferredRoot: dialogRoot }); } if (!commentInput && !waitedForInput) { updateProcessingText('⏳ Suche Kommentarfeld...'); waitedForInput = true; - commentInput = await waitForCommentInput(postElement, { + commentInput = await waitForCommentInput(postContext, { encodedPostUrl, timeout: 4000, interval: 200, - context: aiContext + context: aiContext, + preferredRoot: dialogRoot }); } diff --git a/web/settings.css b/web/settings.css index 5831a3f..9c52bb3 100644 --- a/web/settings.css +++ b/web/settings.css @@ -173,6 +173,68 @@ color: #65676b; } +.credential-item__status { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.credential-status { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + background: #f0f2f5; + color: #1c1e21; + border: 1px solid rgba(0, 0, 0, 0.05); +} + +.credential-status.status-badge--success { + background: rgba(66, 183, 42, 0.12); + color: #2d7a32; + border-color: rgba(66, 183, 42, 0.2); +} + +.credential-status.status-badge--warning { + background: rgba(252, 160, 0, 0.12); + color: #9f580a; + border-color: rgba(252, 160, 0, 0.2); +} + +.credential-status.status-badge--danger { + background: rgba(231, 76, 60, 0.12); + color: #a5281b; + border-color: rgba(231, 76, 60, 0.2); +} + +.credential-status.status-badge--info { + background: rgba(24, 119, 242, 0.1); + color: #1659c7; + border-color: rgba(24, 119, 242, 0.2); +} + +.credential-status.status-badge--neutral { + background: rgba(101, 103, 107, 0.12); + color: #42464b; + border-color: rgba(101, 103, 107, 0.2); +} + +.credential-status.status-badge--muted { + background: rgba(148, 153, 160, 0.12); + color: #4b4f56; + border-color: rgba(148, 153, 160, 0.2); +} + +.credential-item__meta { + margin-top: 6px; + font-size: 12px; + color: #65676b; + line-height: 1.4; +} + .credential-item__actions { display: flex; gap: 8px; diff --git a/web/settings.js b/web/settings.js index 2063940..5378e3b 100644 --- a/web/settings.js +++ b/web/settings.js @@ -123,6 +123,134 @@ async function loadSettings() { 'Schreibe einen freundlichen, authentischen Kommentar auf Deutsch zu folgendem Facebook-Post. Der Kommentar soll natürlich wirken und maximal 2-3 Sätze lang sein:\n\n'; } +function shorten(text, maxLength = 80) { + if (typeof text !== 'string') { + return ''; + } + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, maxLength - 3)}...`; +} + +function escapeHtmlAttr(text) { + return escapeHtml(text || '').replace(/"/g, '"'); +} + +function formatTimeLabel(iso) { + if (!iso) return ''; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); +} + +function formatRelativePast(iso) { + if (!iso) return ''; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + const diffMs = Date.now() - date.getTime(); + if (diffMs < 0) return 'gerade eben'; + const diffMinutes = Math.round(diffMs / 60000); + if (diffMinutes <= 1) return 'gerade eben'; + if (diffMinutes < 60) return `vor ${diffMinutes} Min`; + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) return `vor ${diffHours} Std`; + const diffDays = Math.round(diffHours / 24); + if (diffDays === 1) return 'gestern'; + if (diffDays < 7) return `vor ${diffDays} Tagen`; + return date.toLocaleDateString('de-DE'); +} + +function formatRelativeFuture(iso) { + if (!iso) return ''; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + const diffMs = date.getTime() - Date.now(); + if (diffMs <= 0) return 'gleich'; + const diffMinutes = Math.round(diffMs / 60000); + if (diffMinutes < 1) return 'gleich'; + if (diffMinutes < 60) return `in ${diffMinutes} Min`; + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) return `in ${diffHours} Std`; + return date.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); +} + +function buildCredentialBadges(credential) { + const badges = []; + if (!credential.is_active) { + badges.push({ + label: 'Deaktiviert', + className: 'status-badge--muted', + title: 'Dieser Login ist derzeit deaktiviert' + }); + } else if (credential.auto_disabled) { + const untilText = credential.auto_disabled_until ? formatRelativeFuture(credential.auto_disabled_until) : 'läuft'; + const untilTime = credential.auto_disabled_until ? formatTimeLabel(credential.auto_disabled_until) : ''; + const reason = credential.auto_disabled_reason ? credential.auto_disabled_reason.replace(/^AUTO:/, '').trim() : 'Automatisch deaktiviert'; + badges.push({ + label: `Cooldown ${untilText}${untilTime ? ` (${untilTime})` : ''}`.trim(), + className: 'status-badge--warning', + title: reason || 'Automatisch deaktiviert' + }); + } else { + badges.push({ + label: 'Aktiv', + className: 'status-badge--success', + title: 'Login ist aktiv' + }); + } + + if (credential.last_error_message) { + badges.push({ + label: `Fehler ${formatRelativePast(credential.last_error_at)}`.trim(), + className: 'status-badge--danger', + title: credential.last_error_message + }); + } + + if (credential.usage_24h_count) { + const resetHint = credential.usage_24h_reset_at ? `Reset ${formatRelativeFuture(credential.usage_24h_reset_at)}` : '24h Nutzung'; + badges.push({ + label: `24h: ${credential.usage_24h_count}`, + className: 'status-badge--info', + title: resetHint + }); + } + + if (credential.last_rate_limit_remaining) { + badges.push({ + label: `Limit: ${credential.last_rate_limit_remaining}`, + className: 'status-badge--neutral', + title: 'Letzter „rate limit remaining“-Wert' + }); + } + + return badges; +} + +function buildCredentialMetaLines(credential) { + const lines = []; + if (credential.last_success_at) { + lines.push(`Zuletzt erfolgreich: ${formatRelativePast(credential.last_success_at)}`); + } + if (!credential.last_success_at && credential.last_used_at) { + lines.push(`Zuletzt genutzt: ${formatRelativePast(credential.last_used_at)}`); + } + if (credential.rate_limit_reset_at && !credential.auto_disabled) { + lines.push(`Limit-Reset ${formatRelativeFuture(credential.rate_limit_reset_at)}`); + } + if (credential.latest_event && credential.latest_event.type) { + const typeLabel = credential.latest_event.type.replace(/_/g, ' '); + const eventTime = credential.latest_event.created_at ? formatRelativePast(credential.latest_event.created_at) : ''; + const message = credential.latest_event.message ? shorten(credential.latest_event.message, 90) : ''; + const parts = [`Letztes Event (${typeLabel})`]; + if (eventTime) parts.push(eventTime); + if (message) parts.push(`– ${message}`); + lines.push(parts.join(' ')); + } + return lines; +} + function renderCredentials() { const list = document.getElementById('credentialsList'); if (!credentials.length) { @@ -133,6 +261,14 @@ function renderCredentials() { const providerName = escapeHtml(PROVIDER_INFO[c.provider]?.name || c.provider); const modelLabel = c.model ? ` · ${escapeHtml(c.model)}` : ''; const endpointLabel = c.base_url ? ` · ${escapeHtml(c.base_url)}` : ''; + const badges = buildCredentialBadges(c); + const badgesHtml = badges.length + ? `