diff --git a/frontend/src/components/Chat/MessageList.styles.ts b/frontend/src/components/Chat/MessageList.styles.ts index 7273fa158..83611260e 100644 --- a/frontend/src/components/Chat/MessageList.styles.ts +++ b/frontend/src/components/Chat/MessageList.styles.ts @@ -32,6 +32,21 @@ export const useMessageListStyles = makeStyles({ whiteSpace: 'pre-wrap', wordBreak: 'break-word', }, + messageJsonBlock: { + margin: 0, + padding: tokens.spacingHorizontalS, + backgroundColor: tokens.colorNeutralBackground1, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusMedium, + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + overflowX: 'auto', + maxHeight: '400px', + overflowY: 'auto', + }, messageFooter: { display: 'flex', justifyContent: 'space-between', diff --git a/frontend/src/components/Chat/MessageList.test.tsx b/frontend/src/components/Chat/MessageList.test.tsx index fd68e15ab..612cda296 100644 --- a/frontend/src/components/Chat/MessageList.test.tsx +++ b/frontend/src/components/Chat/MessageList.test.tsx @@ -87,6 +87,152 @@ describe("MessageList", () => { expect(screen.getByText("Assistant message test")).toBeInTheDocument(); }); + describe("structured JSON assistant responses", () => { + // Targets like PromptShieldTarget return structured JSON instead of + // natural-language text. Render these as pretty-printed JSON in a
+ // so the user can actually read them.
+
+ it("renders JSON object responses as pretty-printed ", () => {
+ const messages: Message[] = [
+ {
+ role: "assistant",
+ content: '{"userPromptAnalysis":{"attackDetected":false},"documentsAnalysis":[]}',
+ timestamp: new Date().toISOString(),
+ },
+ ];
+ render(
+
+
+
+ );
+ const block = screen.getByTestId("message-json-0");
+ expect(block.tagName).toBe("PRE");
+ // Pretty-printed (2-space indent) and round-trips to the original payload.
+ const text = block.textContent ?? "";
+ expect(text).toContain('"userPromptAnalysis": {\n');
+ expect(text).toContain('"attackDetected": false');
+ expect(JSON.parse(text)).toEqual({
+ userPromptAnalysis: { attackDetected: false },
+ documentsAnalysis: [],
+ });
+ });
+
+ it("renders JSON array responses as pretty-printed ", () => {
+ const messages: Message[] = [
+ {
+ role: "assistant",
+ content: '[{"label":"safe","score":0.97},{"label":"unsafe","score":0.03}]',
+ timestamp: new Date().toISOString(),
+ },
+ ];
+ render(
+
+
+
+ );
+ const block = screen.getByTestId("message-json-0");
+ expect(block.tagName).toBe("PRE");
+ expect(JSON.parse(block.textContent ?? "")).toEqual([
+ { label: "safe", score: 0.97 },
+ { label: "unsafe", score: 0.03 },
+ ]);
+ });
+
+ it("does not reformat plain text assistant content", () => {
+ const messages: Message[] = [
+ {
+ role: "assistant",
+ content: "Hello there!",
+ timestamp: new Date().toISOString(),
+ },
+ ];
+ render(
+
+
+
+ );
+ expect(screen.queryByTestId("message-json-0")).not.toBeInTheDocument();
+ expect(screen.getByText("Hello there!")).toBeInTheDocument();
+ });
+
+ it("does not reformat malformed JSON-shaped content", () => {
+ const messages: Message[] = [
+ {
+ role: "assistant",
+ content: "{not really json",
+ timestamp: new Date().toISOString(),
+ },
+ ];
+ render(
+
+
+
+ );
+ expect(screen.queryByTestId("message-json-0")).not.toBeInTheDocument();
+ expect(screen.getByText("{not really json")).toBeInTheDocument();
+ });
+
+ it("does not reformat user messages even if they are JSON-shaped", () => {
+ // A user pasting JSON into the input shouldn't have it silently
+ // reformatted in their own bubble.
+ const messages: Message[] = [
+ {
+ role: "user",
+ content: '{"prompt":"hello"}',
+ timestamp: new Date().toISOString(),
+ },
+ ];
+ render(
+
+
+
+ );
+ expect(screen.queryByTestId("message-json-0")).not.toBeInTheDocument();
+ expect(screen.getByText('{"prompt":"hello"}')).toBeInTheDocument();
+ });
+
+ it("does not reformat scalar JSON values", () => {
+ // "true", "42", '"hello"' are all valid JSON but rendering them as
+ // pretty-printed JSON gains nothing — keep them as plain text.
+ for (const scalar of ["true", "42", '"hello"', "null"]) {
+ const { unmount } = render(
+
+
+
+ );
+ expect(screen.queryByTestId("message-json-0")).not.toBeInTheDocument();
+ unmount();
+ }
+ });
+
+ it("does not reformat content while a message is still loading", () => {
+ // Streaming responses pass through with isLoading=true; the
+ // intermediate text may temporarily look JSON-ish.
+ const messages: Message[] = [
+ {
+ role: "assistant",
+ content: '{"partial":',
+ isLoading: true,
+ timestamp: new Date().toISOString(),
+ },
+ ];
+ render(
+
+
+
+ );
+ expect(screen.queryByTestId("message-json-0")).not.toBeInTheDocument();
+ });
+ });
+
it("should handle messages with image attachments", () => {
const messagesWithAttachments: Message[] = [
{
diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx
index 6e2a54086..5f60b3373 100644
--- a/frontend/src/components/Chat/MessageList.tsx
+++ b/frontend/src/components/Chat/MessageList.tsx
@@ -80,6 +80,30 @@ function MediaWithFallback({ type, src, className }: { type: 'video' | 'audio';
return
}
+/**
+ * If the trimmed text is a JSON object or array, return a 2-space pretty-printed
+ * version of it; otherwise return null. Used to render structured assistant
+ * responses (e.g. PromptShield verdicts) as readable JSON instead of a single
+ * line of compact text.
+ */
+function tryFormatJson(text: string): string | null {
+ const trimmed = text.trim()
+ if (!trimmed) return null
+ const first = trimmed[0]
+ const last = trimmed[trimmed.length - 1]
+ // Cheap pre-check: only attempt parsing for object- or array-shaped content
+ // so things like "1" or "true" (which are valid JSON) are still rendered as
+ // plain text.
+ if (!((first === '{' && last === '}') || (first === '[' && last === ']'))) {
+ return null
+ }
+ try {
+ return JSON.stringify(JSON.parse(trimmed), null, 2)
+ } catch {
+ return null
+ }
+}
+
export default function MessageList({ messages, onCopyToInput, onCopyToNewConversation, onBranchConversation, onBranchAttack, isLoading, isSingleTurn, isOperatorLocked, isCrossTarget, noTargetSelected }: MessageListProps) {
const styles = useMessageListStyles()
const messagesEndRef = useRef(null)
@@ -204,11 +228,32 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver
)}
{/* Text content (converted / primary) */}
- {message.content && (
-
- {message.content}
-
- )}
+ {message.content && (() => {
+ if (message.isLoading) {
+ return (
+
+ {message.content}
+
+ )
+ }
+ // For assistant / simulated_assistant messages, detect
+ // structured JSON responses (e.g. PromptShield verdicts) and
+ // render them pretty-printed inside a so the user can
+ // actually read them. User-typed JSON is left as-is.
+ const formatted = !isUser ? tryFormatJson(message.content) : null
+ if (formatted !== null) {
+ return (
+
+ {formatted}
+
+ )
+ }
+ return (
+
+ {message.content}
+
+ )
+ })()}
{/* Attachments (images, audio, video, files) */}
{message.attachments && message.attachments.length > 0 && (