"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { ChatBubble } from "@/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble";
import { buildRequestTurns, buildResponseTurns } from "@/mitm/inspector/conversationNormalizer";
import type { InterceptedRequest, NormalizedTurn } from "@/mitm/inspector/types";
// ─── Payload Code Block ─────────────────────────────────────────────────────
export function PayloadSection({ title, json, onCopy, collapsible = true, defaultOpen = true }) {
const t = useTranslations("requestLogger.detail");
const [copied, setCopied] = useState(false);
const [open, setOpen] = useState(defaultOpen);
const handleCopy = async () => {
const success = await onCopy();
if (success !== false) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
{title}
{collapsible && (
)}
{open && (
{json}
)}
);
}
// ─── Conversation context section ───────────────────────────────────────────
// Renders THIS request's own context (its request body's messages/input, plus
// its response) — a plain single-request normalization, same shape as the
// traffic-inspector's ConversationTab, no cross-request reconstruction. While
// the request is still generating (detail.active === true) the response side
// shows the partial text captured so far, refreshed on a short poll scoped to
// just this section.
const CONVERSATION_ACTIVE_POLL_INTERVAL_MS = 1200;
function asInterceptedResponseBody(responseBody: unknown): InterceptedRequest {
return {
id: "",
source: "custom-host",
timestamp: "",
method: "POST",
host: "",
path: "",
requestHeaders: {},
requestBody: null,
requestSize: 0,
responseHeaders: {},
responseBody: responseBody != null ? JSON.stringify(responseBody) : null,
responseSize: 0,
status: 0,
detectedKind: "llm",
};
}
export function ConversationContextSection({ log, detail }) {
const [open, setOpen] = useState(true);
const [liveDetail, setLiveDetail] = useState(detail);
const [liveRefresh, setLiveRefresh] = useState(() => {
try {
const v = localStorage.getItem("pref:conversationContext:liveRefresh");
return v == null ? true : v === "1";
} catch {
return true;
}
});
const turnsBoxRef = useRef(null);
useEffect(() => {
setLiveDetail(detail);
}, [detail]);
// Same live-poll pattern as the SSE Events section (StreamSection below),
// but gated on liveRefresh too: an active request keeps generating either
// way, this toggle only controls whether THIS panel keeps fetching/
// redrawing while the user reads it.
useEffect(() => {
if (!liveDetail?.active || !liveRefresh) return;
let cancelled = false;
let timeoutId: ReturnType | undefined;
const tick = () => {
if (cancelled) return;
if (document.visibilityState !== "visible") {
timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
return;
}
fetch(`/api/logs/${log.id}`, { cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (cancelled || !data) return;
setLiveDetail(data);
if (data.active) timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
})
.catch(() => {
timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
});
};
timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS);
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}, [liveDetail?.active, liveRefresh, log.id]);
const toggleLiveRefresh = () => {
const next = !liveRefresh;
setLiveRefresh(next);
try {
localStorage.setItem("pref:conversationContext:liveRefresh", next ? "1" : "0");
} catch {}
};
const scrollToBottom = () => {
const el = turnsBoxRef.current;
if (!el) return;
requestAnimationFrame(() => {
try {
el.scrollTop = el.scrollHeight;
} catch {}
});
};
const requestBody =
liveDetail?.requestBody ?? liveDetail?.pipelinePayloads?.clientRequest ?? null;
const requestTurns = buildRequestTurns(requestBody) ?? [];
const responseBody = liveDetail?.responseBody ?? null;
const responseTurns: NormalizedTurn[] =
responseBody != null
? buildResponseTurns(asInterceptedResponseBody(responseBody))
: liveDetail?.partialAssistantText
? [
{
role: "assistant",
blocks: [{ type: "text", text: liveDetail.partialAssistantText }],
},
]
: [];
const allTurns: NormalizedTurn[] = [...requestTurns, ...responseTurns];
// Follow new content as it streams in — same idea as StreamSection's
// autoscroll effect, tied to the same liveRefresh toggle.
useEffect(() => {
if (!liveRefresh || !open) return;
scrollToBottom();
}, [allTurns.length, liveDetail?.partialAssistantText, liveRefresh, open]);
if (allTurns.length === 0) return null;
return (
Conversation Context
{open && (
{liveDetail?.active && (
)}
)}
{open && (
{allTurns.map((turn, i) => (
))}
)}
);
}