api usage cooldown

This commit is contained in:
MDeeApp
2025-10-05 20:12:13 +02:00
parent 1b0389b63d
commit 3bcc7b08b4
4 changed files with 1266 additions and 141 deletions

View File

@@ -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,9 +2304,10 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
const model = credential.model;
let comment = '';
let lastResponse = null;
try {
if (provider === 'gemini') {
// Gemini API
const modelName = model || 'gemini-2.0-flash-exp';
const prompt = promptPrefix + postText;
@@ -1738,16 +2324,47 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
}
);
lastResponse = response;
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Gemini API error: ${errorData.error?.message || response.statusText}`);
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;
}
const data = await response.json();
comment = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
} else if (provider === 'openai') {
// OpenAI/ChatGPT API
const modelName = model || 'gpt-3.5-turbo';
const prompt = promptPrefix + postText;
@@ -1771,17 +2388,47 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
})
});
lastResponse = response;
if (!response.ok) {
const errorData = await response.json();
const message = errorData.error?.message || response.statusText;
throw new Error(`OpenAI API error: ${message}`);
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') {
// Anthropic Claude API
const modelName = model || 'claude-3-5-haiku-20241022';
const prompt = promptPrefix + postText;
@@ -1799,9 +2446,41 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
})
});
lastResponse = response;
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Claude API error: ${errorData.error?.message || response.statusText}`);
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();
@@ -1811,7 +2490,19 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
throw new Error(`Unsupported AI provider: ${provider}`);
}
return sanitizeAIComment(comment);
const rateInfo = extractRateLimitInfo(lastResponse, provider);
rateInfo.status = lastResponse ? lastResponse.status : null;
return {
comment: sanitizeAIComment(comment),
rateInfo
};
} catch (error) {
if (error && !error.provider) {
error.provider = provider;
}
throw error;
}
}
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,26 +2562,60 @@ 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);
if (error && error.attempts) {
res.status(500).json({ error: error.message, attempts: error.attempts });
} else {
res.status(500).json({ error: error.message });
}
}
});
// ============================================================================

View File

@@ -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"]'
];
const searchInRoot = (root) => {
if (!root) {
return null;
}
for (const selector of selectors) {
const input = postElement.querySelector(selector);
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++) {
if (includeParents && postElement) {
let parent = postElement.parentElement;
for (let i = 0; i < 3 && parent; i++) {
if (preferredRoot && !preferredRoot.contains(parent)) {
parent = parent.parentElement;
if (!parent) break;
for (const selector of selectors) {
const input = parent.querySelector(selector);
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,6 +3079,7 @@ async function waitForCommentInput(postElement, options = {}) {
}
}
if (!preferredRoot) {
const fallbackDialog = document.querySelector(DIALOG_ROOT_SELECTOR);
if (fallbackDialog && fallbackDialog !== dialogRootFromPost) {
const dialogInput = fallbackDialog.querySelector('div[contenteditable="true"][role="textbox"]');
@@ -2964,8 +3090,21 @@ async function waitForCommentInput(postElement, options = {}) {
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);
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
});
}

View File

@@ -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;

View File

@@ -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, '&quot;');
}
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
? `<div class="credential-item__status">${badges.map(badge => `<span class="credential-status ${badge.className}"${badge.title ? ` title="${escapeHtmlAttr(badge.title)}"` : ''}>${escapeHtml(badge.label)}</span>`).join('')}</div>`
: '';
const metaLines = buildCredentialMetaLines(c);
const metaHtml = metaLines.length
? `<div class="credential-item__meta">${metaLines.map(line => `<div>${escapeHtml(line)}</div>`).join('')}</div>`
: '';
return `
<div class="credential-item" draggable="true" data-credential-id="${c.id}" data-index="${index}">
@@ -145,6 +281,8 @@ function renderCredentials() {
<div>
<div class="credential-item__name">${escapeHtml(c.name)}</div>
<div class="credential-item__provider">${providerName}${modelLabel}${endpointLabel}</div>
${badgesHtml}
${metaHtml}
</div>
</label>
</div>