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

File diff suppressed because it is too large Load Diff

View File

@@ -5332,47 +5332,185 @@ function sanitizeAIComment(comment) {
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
*/
async function generateAIComment(postText, profileNumber, options = {}) {
const { signal = null, preferredCredentialId = null, maxAttempts = 3 } = options;
const payload = {
const {
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,
profileNumber
profileNumber,
flowId: normalizedFlowId,
traceSource: source
};
if (typeof preferredCredentialId === 'number') {
payload.preferredCredentialId = preferredCredentialId;
if (typeof preferredCredentialId === 'number' && !Number.isNaN(preferredCredentialId)) {
basePayload.preferredCredentialId = preferredCredentialId;
}
const requestAttempts = [];
let lastError = null;
let lastTraceId = null;
const attempts = Math.max(1, maxAttempts);
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 {
const response = await backendFetch(`${API_URL}/ai/generate-comment`, {
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),
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) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to generate comment');
const errorData = await response.json().catch(() => ({}));
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 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) {
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');
} 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;
if (cancelled) {
break;
}
}
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);
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) {
@@ -5402,7 +5559,9 @@ async function handleSelectionAIRequest(selectionText, sendResponse) {
sendResponse({ error: 'profile-missing' });
return;
}
const comment = await generateAIComment(normalizedSelection, profileNumber, {});
const comment = await generateAIComment(normalizedSelection, profileNumber, {
source: 'selection-ai'
});
if (!comment) {
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 = () => {
if (aiContext.cancelled) {
const cancelError = new Error('AI_CANCELLED');
@@ -6170,6 +6396,7 @@ async function addAICommentButton(container, postElement) {
return null;
};
beginPhase('extractPostTextMs');
let postText = '';
const cachedSelection = resolveRecentSelection();
if (cachedSelection) {
@@ -6219,25 +6446,43 @@ async function addAICommentButton(container, postElement) {
postText = `${postText}\n\nZusatzinfo:\n${additionalNote}`;
}
flowTrace.requestMeta.postTextLength = postText.length;
endPhase('extractPostTextMs', { postTextLength: postText.length });
throwIfCancelled();
console.log('[FB Tracker] Generating AI comment for:', postText.substring(0, 100));
beginPhase('profileLookupMs');
const profileNumber = await getProfileNumber();
endPhase('profileLookupMs', { profileNumber: profileNumber || null });
if (!profileNumber) {
flowTrace.status = 'profile_missing';
flowTrace.frontendError = 'Profilstatus nicht geladen';
showToast('Profilstatus nicht geladen. Bitte Tracker neu laden.', 'error');
restoreIdle(originalText);
return;
}
throwIfCancelled();
const comment = await generateAIComment(postText, profileNumber, {
beginPhase('aiRequestMs');
const aiResult = await generateAIComment(postText, profileNumber, {
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();
const comment = aiResult.comment;
console.log('[FB Tracker] Generated comment:', comment);
const dialogRoot = container.closest(DIALOG_ROOT_SELECTOR)
@@ -6245,6 +6490,7 @@ async function addAICommentButton(container, postElement) {
let commentInput = findCommentInput(postContext, { preferredRoot: dialogRoot });
let waitedForInput = false;
let waitStartedMs = null;
if (!commentInput) {
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...');
waitedForInput = true;
waitStartedMs = nowPerformanceMs();
commentInput = await waitForCommentInput(postContext, {
encodedPostUrl,
timeout: buttonClicked ? 8000 : 5000,
@@ -6273,6 +6520,7 @@ async function addAICommentButton(container, postElement) {
if (!commentInput && !waitedForInput) {
updateProcessingText('⏳ Suche Kommentarfeld...');
waitedForInput = true;
waitStartedMs = nowPerformanceMs();
commentInput = await waitForCommentInput(postContext, {
encodedPostUrl,
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();
if (!commentInput) {
throwIfCancelled();
beginPhase('clipboardWriteMs');
await navigator.clipboard.writeText(comment);
endPhase('clipboardWriteMs', { reason: 'input_missing' });
throwIfCancelled();
showToast('📋 Kommentarfeld nicht gefunden - In Zwischenablage kopiert', 'info');
restoreIdle('📋 Kopiert', 2000);
flowTrace.status = 'clipboard_fallback';
beginPhase('confirmParticipationMs');
await confirmParticipationAfterAI(profileNumber);
endPhase('confirmParticipationMs');
return;
}
@@ -6298,33 +6561,79 @@ async function addAICommentButton(container, postElement) {
updateProcessingText('⏳ Füge Kommentar ein...');
}
beginPhase('setCommentTextMs');
const success = await setCommentText(commentInput, comment, { context: aiContext });
endPhase('setCommentTextMs', { success: Boolean(success) });
throwIfCancelled();
if (success) {
showToast('✓ Kommentar wurde eingefügt', 'success');
restoreIdle('✓ Eingefügt', 2000);
flowTrace.status = 'success';
beginPhase('confirmParticipationMs');
await confirmParticipationAfterAI(profileNumber);
endPhase('confirmParticipationMs');
} else {
beginPhase('clipboardWriteMs');
await navigator.clipboard.writeText(comment);
endPhase('clipboardWriteMs', { reason: 'set_comment_failed' });
throwIfCancelled();
showToast('📋 Einfügen fehlgeschlagen - In Zwischenablage kopiert', 'info');
restoreIdle('📋 Kopiert', 2000);
flowTrace.status = 'clipboard_fallback';
beginPhase('confirmParticipationMs');
await confirmParticipationAfterAI(profileNumber);
endPhase('confirmParticipationMs');
}
} catch (error) {
if (error && error.aiTrace) {
mergeTraceInfo(error.aiTrace);
}
const cancelled = aiContext.cancelled || isCancellationError(error);
if (cancelled) {
console.log('[FB Tracker] AI comment operation cancelled');
flowTrace.status = 'cancelled';
flowTrace.frontendError = 'AI_CANCELLED';
restoreIdle('✋ Abgebrochen', 1500);
showToast('⏹️ Vorgang abgebrochen', 'info');
return;
}
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');
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 dashboard.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 automation.css /usr/share/nginx/html/
COPY app.js /usr/share/nginx/html/
COPY dashboard.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 automation.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/dashboard.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/automation.js \
/usr/share/nginx/html/login.js \
@@ -35,6 +38,7 @@ RUN set -e; \
/usr/share/nginx/html/style.css \
/usr/share/nginx/html/dashboard.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/automation.css \
| 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: 'dashboard.css' },
{ href: 'settings.css' },
{ href: 'ai-debug.css' },
{ href: 'automation.css' },
{ href: 'daily-bookmarks.css', id: 'dailyBookmarksCss', disabled: true }
];
@@ -29,6 +30,7 @@
'app.js',
'dashboard.js',
'settings.js',
'ai-debug.js',
'vendor/list.min.js',
'automation.js',
'daily-bookmarks.js'
@@ -1098,6 +1100,9 @@
<button type="button" class="btn btn-secondary" id="testBtn">
🧪 Kommentar testen
</button>
<a class="btn btn-secondary" id="openAiDebugViewBtn" data-view-target="ai-debug" href="index.html?view=ai-debug">
🧭 AI-Debug öffnen
</a>
</div>
</form>
</section>
@@ -1392,6 +1397,68 @@
</div>
</div>
</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">
<div class="container">
<main class="bookmark-page">
@@ -1468,6 +1535,7 @@
posts: 'Beiträge',
dashboard: 'Dashboard',
settings: 'Einstellungen',
'ai-debug': 'AI-Debug',
bookmarks: 'Bookmarks',
automation: 'Automationen',
'daily-bookmarks': 'Daily Bookmarks'
@@ -1549,6 +1617,7 @@
(function() {
const AUTOMATION_VIEW = 'automation';
const DAILY_VIEW = 'daily-bookmarks';
const AI_DEBUG_VIEW = 'ai-debug';
function handleViewChange(event) {
const view = event?.detail?.view;
if (view === AUTOMATION_VIEW) {
@@ -1561,6 +1630,11 @@
} else {
window.DailyBookmarksPage?.deactivate?.();
}
if (view === AI_DEBUG_VIEW) {
window.AIDebugPage?.activate?.();
} else {
window.AIDebugPage?.deactivate?.();
}
}
window.addEventListener('app:view-change', handleViewChange);
@@ -1573,6 +1647,10 @@
if (dailySection && dailySection.classList.contains('app-view--active')) {
window.DailyBookmarksPage?.activate?.();
}
const aiDebugSection = document.querySelector('[data-view="ai-debug"]');
if (aiDebugSection && aiDebugSection.classList.contains('app-view--active')) {
window.AIDebugPage?.activate?.();
}
})();
</script>
</body>