Add end-to-end AI timing traces and AI debug view
This commit is contained in:
1055
backend/server.js
1055
backend/server.js
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
180
web/ai-debug.css
Normal 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
261
web/ai-debug.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user