Add end-to-end AI timing traces and AI debug view

This commit is contained in:
2026-02-20 14:10:06 +01:00
parent 4c187dab3c
commit af3b07b80f
6 changed files with 1705 additions and 210 deletions

View File

@@ -164,7 +164,14 @@ app.use(cors({
callback(null, origin || false); callback(null, origin || false);
}, },
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'], allowedHeaders: [
'Content-Type',
'Authorization',
'X-AI-Trace-Id',
'X-AI-Flow-Id',
'X-AI-Trace-Source'
],
exposedHeaders: ['X-AI-Trace-Id', 'X-AI-Flow-Id', 'Server-Timing'],
credentials: true credentials: true
})); }));
// Allow larger payloads because screenshots from high-res monitors can easily exceed 10 MB // Allow larger payloads because screenshots from high-res monitors can easily exceed 10 MB
@@ -177,7 +184,8 @@ app.use((req, res, next) => {
const fallbackOrigin = host ? `${isSecureRequest(req) ? 'https' : 'http'}://${host}` : '*'; const fallbackOrigin = host ? `${isSecureRequest(req) ? 'https' : 'http'}://${host}` : '*';
res.header('Access-Control-Allow-Origin', origin || fallbackOrigin); res.header('Access-Control-Allow-Origin', origin || fallbackOrigin);
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-AI-Trace-Id, X-AI-Flow-Id, X-AI-Trace-Source');
res.header('Access-Control-Expose-Headers', 'X-AI-Trace-Id, X-AI-Flow-Id, Server-Timing');
res.header('Access-Control-Allow-Credentials', 'true'); res.header('Access-Control-Allow-Credentials', 'true');
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
@@ -2075,6 +2083,45 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_ai_usage_events_credential_created CREATE INDEX IF NOT EXISTS idx_ai_usage_events_credential_created
ON ai_usage_events(credential_id, created_at DESC); ON ai_usage_events(credential_id, created_at DESC);
`); `);
db.exec(`
CREATE TABLE IF NOT EXISTS ai_debug_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trace_id TEXT NOT NULL UNIQUE,
flow_id TEXT,
source TEXT,
status TEXT NOT NULL DEFAULT 'created',
origin TEXT,
request_ip TEXT,
user_agent TEXT,
request_meta_json TEXT,
backend_timing_json TEXT,
backend_attempts_json TEXT,
backend_error TEXT,
response_meta_json TEXT,
frontend_timing_json TEXT,
frontend_steps_json TEXT,
frontend_error TEXT,
total_duration_ms REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_ai_debug_traces_created
ON ai_debug_traces(created_at DESC);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_ai_debug_traces_flow
ON ai_debug_traces(flow_id, created_at DESC);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_ai_debug_traces_status
ON ai_debug_traces(status, created_at DESC);
`);
db.prepare(` db.prepare(`
UPDATE posts UPDATE posts
SET last_change = COALESCE( SET last_change = COALESCE(
@@ -2311,6 +2358,10 @@ function extractRateLimitInfo(response, provider) {
const RATE_LIMIT_KEYWORDS = ['rate limit', 'ratelimit', 'quota', 'limit', 'too many requests', 'insufficient_quota', 'billing', 'exceeded', 'exceed']; const RATE_LIMIT_KEYWORDS = ['rate limit', 'ratelimit', 'quota', 'limit', 'too many requests', 'insufficient_quota', 'billing', 'exceeded', 'exceed'];
const AI_COMMENT_RETRY_LIMIT = 5; const AI_COMMENT_RETRY_LIMIT = 5;
const AI_DEBUG_TRACE_ID_MAX_LENGTH = 120;
const AI_DEBUG_FLOW_ID_MAX_LENGTH = 120;
const AI_DEBUG_DEFAULT_LIST_LIMIT = 100;
const AI_DEBUG_MAX_LIST_LIMIT = 500;
function determineAutoDisable(error) { function determineAutoDisable(error) {
if (!error) { if (!error) {
@@ -3029,6 +3080,301 @@ function recordAIUsageEvent(credentialId, eventType, options = {}) {
} }
} }
function safeJsonStringify(value) {
if (value === undefined) {
return null;
}
try {
return JSON.stringify(value);
} catch (error) {
return null;
}
}
function safeJsonParse(value, fallback = null) {
if (!value || typeof value !== 'string') {
return fallback;
}
try {
return JSON.parse(value);
} catch (error) {
return fallback;
}
}
function sanitizeAIDebugValue(value, maxLength) {
if (value === undefined || value === null) {
return null;
}
const normalized = String(value).trim();
if (!normalized) {
return null;
}
if (!Number.isFinite(maxLength) || maxLength <= 0) {
return normalized;
}
return normalized.length > maxLength
? normalized.slice(0, maxLength)
: normalized;
}
function buildAIDebugTraceId(prefix = 'trace') {
return `${prefix}-${uuidv4()}`;
}
function sanitizeTraceStatus(value, fallback = 'created') {
const raw = sanitizeAIDebugValue(value, 40);
if (!raw) {
return fallback;
}
return raw.toLowerCase();
}
function normalizeAIDebugRequestMeta(payload = {}) {
if (!payload || typeof payload !== 'object') {
return {};
}
const preferredCredential = Number(payload.preferredCredentialId);
return {
profileNumber: sanitizeProfileNumber(payload.profileNumber),
preferredCredentialId: Number.isNaN(preferredCredential) ? null : preferredCredential,
postTextLength: typeof payload.postText === 'string' ? payload.postText.length : null,
providerHint: sanitizeAIDebugValue(payload.providerHint, 120),
extensionVersion: sanitizeAIDebugValue(payload.extensionVersion, 80),
requestAttempt: Number.isFinite(Number(payload.requestAttempt))
? Number(payload.requestAttempt)
: null
};
}
function roundTiming(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
return null;
}
return Math.round(numeric * 1000) / 1000;
}
const upsertAIDebugTraceBaseStmt = db.prepare(`
INSERT INTO ai_debug_traces (
trace_id,
flow_id,
source,
status,
origin,
request_ip,
user_agent,
request_meta_json,
created_at,
updated_at
) VALUES (
@trace_id,
@flow_id,
@source,
@status,
@origin,
@request_ip,
@user_agent,
@request_meta_json,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON CONFLICT(trace_id) DO UPDATE SET
flow_id = COALESCE(excluded.flow_id, ai_debug_traces.flow_id),
source = COALESCE(excluded.source, ai_debug_traces.source),
status = excluded.status,
origin = COALESCE(excluded.origin, ai_debug_traces.origin),
request_ip = COALESCE(excluded.request_ip, ai_debug_traces.request_ip),
user_agent = COALESCE(excluded.user_agent, ai_debug_traces.user_agent),
request_meta_json = COALESCE(excluded.request_meta_json, ai_debug_traces.request_meta_json),
updated_at = CURRENT_TIMESTAMP
`);
const updateAIDebugTraceBackendStmt = db.prepare(`
UPDATE ai_debug_traces
SET flow_id = COALESCE(@flow_id, flow_id),
source = COALESCE(@source, source),
status = @status,
backend_timing_json = @backend_timing_json,
backend_attempts_json = @backend_attempts_json,
backend_error = @backend_error,
response_meta_json = @response_meta_json,
total_duration_ms = @total_duration_ms,
updated_at = CURRENT_TIMESTAMP
WHERE trace_id = @trace_id
`);
const upsertAIDebugTraceFrontendStmt = db.prepare(`
INSERT INTO ai_debug_traces (
trace_id,
flow_id,
source,
status,
frontend_timing_json,
frontend_steps_json,
frontend_error,
total_duration_ms,
created_at,
updated_at
) VALUES (
@trace_id,
@flow_id,
@source,
@status,
@frontend_timing_json,
@frontend_steps_json,
@frontend_error,
@total_duration_ms,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON CONFLICT(trace_id) DO UPDATE SET
flow_id = COALESCE(excluded.flow_id, ai_debug_traces.flow_id),
source = COALESCE(excluded.source, ai_debug_traces.source),
status = excluded.status,
frontend_timing_json = COALESCE(excluded.frontend_timing_json, ai_debug_traces.frontend_timing_json),
frontend_steps_json = COALESCE(excluded.frontend_steps_json, ai_debug_traces.frontend_steps_json),
frontend_error = COALESCE(excluded.frontend_error, ai_debug_traces.frontend_error),
total_duration_ms = COALESCE(excluded.total_duration_ms, ai_debug_traces.total_duration_ms),
updated_at = CURRENT_TIMESTAMP
`);
const getAIDebugTraceByIdStmt = db.prepare(`
SELECT *
FROM ai_debug_traces
WHERE trace_id = @traceId
LIMIT 1
`);
const listAIDebugTracesStmt = db.prepare(`
SELECT *
FROM ai_debug_traces
WHERE (@status IS NULL OR status = @status)
AND (@flowId IS NULL OR flow_id = @flowId)
AND (@fromDate IS NULL OR datetime(created_at) >= datetime(@fromDate))
AND (@toDate IS NULL OR datetime(created_at) <= datetime(@toDate))
ORDER BY datetime(created_at) DESC
LIMIT @limit
OFFSET @offset
`);
function upsertAIDebugTraceBase(payload = {}) {
const traceId = sanitizeAIDebugValue(payload.traceId, AI_DEBUG_TRACE_ID_MAX_LENGTH)
|| buildAIDebugTraceId();
const flowId = sanitizeAIDebugValue(payload.flowId, AI_DEBUG_FLOW_ID_MAX_LENGTH);
upsertAIDebugTraceBaseStmt.run({
trace_id: traceId,
flow_id: flowId,
source: sanitizeAIDebugValue(payload.source, 80) || 'unknown',
status: sanitizeTraceStatus(payload.status, 'created'),
origin: sanitizeAIDebugValue(payload.origin, 240),
request_ip: sanitizeAIDebugValue(payload.requestIp, 120),
user_agent: sanitizeAIDebugValue(payload.userAgent, 800),
request_meta_json: safeJsonStringify(payload.requestMeta || null)
});
return { traceId, flowId };
}
function updateAIDebugTraceBackend(payload = {}) {
const traceId = sanitizeAIDebugValue(payload.traceId, AI_DEBUG_TRACE_ID_MAX_LENGTH);
if (!traceId) {
return;
}
updateAIDebugTraceBackendStmt.run({
trace_id: traceId,
flow_id: sanitizeAIDebugValue(payload.flowId, AI_DEBUG_FLOW_ID_MAX_LENGTH),
source: sanitizeAIDebugValue(payload.source, 80),
status: sanitizeTraceStatus(payload.status, 'backend_done'),
backend_timing_json: safeJsonStringify(payload.backendTimings || null),
backend_attempts_json: safeJsonStringify(payload.backendAttempts || null),
backend_error: payload.backendError
? truncateString(String(payload.backendError), 1200)
: null,
response_meta_json: safeJsonStringify(payload.responseMeta || null),
total_duration_ms: roundTiming(payload.totalDurationMs)
});
}
function upsertAIDebugTraceFrontend(payload = {}) {
const traceId = sanitizeAIDebugValue(payload.traceId, AI_DEBUG_TRACE_ID_MAX_LENGTH)
|| buildAIDebugTraceId();
const flowId = sanitizeAIDebugValue(payload.flowId, AI_DEBUG_FLOW_ID_MAX_LENGTH);
upsertAIDebugTraceFrontendStmt.run({
trace_id: traceId,
flow_id: flowId,
source: sanitizeAIDebugValue(payload.source, 80) || 'extension',
status: sanitizeTraceStatus(payload.status, 'frontend_reported'),
frontend_timing_json: safeJsonStringify(payload.frontendTimings || null),
frontend_steps_json: safeJsonStringify(payload.frontendSteps || null),
frontend_error: payload.frontendError
? truncateString(String(payload.frontendError), 1200)
: null,
total_duration_ms: roundTiming(payload.totalDurationMs)
});
return { traceId, flowId };
}
function parseAIDebugTraceRow(row) {
if (!row) {
return null;
}
return {
id: row.id,
trace_id: row.trace_id,
flow_id: row.flow_id || null,
source: row.source || null,
status: row.status || null,
origin: row.origin || null,
request_ip: row.request_ip || null,
user_agent: row.user_agent || null,
request_meta: safeJsonParse(row.request_meta_json, null),
backend_timings: safeJsonParse(row.backend_timing_json, null),
backend_attempts: safeJsonParse(row.backend_attempts_json, null),
backend_error: row.backend_error || null,
response_meta: safeJsonParse(row.response_meta_json, null),
frontend_timings: safeJsonParse(row.frontend_timing_json, null),
frontend_steps: safeJsonParse(row.frontend_steps_json, null),
frontend_error: row.frontend_error || null,
total_duration_ms: roundTiming(row.total_duration_ms),
created_at: sqliteTimestampToUTC(row.created_at),
updated_at: sqliteTimestampToUTC(row.updated_at)
};
}
function buildServerTimingHeader(timingPairs = []) {
if (!Array.isArray(timingPairs) || !timingPairs.length) {
return '';
}
const parts = timingPairs
.map((entry) => {
if (!entry || typeof entry !== 'object') {
return null;
}
const name = sanitizeAIDebugValue(entry.name, 24);
const duration = roundTiming(entry.duration);
if (!name || duration === null) {
return null;
}
const normalizedName = name.replace(/[^a-zA-Z0-9_-]/g, '').toLowerCase();
if (!normalizedName) {
return null;
}
return `${normalizedName};dur=${duration}`;
})
.filter(Boolean);
return parts.join(', ');
}
function updateCredentialUsageOnSuccess(credentialId, info = {}) { function updateCredentialUsageOnSuccess(credentialId, info = {}) {
if (!credentialId) { if (!credentialId) {
return; return;
@@ -6372,6 +6718,95 @@ app.put('/api/ai-settings', (req, res) => {
} }
}); });
app.get('/api/ai/debug-traces', (req, res) => {
try {
const status = sanitizeTraceStatus(req.query.status, '') || null;
const flowId = sanitizeAIDebugValue(req.query.flowId || req.query.flow_id, AI_DEBUG_FLOW_ID_MAX_LENGTH);
const fromDate = ensureIsoDate(req.query.from || req.query.from_date);
const toDate = ensureIsoDate(req.query.to || req.query.to_date);
const limitRaw = parseInt(req.query.limit, 10);
const offsetRaw = parseInt(req.query.offset, 10);
const limit = Number.isFinite(limitRaw)
? Math.max(1, Math.min(AI_DEBUG_MAX_LIST_LIMIT, limitRaw))
: AI_DEBUG_DEFAULT_LIST_LIMIT;
const offset = Number.isFinite(offsetRaw)
? Math.max(0, offsetRaw)
: 0;
const rows = listAIDebugTracesStmt.all({
status,
flowId: flowId || null,
fromDate: fromDate || null,
toDate: toDate || null,
limit,
offset
});
res.json({
items: rows.map(parseAIDebugTraceRow),
limit,
offset
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/ai/debug-traces/:traceId', (req, res) => {
try {
const traceId = sanitizeAIDebugValue(req.params.traceId, AI_DEBUG_TRACE_ID_MAX_LENGTH);
if (!traceId) {
return res.status(400).json({ error: 'traceId is required' });
}
const row = getAIDebugTraceByIdStmt.get({ traceId });
if (!row) {
return res.status(404).json({ error: 'Trace nicht gefunden' });
}
res.json(parseAIDebugTraceRow(row));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/ai/debug-traces/frontend', (req, res) => {
try {
const payload = req.body || {};
const traceId = sanitizeAIDebugValue(payload.traceId, AI_DEBUG_TRACE_ID_MAX_LENGTH);
const flowId = sanitizeAIDebugValue(payload.flowId, AI_DEBUG_FLOW_ID_MAX_LENGTH);
const source = sanitizeAIDebugValue(payload.source, 80) || 'extension';
const status = sanitizeTraceStatus(payload.status, 'frontend_reported');
const ensured = upsertAIDebugTraceBase({
traceId,
flowId,
source,
status,
requestMeta: payload.requestMeta || null
});
const upserted = upsertAIDebugTraceFrontend({
traceId: ensured.traceId,
flowId: flowId || ensured.flowId,
source,
status,
frontendTimings: payload.frontendTimings || null,
frontendSteps: Array.isArray(payload.frontendSteps) ? payload.frontendSteps : null,
frontendError: payload.frontendError || null,
totalDurationMs: payload.totalDurationMs
});
const row = getAIDebugTraceByIdStmt.get({ traceId: upserted.traceId });
res.json({
success: true,
trace: parseAIDebugTraceRow(row)
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/moderation-settings', (req, res) => { app.get('/api/moderation-settings', (req, res) => {
try { try {
const settings = loadModerationSettings(); const settings = loadModerationSettings();
@@ -6505,19 +6940,45 @@ function shouldRetryAIComment(text) {
return hasCommentOrCharacter && hasLengthOrCount; return hasCommentOrCharacter && hasLengthOrCount;
} }
async function tryGenerateComment(credential, promptPrefix, postText) { async function tryGenerateComment(credential, promptPrefix, postText, options = {}) {
const provider = credential.provider; const provider = credential.provider;
const apiKey = credential.api_key; const apiKey = credential.api_key;
const model = credential.model; const model = credential.model;
const traceId = sanitizeAIDebugValue(options.traceId, AI_DEBUG_TRACE_ID_MAX_LENGTH);
const flowId = sanitizeAIDebugValue(options.flowId, AI_DEBUG_FLOW_ID_MAX_LENGTH);
let lastResponse = null; let lastResponse = null;
const providerAttempts = [];
try { try {
for (let attempt = 1; attempt <= AI_COMMENT_RETRY_LIMIT; attempt += 1) { for (let attempt = 1; attempt <= AI_COMMENT_RETRY_LIMIT; attempt += 1) {
const attemptStartMs = Date.now();
const attemptStartedAt = new Date(attemptStartMs).toISOString();
let attemptRecorded = false;
let modelName = model || null;
let comment = ''; let comment = '';
const recordAttempt = (status, extra = {}) => {
if (attemptRecorded) {
return;
}
attemptRecorded = true;
providerAttempts.push({
attempt,
provider,
model: modelName,
traceId: traceId || null,
flowId: flowId || null,
status,
started_at: attemptStartedAt,
duration_ms: roundTiming(Date.now() - attemptStartMs),
...extra
});
};
try {
if (provider === 'gemini') { if (provider === 'gemini') {
const modelName = model || 'gemini-2.0-flash-exp'; modelName = model || 'gemini-2.0-flash-exp';
const prompt = promptPrefix + postText; const prompt = promptPrefix + postText;
const response = await fetch( const response = await fetch(
@@ -6553,6 +7014,13 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
} }
const rateInfo = extractRateLimitInfo(response, provider); const rateInfo = extractRateLimitInfo(response, provider);
recordAttempt('http_error', {
http_status: response.status,
error: message,
rate_limit_remaining: rateInfo.rateLimitRemaining ?? null,
rate_limit_reset_at: rateInfo.rateLimitResetAt ?? null
});
const error = new Error(`Gemini API error: ${message}`); const error = new Error(`Gemini API error: ${message}`);
error.status = response.status; error.status = response.status;
error.provider = provider; error.provider = provider;
@@ -6573,7 +7041,7 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
const data = await response.json(); const data = await response.json();
comment = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; comment = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
} else if (provider === 'openai') { } else if (provider === 'openai') {
const modelName = model || 'gpt-3.5-turbo'; modelName = model || 'gpt-3.5-turbo';
const prompt = promptPrefix + postText; const prompt = promptPrefix + postText;
const baseUrl = (credential.base_url || 'https://api.openai.com/v1').trim().replace(/\/+$/, ''); const baseUrl = (credential.base_url || 'https://api.openai.com/v1').trim().replace(/\/+$/, '');
@@ -6583,7 +7051,7 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}; };
if (apiKey) { if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`; headers.Authorization = `Bearer ${apiKey}`;
} }
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
@@ -6616,6 +7084,13 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
} }
const rateInfo = extractRateLimitInfo(response, provider); const rateInfo = extractRateLimitInfo(response, provider);
recordAttempt('http_error', {
http_status: response.status,
error: message,
rate_limit_remaining: rateInfo.rateLimitRemaining ?? null,
rate_limit_reset_at: rateInfo.rateLimitResetAt ?? null
});
const error = new Error(`OpenAI API error: ${message}`); const error = new Error(`OpenAI API error: ${message}`);
error.status = response.status; error.status = response.status;
error.provider = provider; error.provider = provider;
@@ -6636,7 +7111,7 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
const data = await response.json(); const data = await response.json();
comment = data.choices?.[0]?.message?.content || ''; comment = data.choices?.[0]?.message?.content || '';
} else if (provider === 'claude') { } else if (provider === 'claude') {
const modelName = model || 'claude-3-5-haiku-20241022'; modelName = model || 'claude-3-5-haiku-20241022';
const prompt = promptPrefix + postText; const prompt = promptPrefix + postText;
const response = await fetch('https://api.anthropic.com/v1/messages', { const response = await fetch('https://api.anthropic.com/v1/messages', {
@@ -6673,6 +7148,13 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
} }
const rateInfo = extractRateLimitInfo(response, provider); const rateInfo = extractRateLimitInfo(response, provider);
recordAttempt('http_error', {
http_status: response.status,
error: message,
rate_limit_remaining: rateInfo.rateLimitRemaining ?? null,
rate_limit_reset_at: rateInfo.rateLimitResetAt ?? null
});
const error = new Error(`Claude API error: ${message}`); const error = new Error(`Claude API error: ${message}`);
error.status = response.status; error.status = response.status;
error.provider = provider; error.provider = provider;
@@ -6697,6 +7179,10 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
} }
if (shouldRetryAIComment(comment)) { if (shouldRetryAIComment(comment)) {
recordAttempt('retry_filtered', {
http_status: lastResponse ? lastResponse.status : null,
comment_length: comment.length
});
if (attempt < AI_COMMENT_RETRY_LIMIT) { if (attempt < AI_COMMENT_RETRY_LIMIT) {
continue; continue;
} }
@@ -6708,36 +7194,147 @@ async function tryGenerateComment(credential, promptPrefix, postText) {
const rateInfo = extractRateLimitInfo(lastResponse, provider); const rateInfo = extractRateLimitInfo(lastResponse, provider);
rateInfo.status = lastResponse ? lastResponse.status : null; rateInfo.status = lastResponse ? lastResponse.status : null;
recordAttempt('success', {
http_status: lastResponse ? lastResponse.status : null,
comment_length: comment.length,
rate_limit_remaining: rateInfo.rateLimitRemaining ?? null,
rate_limit_reset_at: rateInfo.rateLimitResetAt ?? null
});
return { return {
comment: sanitizeAIComment(comment), comment: sanitizeAIComment(comment),
rateInfo rateInfo,
providerAttempts
}; };
} catch (error) {
if (!attemptRecorded) {
recordAttempt(error && error.name === 'AbortError' ? 'aborted' : 'error', {
http_status: lastResponse ? lastResponse.status : null,
error: error && error.message ? String(error.message) : 'Unbekannter Fehler'
});
}
throw error;
}
} }
} catch (error) { } catch (error) {
if (error && !error.provider) { if (error && !error.provider) {
error.provider = provider; error.provider = provider;
} }
if (error && !error.providerAttempts) {
error.providerAttempts = providerAttempts;
}
throw error; throw error;
} }
} }
app.post('/api/ai/generate-comment', async (req, res) => { app.post('/api/ai/generate-comment', async (req, res) => {
const requestStartedMs = Date.now();
const requestBody = req.body || {};
const requestMeta = normalizeAIDebugRequestMeta(requestBody);
const incomingTraceId = sanitizeAIDebugValue(req.headers['x-ai-trace-id'] || requestBody.traceId, AI_DEBUG_TRACE_ID_MAX_LENGTH);
const incomingFlowId = sanitizeAIDebugValue(req.headers['x-ai-flow-id'] || requestBody.flowId, AI_DEBUG_FLOW_ID_MAX_LENGTH);
const traceId = incomingTraceId || buildAIDebugTraceId();
const flowId = incomingFlowId || traceId;
const traceSource = sanitizeAIDebugValue(req.headers['x-ai-trace-source'] || requestBody.traceSource, 80) || 'extension-ai-button';
const backendTimings = {};
const credentialTimingDetails = [];
upsertAIDebugTraceBase({
traceId,
flowId,
source: traceSource,
status: 'backend_in_progress',
origin: req.headers.origin || null,
requestIp: req.ip || req.socket?.remoteAddress || null,
userAgent: req.headers['user-agent'] || null,
requestMeta
});
const timingStart = () => Date.now();
const timingEnd = (key, startedAt) => {
const value = roundTiming(Date.now() - startedAt);
backendTimings[key] = value;
return value;
};
const applyTraceHeaders = () => {
res.setHeader('x-ai-trace-id', traceId);
if (flowId) {
res.setHeader('x-ai-flow-id', flowId);
}
};
const applyServerTimingHeader = () => {
const header = buildServerTimingHeader([
{ name: 'settings', duration: backendTimings.loadSettingsMs },
{ name: 'reactivate', duration: backendTimings.reactivateCredentialsMs },
{ name: 'credentials', duration: backendTimings.loadCredentialsMs },
{ name: 'prompt', duration: backendTimings.buildPromptMs },
{ name: 'ai', duration: backendTimings.credentialLoopMs },
{ name: 'total', duration: backendTimings.totalMs }
]);
if (header) {
res.setHeader('Server-Timing', header);
}
};
const respondWithTrackedError = (statusCode, message, options = {}) => {
backendTimings.totalMs = roundTiming(Date.now() - requestStartedMs);
updateAIDebugTraceBackend({
traceId,
flowId,
source: traceSource,
status: statusCode >= 500 ? 'backend_error' : 'backend_rejected',
backendTimings,
backendAttempts: credentialTimingDetails,
backendError: message,
responseMeta: {
statusCode,
...options.responseMeta
},
totalDurationMs: backendTimings.totalMs
});
applyTraceHeaders();
applyServerTimingHeader();
const payload = {
error: message,
traceId,
flowId,
timings: {
backend: backendTimings
}
};
if (options.attempts) {
payload.attempts = options.attempts;
}
return res.status(statusCode).json(payload);
};
try { try {
const { postText, profileNumber, preferredCredentialId } = req.body; const { postText, profileNumber, preferredCredentialId } = requestBody;
if (!postText) { if (!postText) {
return res.status(400).json({ error: 'postText is required' }); return respondWithTrackedError(400, 'postText is required');
} }
const loadSettingsStartedMs = timingStart();
const settings = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); const settings = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get();
timingEnd('loadSettingsMs', loadSettingsStartedMs);
if (!settings || !settings.enabled) { if (!settings || !settings.enabled) {
return res.status(400).json({ error: 'AI comment generation is not enabled' }); return respondWithTrackedError(400, 'AI comment generation is not enabled');
} }
const reactivateStartedMs = timingStart();
reactivateExpiredCredentials(); reactivateExpiredCredentials();
timingEnd('reactivateCredentialsMs', reactivateStartedMs);
// Get all active credentials, ordered by priority const loadCredentialsStartedMs = timingStart();
const credentials = db.prepare(` const credentials = db.prepare(`
SELECT * SELECT *
FROM ai_credentials FROM ai_credentials
@@ -6745,9 +7342,10 @@ app.post('/api/ai/generate-comment', async (req, res) => {
AND COALESCE(auto_disabled, 0) = 0 AND COALESCE(auto_disabled, 0) = 0
ORDER BY priority ASC, id ASC ORDER BY priority ASC, id ASC
`).all(); `).all();
timingEnd('loadCredentialsMs', loadCredentialsStartedMs);
if (!credentials || credentials.length === 0) { if (!credentials || credentials.length === 0) {
return res.status(400).json({ error: 'No active AI credentials available' }); return respondWithTrackedError(400, 'No active AI credentials available');
} }
let orderedCredentials = credentials; let orderedCredentials = credentials;
@@ -6762,10 +7360,10 @@ app.post('/api/ai/generate-comment', async (req, res) => {
} }
} }
const promptBuildStartedMs = timingStart();
let promptPrefix = settings.prompt_prefix || ''; let promptPrefix = settings.prompt_prefix || '';
const normalizedProfileNumber = sanitizeProfileNumber(profileNumber); const normalizedProfileNumber = sanitizeProfileNumber(profileNumber);
// Get friend names for the profile if available
if (normalizedProfileNumber) { if (normalizedProfileNumber) {
const friends = db.prepare('SELECT friend_names FROM profile_friends WHERE profile_number = ?').get(normalizedProfileNumber); const friends = db.prepare('SELECT friend_names FROM profile_friends WHERE profile_number = ?').get(normalizedProfileNumber);
if (friends && friends.friend_names) { if (friends && friends.friend_names) {
@@ -6782,63 +7380,128 @@ app.post('/api/ai/generate-comment', async (req, res) => {
promptPrefix = promptPrefix.replaceAll('{DATUM}', todayDisplay); promptPrefix = promptPrefix.replaceAll('{DATUM}', todayDisplay);
promptPrefix = applyRandomNumberTemplates(promptPrefix); promptPrefix = applyRandomNumberTemplates(promptPrefix);
promptPrefix = applyProfileVariantTemplates(promptPrefix, normalizedProfileNumber); promptPrefix = applyProfileVariantTemplates(promptPrefix, normalizedProfileNumber);
timingEnd('buildPromptMs', promptBuildStartedMs);
// Try each active credential until one succeeds
let lastError = null; let lastError = null;
const attemptDetails = []; const attemptDetails = [];
const credentialLoopStartedMs = timingStart();
for (const credential of orderedCredentials) { for (const credential of orderedCredentials) {
const credentialStartedMs = Date.now();
try { try {
console.log(`Trying credential: ${credential.name} (ID: ${credential.id})`); console.log(`Trying credential: ${credential.name} (ID: ${credential.id})`);
const { comment, rateInfo } = await tryGenerateComment(credential, promptPrefix, postText); const { comment, rateInfo, providerAttempts } = await tryGenerateComment(
console.log(`Success with credential: ${credential.name}`); credential,
promptPrefix,
postText,
{ traceId, flowId }
);
const credentialDurationMs = roundTiming(Date.now() - credentialStartedMs);
console.log(`Success with credential: ${credential.name}`);
updateCredentialUsageOnSuccess(credential.id, rateInfo || {}); updateCredentialUsageOnSuccess(credential.id, rateInfo || {});
credentialTimingDetails.push({
credentialId: credential.id,
credentialName: credential.name,
provider: credential.provider,
model: credential.model || null,
status: 'success',
duration_ms: credentialDurationMs,
provider_attempts: providerAttempts || []
});
attemptDetails.push({ attemptDetails.push({
credentialId: credential.id, credentialId: credential.id,
credentialName: credential.name, credentialName: credential.name,
status: 'success', status: 'success',
durationMs: credentialDurationMs,
rateLimitRemaining: rateInfo?.rateLimitRemaining ?? null, rateLimitRemaining: rateInfo?.rateLimitRemaining ?? null,
rateLimitResetAt: rateInfo?.rateLimitResetAt ?? null rateLimitResetAt: rateInfo?.rateLimitResetAt ?? null
}); });
backendTimings.credentialLoopMs = roundTiming(Date.now() - credentialLoopStartedMs);
backendTimings.totalMs = roundTiming(Date.now() - requestStartedMs);
updateAIDebugTraceBackend({
traceId,
flowId,
source: traceSource,
status: 'backend_success',
backendTimings,
backendAttempts: credentialTimingDetails,
responseMeta: {
usedCredential: credential.name,
usedCredentialId: credential.id,
attempts: attemptDetails
},
totalDurationMs: backendTimings.totalMs
});
applyTraceHeaders();
applyServerTimingHeader();
return res.json({ return res.json({
comment, comment,
usedCredential: credential.name, usedCredential: credential.name,
usedCredentialId: credential.id, usedCredentialId: credential.id,
attempts: attemptDetails, attempts: attemptDetails,
rateLimitInfo: rateInfo || null rateLimitInfo: rateInfo || null,
traceId,
flowId,
timings: {
backend: backendTimings,
credentials: credentialTimingDetails
}
}); });
} catch (error) { } catch (error) {
const credentialDurationMs = roundTiming(Date.now() - credentialStartedMs);
console.error(`Failed with credential ${credential.name}:`, error.message); console.error(`Failed with credential ${credential.name}:`, error.message);
lastError = error; lastError = error;
const errorUpdate = updateCredentialUsageOnError(credential.id, error); const errorUpdate = updateCredentialUsageOnError(credential.id, error);
credentialTimingDetails.push({
credentialId: credential.id,
credentialName: credential.name,
provider: credential.provider,
model: credential.model || null,
status: 'error',
duration_ms: credentialDurationMs,
error: error.message,
status_code: error.status || error.statusCode || null,
provider_attempts: error.providerAttempts || []
});
attemptDetails.push({ attemptDetails.push({
credentialId: credential.id, credentialId: credential.id,
credentialName: credential.name, credentialName: credential.name,
status: 'error', status: 'error',
durationMs: credentialDurationMs,
message: error.message, message: error.message,
statusCode: error.status || error.statusCode || null, statusCode: error.status || error.statusCode || null,
autoDisabled: Boolean(errorUpdate.autoDisabled), autoDisabled: Boolean(errorUpdate.autoDisabled),
autoDisabledUntil: errorUpdate.autoDisabledUntil || null autoDisabledUntil: errorUpdate.autoDisabledUntil || null
}); });
// Continue to next credential
} }
} }
// If we get here, all credentials failed backendTimings.credentialLoopMs = roundTiming(Date.now() - credentialLoopStartedMs);
const finalError = lastError || new Error('All AI credentials failed'); const finalError = lastError || new Error('All AI credentials failed');
finalError.attempts = attemptDetails; finalError.attempts = attemptDetails;
throw finalError; throw finalError;
} catch (error) { } catch (error) {
console.error('AI comment generation error:', error); console.error('AI comment generation error:', error);
if (error && error.attempts) { return respondWithTrackedError(
res.status(500).json({ error: error.message, attempts: error.attempts }); 500,
} else { error && error.message ? error.message : 'Unbekannter Fehler',
res.status(500).json({ error: error.message }); {
attempts: error && error.attempts ? error.attempts : null,
responseMeta: {
attempts: error && error.attempts ? error.attempts : null
} }
} }
);
}
}); });
// ============================================================================ // ============================================================================

View File

@@ -5332,47 +5332,185 @@ function sanitizeAIComment(comment) {
return sanitized.trim(); return sanitized.trim();
} }
function nowPerformanceMs() {
if (typeof performance !== 'undefined' && performance && typeof performance.now === 'function') {
return performance.now();
}
return Date.now();
}
function roundDurationMs(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
return null;
}
return Math.round(numeric * 1000) / 1000;
}
function buildAITraceId(prefix = 'trace') {
const safePrefix = String(prefix || 'trace').trim().replace(/[^a-z0-9_-]/gi, '').toLowerCase() || 'trace';
if (typeof crypto !== 'undefined' && crypto && typeof crypto.randomUUID === 'function') {
return `${safePrefix}-${crypto.randomUUID()}`;
}
const randomPart = Math.random().toString(36).slice(2, 10);
return `${safePrefix}-${Date.now().toString(36)}-${randomPart}`;
}
async function reportAIDebugFrontendTrace(payload = {}) {
try {
const body = {
traceId: payload.traceId || null,
flowId: payload.flowId || null,
source: payload.source || 'extension-ai-button',
status: payload.status || 'frontend_reported',
requestMeta: payload.requestMeta || null,
frontendTimings: payload.frontendTimings || null,
frontendSteps: Array.isArray(payload.frontendSteps) ? payload.frontendSteps : null,
frontendError: payload.frontendError || null,
totalDurationMs: payload.totalDurationMs
};
await backendFetch(`${API_URL}/ai/debug-traces/frontend`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
} catch (error) {
console.warn('[FB Tracker] Failed to submit AI frontend trace:', error);
}
}
/** /**
* Generate AI comment for a post * Generate AI comment for a post
*/ */
async function generateAIComment(postText, profileNumber, options = {}) { async function generateAIComment(postText, profileNumber, options = {}) {
const { signal = null, preferredCredentialId = null, maxAttempts = 3 } = options; const {
const payload = { signal = null,
preferredCredentialId = null,
maxAttempts = 3,
flowId = null,
source = 'extension-ai-button',
returnMeta = false
} = options;
const normalizedFlowId = typeof flowId === 'string' && flowId.trim()
? flowId.trim()
: buildAITraceId('flow');
const basePayload = {
postText, postText,
profileNumber profileNumber,
flowId: normalizedFlowId,
traceSource: source
}; };
if (typeof preferredCredentialId === 'number') { if (typeof preferredCredentialId === 'number' && !Number.isNaN(preferredCredentialId)) {
payload.preferredCredentialId = preferredCredentialId; basePayload.preferredCredentialId = preferredCredentialId;
} }
const requestAttempts = [];
let lastError = null; let lastError = null;
let lastTraceId = null;
const attempts = Math.max(1, maxAttempts); const attempts = Math.max(1, maxAttempts);
for (let attempt = 1; attempt <= attempts; attempt += 1) { for (let attempt = 1; attempt <= attempts; attempt += 1) {
const requestTraceId = `${normalizedFlowId}-r${attempt}`;
const startedAt = new Date().toISOString();
const attemptStartedMs = nowPerformanceMs();
const payload = {
...basePayload,
traceId: requestTraceId,
requestAttempt: attempt
};
try { try {
const response = await backendFetch(`${API_URL}/ai/generate-comment`, { const response = await backendFetch(`${API_URL}/ai/generate-comment`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
'x-ai-trace-id': requestTraceId,
'x-ai-flow-id': normalizedFlowId,
'x-ai-trace-source': source
},
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal signal
}); });
const durationMs = roundDurationMs(nowPerformanceMs() - attemptStartedMs);
const responseTraceId = response.headers.get('x-ai-trace-id') || requestTraceId;
const responseFlowId = response.headers.get('x-ai-flow-id') || normalizedFlowId;
lastTraceId = responseTraceId || lastTraceId;
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to generate comment'); const message = errorData.error || 'Failed to generate comment';
requestAttempts.push({
attempt,
traceId: responseTraceId,
flowId: responseFlowId,
status: 'http_error',
startedAt,
durationMs,
httpStatus: response.status,
error: message
});
const error = new Error(message);
error.aiTrace = {
traceId: responseTraceId,
flowId: responseFlowId,
requestAttempts: requestAttempts.slice()
};
throw error;
} }
const data = await response.json(); const data = await response.json();
const sanitizedComment = sanitizeAIComment(data.comment); const sanitizedComment = sanitizeAIComment(data.comment);
const effectiveTraceId = data.traceId || responseTraceId;
const effectiveFlowId = data.flowId || responseFlowId;
lastTraceId = effectiveTraceId || lastTraceId;
requestAttempts.push({
attempt,
traceId: effectiveTraceId,
flowId: effectiveFlowId,
status: sanitizedComment ? 'success' : 'empty',
startedAt,
durationMs,
httpStatus: response.status,
backendTimings: data.timings && data.timings.backend ? data.timings.backend : null
});
if (sanitizedComment) { if (sanitizedComment) {
return sanitizedComment; const result = {
comment: sanitizedComment,
traceId: effectiveTraceId,
flowId: effectiveFlowId,
requestAttempts,
backendTimings: data.timings && data.timings.backend ? data.timings.backend : null
};
return returnMeta ? result : sanitizedComment;
} }
lastError = new Error('AI response empty'); lastError = new Error('AI response empty');
} catch (error) { } catch (error) {
const durationMs = roundDurationMs(nowPerformanceMs() - attemptStartedMs);
const cancelled = error && (error.name === 'AbortError' || isCancellationError(error));
if (!error || !error.aiTrace) {
requestAttempts.push({
attempt,
traceId: requestTraceId,
flowId: normalizedFlowId,
status: cancelled ? 'cancelled' : 'request_error',
startedAt,
durationMs,
error: error && error.message ? String(error.message) : 'Unbekannter Fehler'
});
} else if (error.aiTrace && error.aiTrace.traceId) {
lastTraceId = error.aiTrace.traceId;
}
lastError = error; lastError = error;
if (cancelled) {
break;
}
} }
if (attempt < attempts) { if (attempt < attempts) {
@@ -5381,8 +5519,27 @@ async function generateAIComment(postText, profileNumber, options = {}) {
} }
} }
if (lastError) {
if (!lastError.aiTrace) {
lastError.aiTrace = {
traceId: lastTraceId,
flowId: normalizedFlowId,
requestAttempts: requestAttempts.slice()
};
}
if (lastError.name === 'AbortError' || isCancellationError(lastError)) {
throw lastError;
}
}
console.error('[FB Tracker] AI comment generation failed after retries:', lastError); console.error('[FB Tracker] AI comment generation failed after retries:', lastError);
throw new Error('AI-Antwort konnte nicht erzeugt werden. Bitte später erneut versuchen.'); const finalError = new Error('AI-Antwort konnte nicht erzeugt werden. Bitte später erneut versuchen.');
finalError.aiTrace = {
traceId: lastTraceId,
flowId: normalizedFlowId,
requestAttempts: requestAttempts.slice()
};
throw finalError;
} }
async function handleSelectionAIRequest(selectionText, sendResponse) { async function handleSelectionAIRequest(selectionText, sendResponse) {
@@ -5402,7 +5559,9 @@ async function handleSelectionAIRequest(selectionText, sendResponse) {
sendResponse({ error: 'profile-missing' }); sendResponse({ error: 'profile-missing' });
return; return;
} }
const comment = await generateAIComment(normalizedSelection, profileNumber, {}); const comment = await generateAIComment(normalizedSelection, profileNumber, {
source: 'selection-ai'
});
if (!comment) { if (!comment) {
throw new Error('Keine Antwort vom AI-Dienst erhalten'); throw new Error('Keine Antwort vom AI-Dienst erhalten');
@@ -6081,6 +6240,73 @@ async function addAICommentButton(container, postElement) {
} }
}; };
const flowTrace = {
source: 'extension-ai-button',
flowId: buildAITraceId('flow'),
traceId: null,
status: 'processing',
startedAt: new Date().toISOString(),
finishedAt: null,
frontendTimings: {},
frontendSteps: [],
frontendError: null,
totalDurationMs: null,
requestMeta: {
preferredCredentialId: typeof preferredCredentialId === 'number' && !Number.isNaN(preferredCredentialId)
? preferredCredentialId
: null
},
backend: null
};
const flowStartMs = nowPerformanceMs();
const phaseStartTimes = {};
const addStep = (step, payload = {}) => {
flowTrace.frontendSteps.push({
step,
at: new Date().toISOString(),
...(payload && typeof payload === 'object' ? payload : {})
});
};
const beginPhase = (name) => {
phaseStartTimes[name] = nowPerformanceMs();
};
const endPhase = (name, payload = null) => {
if (!Object.prototype.hasOwnProperty.call(phaseStartTimes, name)) {
return null;
}
const durationMs = roundDurationMs(nowPerformanceMs() - phaseStartTimes[name]);
flowTrace.frontendTimings[name] = durationMs;
if (payload && typeof payload === 'object') {
addStep(name, payload);
}
return durationMs;
};
const mergeTraceInfo = (tracePayload) => {
if (!tracePayload || typeof tracePayload !== 'object') {
return;
}
if (tracePayload.traceId) {
flowTrace.traceId = tracePayload.traceId;
}
if (tracePayload.flowId) {
flowTrace.flowId = tracePayload.flowId;
}
const backend = {};
if (Array.isArray(tracePayload.requestAttempts)) {
backend.requestAttempts = tracePayload.requestAttempts;
}
if (tracePayload.backendTimings && typeof tracePayload.backendTimings === 'object') {
backend.timings = tracePayload.backendTimings;
}
if (Object.keys(backend).length) {
flowTrace.backend = backend;
}
};
const throwIfCancelled = () => { const throwIfCancelled = () => {
if (aiContext.cancelled) { if (aiContext.cancelled) {
const cancelError = new Error('AI_CANCELLED'); const cancelError = new Error('AI_CANCELLED');
@@ -6170,6 +6396,7 @@ async function addAICommentButton(container, postElement) {
return null; return null;
}; };
beginPhase('extractPostTextMs');
let postText = ''; let postText = '';
const cachedSelection = resolveRecentSelection(); const cachedSelection = resolveRecentSelection();
if (cachedSelection) { if (cachedSelection) {
@@ -6219,25 +6446,43 @@ async function addAICommentButton(container, postElement) {
postText = `${postText}\n\nZusatzinfo:\n${additionalNote}`; postText = `${postText}\n\nZusatzinfo:\n${additionalNote}`;
} }
flowTrace.requestMeta.postTextLength = postText.length;
endPhase('extractPostTextMs', { postTextLength: postText.length });
throwIfCancelled(); throwIfCancelled();
console.log('[FB Tracker] Generating AI comment for:', postText.substring(0, 100)); console.log('[FB Tracker] Generating AI comment for:', postText.substring(0, 100));
beginPhase('profileLookupMs');
const profileNumber = await getProfileNumber(); const profileNumber = await getProfileNumber();
endPhase('profileLookupMs', { profileNumber: profileNumber || null });
if (!profileNumber) { if (!profileNumber) {
flowTrace.status = 'profile_missing';
flowTrace.frontendError = 'Profilstatus nicht geladen';
showToast('Profilstatus nicht geladen. Bitte Tracker neu laden.', 'error'); showToast('Profilstatus nicht geladen. Bitte Tracker neu laden.', 'error');
restoreIdle(originalText);
return; return;
} }
throwIfCancelled(); throwIfCancelled();
const comment = await generateAIComment(postText, profileNumber, { beginPhase('aiRequestMs');
const aiResult = await generateAIComment(postText, profileNumber, {
signal: aiContext.abortController.signal, signal: aiContext.abortController.signal,
preferredCredentialId preferredCredentialId,
flowId: flowTrace.flowId,
source: flowTrace.source,
returnMeta: true
}); });
endPhase('aiRequestMs', {
traceId: aiResult.traceId || null,
requestAttempts: Array.isArray(aiResult.requestAttempts) ? aiResult.requestAttempts.length : 0
});
mergeTraceInfo(aiResult);
throwIfCancelled(); throwIfCancelled();
const comment = aiResult.comment;
console.log('[FB Tracker] Generated comment:', comment); console.log('[FB Tracker] Generated comment:', comment);
const dialogRoot = container.closest(DIALOG_ROOT_SELECTOR) const dialogRoot = container.closest(DIALOG_ROOT_SELECTOR)
@@ -6245,6 +6490,7 @@ async function addAICommentButton(container, postElement) {
let commentInput = findCommentInput(postContext, { preferredRoot: dialogRoot }); let commentInput = findCommentInput(postContext, { preferredRoot: dialogRoot });
let waitedForInput = false; let waitedForInput = false;
let waitStartedMs = null;
if (!commentInput) { if (!commentInput) {
console.log('[FB Tracker] Comment input not found, trying to click comment button'); console.log('[FB Tracker] Comment input not found, trying to click comment button');
@@ -6261,6 +6507,7 @@ async function addAICommentButton(container, postElement) {
updateProcessingText(buttonClicked ? '⏳ Öffne Kommentare...' : '⏳ Suche Kommentarfeld...'); updateProcessingText(buttonClicked ? '⏳ Öffne Kommentare...' : '⏳ Suche Kommentarfeld...');
waitedForInput = true; waitedForInput = true;
waitStartedMs = nowPerformanceMs();
commentInput = await waitForCommentInput(postContext, { commentInput = await waitForCommentInput(postContext, {
encodedPostUrl, encodedPostUrl,
timeout: buttonClicked ? 8000 : 5000, timeout: buttonClicked ? 8000 : 5000,
@@ -6273,6 +6520,7 @@ async function addAICommentButton(container, postElement) {
if (!commentInput && !waitedForInput) { if (!commentInput && !waitedForInput) {
updateProcessingText('⏳ Suche Kommentarfeld...'); updateProcessingText('⏳ Suche Kommentarfeld...');
waitedForInput = true; waitedForInput = true;
waitStartedMs = nowPerformanceMs();
commentInput = await waitForCommentInput(postContext, { commentInput = await waitForCommentInput(postContext, {
encodedPostUrl, encodedPostUrl,
timeout: 4000, timeout: 4000,
@@ -6282,15 +6530,30 @@ async function addAICommentButton(container, postElement) {
}); });
} }
if (waitStartedMs !== null) {
flowTrace.frontendTimings.waitForCommentInputMs = roundDurationMs(nowPerformanceMs() - waitStartedMs);
addStep('waitForCommentInputMs', {
durationMs: flowTrace.frontendTimings.waitForCommentInputMs,
found: Boolean(commentInput)
});
}
flowTrace.requestMeta.waitedForInput = waitedForInput;
flowTrace.requestMeta.commentInputFound = Boolean(commentInput);
throwIfCancelled(); throwIfCancelled();
if (!commentInput) { if (!commentInput) {
throwIfCancelled(); beginPhase('clipboardWriteMs');
await navigator.clipboard.writeText(comment); await navigator.clipboard.writeText(comment);
endPhase('clipboardWriteMs', { reason: 'input_missing' });
throwIfCancelled(); throwIfCancelled();
showToast('📋 Kommentarfeld nicht gefunden - In Zwischenablage kopiert', 'info'); showToast('📋 Kommentarfeld nicht gefunden - In Zwischenablage kopiert', 'info');
restoreIdle('📋 Kopiert', 2000); restoreIdle('📋 Kopiert', 2000);
flowTrace.status = 'clipboard_fallback';
beginPhase('confirmParticipationMs');
await confirmParticipationAfterAI(profileNumber); await confirmParticipationAfterAI(profileNumber);
endPhase('confirmParticipationMs');
return; return;
} }
@@ -6298,33 +6561,79 @@ async function addAICommentButton(container, postElement) {
updateProcessingText('⏳ Füge Kommentar ein...'); updateProcessingText('⏳ Füge Kommentar ein...');
} }
beginPhase('setCommentTextMs');
const success = await setCommentText(commentInput, comment, { context: aiContext }); const success = await setCommentText(commentInput, comment, { context: aiContext });
endPhase('setCommentTextMs', { success: Boolean(success) });
throwIfCancelled(); throwIfCancelled();
if (success) { if (success) {
showToast('✓ Kommentar wurde eingefügt', 'success'); showToast('✓ Kommentar wurde eingefügt', 'success');
restoreIdle('✓ Eingefügt', 2000); restoreIdle('✓ Eingefügt', 2000);
flowTrace.status = 'success';
beginPhase('confirmParticipationMs');
await confirmParticipationAfterAI(profileNumber); await confirmParticipationAfterAI(profileNumber);
endPhase('confirmParticipationMs');
} else { } else {
beginPhase('clipboardWriteMs');
await navigator.clipboard.writeText(comment); await navigator.clipboard.writeText(comment);
endPhase('clipboardWriteMs', { reason: 'set_comment_failed' });
throwIfCancelled(); throwIfCancelled();
showToast('📋 Einfügen fehlgeschlagen - In Zwischenablage kopiert', 'info'); showToast('📋 Einfügen fehlgeschlagen - In Zwischenablage kopiert', 'info');
restoreIdle('📋 Kopiert', 2000); restoreIdle('📋 Kopiert', 2000);
flowTrace.status = 'clipboard_fallback';
beginPhase('confirmParticipationMs');
await confirmParticipationAfterAI(profileNumber); await confirmParticipationAfterAI(profileNumber);
endPhase('confirmParticipationMs');
} }
} catch (error) { } catch (error) {
if (error && error.aiTrace) {
mergeTraceInfo(error.aiTrace);
}
const cancelled = aiContext.cancelled || isCancellationError(error); const cancelled = aiContext.cancelled || isCancellationError(error);
if (cancelled) { if (cancelled) {
console.log('[FB Tracker] AI comment operation cancelled'); console.log('[FB Tracker] AI comment operation cancelled');
flowTrace.status = 'cancelled';
flowTrace.frontendError = 'AI_CANCELLED';
restoreIdle('✋ Abgebrochen', 1500); restoreIdle('✋ Abgebrochen', 1500);
showToast('⏹️ Vorgang abgebrochen', 'info'); showToast('⏹️ Vorgang abgebrochen', 'info');
return; return;
} }
console.error('[FB Tracker] AI comment error:', error); console.error('[FB Tracker] AI comment error:', error);
flowTrace.status = 'error';
flowTrace.frontendError = error && error.message ? String(error.message) : 'Unbekannter Fehler';
showToast(`${error.message}`, 'error'); showToast(`${error.message}`, 'error');
restoreIdle(originalText); restoreIdle(originalText);
} finally {
flowTrace.finishedAt = new Date().toISOString();
flowTrace.totalDurationMs = roundDurationMs(nowPerformanceMs() - flowStartMs);
flowTrace.frontendTimings.totalMs = flowTrace.totalDurationMs;
if (!flowTrace.status || flowTrace.status === 'processing') {
flowTrace.status = 'finished';
}
addStep('flowComplete', {
status: flowTrace.status,
totalDurationMs: flowTrace.totalDurationMs
});
void reportAIDebugFrontendTrace({
traceId: flowTrace.traceId,
flowId: flowTrace.flowId,
source: flowTrace.source,
status: flowTrace.status,
requestMeta: {
...flowTrace.requestMeta,
startedAt: flowTrace.startedAt,
finishedAt: flowTrace.finishedAt,
backend: flowTrace.backend
},
frontendTimings: flowTrace.frontendTimings,
frontendSteps: flowTrace.frontendSteps,
frontendError: flowTrace.frontendError,
totalDurationMs: flowTrace.totalDurationMs
});
} }
}; };

View File

@@ -12,11 +12,13 @@ COPY login.html /usr/share/nginx/html/
COPY style.css /usr/share/nginx/html/ COPY style.css /usr/share/nginx/html/
COPY dashboard.css /usr/share/nginx/html/ COPY dashboard.css /usr/share/nginx/html/
COPY settings.css /usr/share/nginx/html/ COPY settings.css /usr/share/nginx/html/
COPY ai-debug.css /usr/share/nginx/html/
COPY daily-bookmarks.css /usr/share/nginx/html/ COPY daily-bookmarks.css /usr/share/nginx/html/
COPY automation.css /usr/share/nginx/html/ COPY automation.css /usr/share/nginx/html/
COPY app.js /usr/share/nginx/html/ COPY app.js /usr/share/nginx/html/
COPY dashboard.js /usr/share/nginx/html/ COPY dashboard.js /usr/share/nginx/html/
COPY settings.js /usr/share/nginx/html/ COPY settings.js /usr/share/nginx/html/
COPY ai-debug.js /usr/share/nginx/html/
COPY daily-bookmarks.js /usr/share/nginx/html/ COPY daily-bookmarks.js /usr/share/nginx/html/
COPY automation.js /usr/share/nginx/html/ COPY automation.js /usr/share/nginx/html/
COPY login.js /usr/share/nginx/html/ COPY login.js /usr/share/nginx/html/
@@ -28,6 +30,7 @@ RUN set -e; \
/usr/share/nginx/html/app.js \ /usr/share/nginx/html/app.js \
/usr/share/nginx/html/dashboard.js \ /usr/share/nginx/html/dashboard.js \
/usr/share/nginx/html/settings.js \ /usr/share/nginx/html/settings.js \
/usr/share/nginx/html/ai-debug.js \
/usr/share/nginx/html/daily-bookmarks.js \ /usr/share/nginx/html/daily-bookmarks.js \
/usr/share/nginx/html/automation.js \ /usr/share/nginx/html/automation.js \
/usr/share/nginx/html/login.js \ /usr/share/nginx/html/login.js \
@@ -35,6 +38,7 @@ RUN set -e; \
/usr/share/nginx/html/style.css \ /usr/share/nginx/html/style.css \
/usr/share/nginx/html/dashboard.css \ /usr/share/nginx/html/dashboard.css \
/usr/share/nginx/html/settings.css \ /usr/share/nginx/html/settings.css \
/usr/share/nginx/html/ai-debug.css \
/usr/share/nginx/html/daily-bookmarks.css \ /usr/share/nginx/html/daily-bookmarks.css \
/usr/share/nginx/html/automation.css \ /usr/share/nginx/html/automation.css \
| sha256sum | awk '{print $1}')"; \ | sha256sum | awk '{print $1}')"; \

180
web/ai-debug.css Normal file
View File

@@ -0,0 +1,180 @@
.ai-debug-shell {
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-debug-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.ai-debug-header h2 {
margin: 0;
}
.ai-debug-subtitle {
margin: 6px 0 0;
color: #64748b;
}
.ai-debug-toolbar {
display: flex;
gap: 10px;
align-items: flex-end;
flex-wrap: wrap;
}
.ai-debug-toolbar__item {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: #334155;
}
.ai-debug-toolbar__item select {
min-width: 120px;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 7px 10px;
background: #fff;
}
.ai-debug-status {
min-height: 20px;
color: #1f2937;
font-size: 14px;
}
.ai-debug-status--error {
color: #b91c1c;
}
.ai-debug-layout {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: 16px;
}
.ai-debug-list-panel,
.ai-debug-detail-panel {
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
padding: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.ai-debug-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.ai-debug-table th,
.ai-debug-table td {
padding: 9px 8px;
border-bottom: 1px solid #eef2f7;
text-align: left;
vertical-align: top;
}
.ai-debug-table th {
font-size: 12px;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #64748b;
}
.ai-debug-table tbody tr {
cursor: pointer;
}
.ai-debug-table tbody tr:hover {
background: #f8fafc;
}
.ai-debug-table tbody tr.is-selected {
background: #e0f2fe;
}
.ai-debug-empty {
text-align: center;
color: #64748b;
}
.ai-debug-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 2px 8px;
font-size: 11px;
line-height: 1.6;
background: #e2e8f0;
color: #0f172a;
}
.ai-debug-badge--success {
background: #dcfce7;
color: #166534;
}
.ai-debug-badge--clipboard_fallback {
background: #ffedd5;
color: #9a3412;
}
.ai-debug-badge--cancelled {
background: #f1f5f9;
color: #334155;
}
.ai-debug-badge--error,
.ai-debug-badge--backend_error {
background: #fee2e2;
color: #991b1b;
}
.ai-debug-badge--backend_rejected {
background: #fef3c7;
color: #92400e;
}
.ai-debug-detail-panel h3 {
margin: 0 0 8px;
}
.ai-debug-detail-meta {
color: #475569;
font-size: 13px;
margin-bottom: 10px;
}
.ai-debug-json {
margin: 0;
max-height: 60vh;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #0f172a;
color: #e2e8f0;
padding: 12px;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 1080px) {
.ai-debug-layout {
grid-template-columns: 1fr;
}
.ai-debug-json {
max-height: 42vh;
}
}

261
web/ai-debug.js Normal file
View File

@@ -0,0 +1,261 @@
(function () {
let active = false;
let initialized = false;
const API_URL = (() => {
if (window.API_URL) return window.API_URL;
try {
return `${window.location.origin}/api`;
} catch (error) {
return 'https://fb.srv.medeba-media.de/api';
}
})();
const LOGIN_PAGE = 'login.html';
const state = {
traces: [],
selectedTraceId: null,
loading: false
};
const tableBody = document.getElementById('aiDebugTableBody');
const statusEl = document.getElementById('aiDebugStatus');
const detailMeta = document.getElementById('aiDebugDetailMeta');
const detailJson = document.getElementById('aiDebugDetailJson');
const refreshBtn = document.getElementById('aiDebugRefreshBtn');
const statusFilter = document.getElementById('aiDebugStatusFilter');
const limitFilter = document.getElementById('aiDebugLimitFilter');
function handleUnauthorized(response) {
if (response && response.status === 401) {
if (typeof redirectToLogin === 'function') {
redirectToLogin();
} else {
window.location.href = LOGIN_PAGE;
}
return true;
}
return false;
}
async function apiFetchJSON(path, options = {}) {
const response = await fetch(`${API_URL}${path}`, {
credentials: 'include',
...options
});
if (handleUnauthorized(response)) {
throw new Error('Nicht angemeldet');
}
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || 'Unbekannter Fehler');
}
return response.json();
}
function setStatus(message, isError = false) {
if (!statusEl) return;
statusEl.textContent = message || '';
statusEl.classList.toggle('ai-debug-status--error', !!isError);
}
function formatDate(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function formatMs(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
return '—';
}
return `${Math.round(numeric)} ms`;
}
function compactId(value) {
if (!value || typeof value !== 'string') return '—';
if (value.length <= 16) return value;
return `${value.slice(0, 8)}${value.slice(-6)}`;
}
function getBackendTotalMs(item) {
return item
&& item.backend_timings
&& typeof item.backend_timings === 'object'
? item.backend_timings.totalMs
: null;
}
function getAiRequestMs(item) {
return item
&& item.frontend_timings
&& typeof item.frontend_timings === 'object'
? item.frontend_timings.aiRequestMs
: null;
}
function renderTable() {
if (!tableBody) return;
tableBody.innerHTML = '';
if (!state.traces.length) {
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="6" class="ai-debug-empty">Keine Debug-Läufe gefunden.</td>';
tableBody.appendChild(tr);
return;
}
state.traces.forEach((item) => {
const tr = document.createElement('tr');
tr.dataset.traceId = item.trace_id;
if (item.trace_id === state.selectedTraceId) {
tr.classList.add('is-selected');
}
tr.innerHTML = `
<td>${formatDate(item.created_at)}</td>
<td><span class="ai-debug-badge ai-debug-badge--${(item.status || 'unknown').replace(/[^a-z0-9_-]/gi, '')}">${item.status || '—'}</span></td>
<td>${formatMs(item.total_duration_ms)}</td>
<td>${formatMs(getBackendTotalMs(item))}</td>
<td>${formatMs(getAiRequestMs(item))}</td>
<td><code>${compactId(item.flow_id)}</code> / <code>${compactId(item.trace_id)}</code></td>
`;
tableBody.appendChild(tr);
});
}
function renderDetail(trace) {
if (!detailMeta || !detailJson) {
return;
}
if (!trace) {
detailMeta.textContent = 'Bitte einen Eintrag auswählen.';
detailJson.textContent = '';
return;
}
detailMeta.textContent = `${trace.status || '—'} · ${formatDate(trace.created_at)} · Trace ${trace.trace_id || '—'}`;
detailJson.textContent = JSON.stringify(trace, null, 2);
}
async function loadTraceDetail(traceId) {
if (!traceId) {
renderDetail(null);
return;
}
try {
const trace = await apiFetchJSON(`/ai/debug-traces/${encodeURIComponent(traceId)}`);
if (!active) return;
renderDetail(trace);
} catch (error) {
if (!active) return;
setStatus(`Details konnten nicht geladen werden: ${error.message}`, true);
}
}
async function loadTraces() {
if (!active || state.loading) {
return;
}
state.loading = true;
setStatus('Lade Debug-Läufe...');
try {
const params = new URLSearchParams();
const status = statusFilter ? statusFilter.value.trim() : '';
const limit = limitFilter ? parseInt(limitFilter.value, 10) : 50;
if (status) {
params.set('status', status);
}
if (Number.isFinite(limit) && limit > 0) {
params.set('limit', String(limit));
}
const data = await apiFetchJSON(`/ai/debug-traces?${params.toString()}`);
if (!active) return;
state.traces = Array.isArray(data.items) ? data.items : [];
if (!state.selectedTraceId || !state.traces.some((item) => item.trace_id === state.selectedTraceId)) {
state.selectedTraceId = state.traces.length ? state.traces[0].trace_id : null;
}
renderTable();
await loadTraceDetail(state.selectedTraceId);
setStatus(`${state.traces.length} Lauf/Läufe geladen.`);
} catch (error) {
if (!active) return;
state.traces = [];
renderTable();
renderDetail(null);
setStatus(`Debug-Läufe konnten nicht geladen werden: ${error.message}`, true);
} finally {
state.loading = false;
}
}
function setupEvents() {
if (refreshBtn) {
refreshBtn.addEventListener('click', () => loadTraces());
}
if (statusFilter) {
statusFilter.addEventListener('change', () => loadTraces());
}
if (limitFilter) {
limitFilter.addEventListener('change', () => loadTraces());
}
if (tableBody) {
tableBody.addEventListener('click', (event) => {
const row = event.target.closest('tr[data-trace-id]');
if (!row) return;
const traceId = row.dataset.traceId;
if (!traceId) return;
state.selectedTraceId = traceId;
renderTable();
loadTraceDetail(traceId);
});
}
}
function init() {
if (initialized) return;
setupEvents();
initialized = true;
}
function activate() {
init();
active = true;
loadTraces();
}
function deactivate() {
active = false;
}
window.AIDebugPage = {
activate,
deactivate
};
const section = document.querySelector('[data-view="ai-debug"]');
if (section && section.classList.contains('app-view--active')) {
activate();
}
})();

View File

@@ -22,6 +22,7 @@
{ href: 'style.css' }, { href: 'style.css' },
{ href: 'dashboard.css' }, { href: 'dashboard.css' },
{ href: 'settings.css' }, { href: 'settings.css' },
{ href: 'ai-debug.css' },
{ href: 'automation.css' }, { href: 'automation.css' },
{ href: 'daily-bookmarks.css', id: 'dailyBookmarksCss', disabled: true } { href: 'daily-bookmarks.css', id: 'dailyBookmarksCss', disabled: true }
]; ];
@@ -29,6 +30,7 @@
'app.js', 'app.js',
'dashboard.js', 'dashboard.js',
'settings.js', 'settings.js',
'ai-debug.js',
'vendor/list.min.js', 'vendor/list.min.js',
'automation.js', 'automation.js',
'daily-bookmarks.js' 'daily-bookmarks.js'
@@ -1098,6 +1100,9 @@
<button type="button" class="btn btn-secondary" id="testBtn"> <button type="button" class="btn btn-secondary" id="testBtn">
🧪 Kommentar testen 🧪 Kommentar testen
</button> </button>
<a class="btn btn-secondary" id="openAiDebugViewBtn" data-view-target="ai-debug" href="index.html?view=ai-debug">
🧭 AI-Debug öffnen
</a>
</div> </div>
</form> </form>
</section> </section>
@@ -1392,6 +1397,68 @@
</div> </div>
</div> </div>
</section> </section>
<section id="view-ai-debug" class="app-view ai-debug-view" data-view="ai-debug">
<div class="container">
<div class="ai-debug-shell">
<header class="ai-debug-header">
<div>
<h2>🧭 AI-Debug</h2>
<p class="ai-debug-subtitle">Zeitliche Ablaufanalyse für AI-Kommentar-Generierung (Backend + Extension).</p>
</div>
<div class="ai-debug-toolbar">
<label class="ai-debug-toolbar__item" for="aiDebugStatusFilter">
<span>Status</span>
<select id="aiDebugStatusFilter">
<option value="">Alle</option>
<option value="success">Erfolg</option>
<option value="clipboard_fallback">Clipboard Fallback</option>
<option value="cancelled">Abgebrochen</option>
<option value="error">Fehler</option>
<option value="backend_error">Backend-Fehler</option>
<option value="backend_rejected">Backend-Rejected</option>
</select>
</label>
<label class="ai-debug-toolbar__item" for="aiDebugLimitFilter">
<span>Einträge</span>
<select id="aiDebugLimitFilter">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</label>
<button type="button" class="btn btn-secondary" id="aiDebugRefreshBtn">Aktualisieren</button>
</div>
</header>
<div id="aiDebugStatus" class="ai-debug-status" role="status" aria-live="polite"></div>
<div class="ai-debug-layout">
<section class="ai-debug-list-panel">
<table class="ai-debug-table">
<thead>
<tr>
<th>Zeit</th>
<th>Status</th>
<th>Total (ms)</th>
<th>Backend (ms)</th>
<th>AI-Request (ms)</th>
<th>Flow/Trace</th>
</tr>
</thead>
<tbody id="aiDebugTableBody"></tbody>
</table>
</section>
<section class="ai-debug-detail-panel">
<h3>Ablaufdetails</h3>
<div id="aiDebugDetailMeta" class="ai-debug-detail-meta">Bitte einen Eintrag auswählen.</div>
<pre id="aiDebugDetailJson" class="ai-debug-json"></pre>
</section>
</div>
</div>
</div>
</section>
<section id="view-bookmarks" class="app-view" data-view="bookmarks"> <section id="view-bookmarks" class="app-view" data-view="bookmarks">
<div class="container"> <div class="container">
<main class="bookmark-page"> <main class="bookmark-page">
@@ -1468,6 +1535,7 @@
posts: 'Beiträge', posts: 'Beiträge',
dashboard: 'Dashboard', dashboard: 'Dashboard',
settings: 'Einstellungen', settings: 'Einstellungen',
'ai-debug': 'AI-Debug',
bookmarks: 'Bookmarks', bookmarks: 'Bookmarks',
automation: 'Automationen', automation: 'Automationen',
'daily-bookmarks': 'Daily Bookmarks' 'daily-bookmarks': 'Daily Bookmarks'
@@ -1549,6 +1617,7 @@
(function() { (function() {
const AUTOMATION_VIEW = 'automation'; const AUTOMATION_VIEW = 'automation';
const DAILY_VIEW = 'daily-bookmarks'; const DAILY_VIEW = 'daily-bookmarks';
const AI_DEBUG_VIEW = 'ai-debug';
function handleViewChange(event) { function handleViewChange(event) {
const view = event?.detail?.view; const view = event?.detail?.view;
if (view === AUTOMATION_VIEW) { if (view === AUTOMATION_VIEW) {
@@ -1561,6 +1630,11 @@
} else { } else {
window.DailyBookmarksPage?.deactivate?.(); window.DailyBookmarksPage?.deactivate?.();
} }
if (view === AI_DEBUG_VIEW) {
window.AIDebugPage?.activate?.();
} else {
window.AIDebugPage?.deactivate?.();
}
} }
window.addEventListener('app:view-change', handleViewChange); window.addEventListener('app:view-change', handleViewChange);
@@ -1573,6 +1647,10 @@
if (dailySection && dailySection.classList.contains('app-view--active')) { if (dailySection && dailySection.classList.contains('app-view--active')) {
window.DailyBookmarksPage?.activate?.(); window.DailyBookmarksPage?.activate?.();
} }
const aiDebugSection = document.querySelector('[data-view="ai-debug"]');
if (aiDebugSection && aiDebugSection.classList.contains('app-view--active')) {
window.AIDebugPage?.activate?.();
}
})(); })();
</script> </script>
</body> </body>