Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions frontend/src/components/Chat/MessageList.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
146 changes: 146 additions & 0 deletions frontend/src/components/Chat/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pre>
// so the user can actually read them.

it("renders JSON object responses as pretty-printed <pre>", () => {
const messages: Message[] = [
{
role: "assistant",
content: '{"userPromptAnalysis":{"attackDetected":false},"documentsAnalysis":[]}',
timestamp: new Date().toISOString(),
},
];
render(
<TestWrapper>
<MessageList messages={messages} />
</TestWrapper>
);
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 <pre>", () => {
const messages: Message[] = [
{
role: "assistant",
content: '[{"label":"safe","score":0.97},{"label":"unsafe","score":0.03}]',
timestamp: new Date().toISOString(),
},
];
render(
<TestWrapper>
<MessageList messages={messages} />
</TestWrapper>
);
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(
<TestWrapper>
<MessageList messages={messages} />
</TestWrapper>
);
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(
<TestWrapper>
<MessageList messages={messages} />
</TestWrapper>
);
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(
<TestWrapper>
<MessageList messages={messages} />
</TestWrapper>
);
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(
<TestWrapper>
<MessageList
messages={[
{
role: "assistant",
content: scalar,
timestamp: new Date().toISOString(),
},
]}
/>
</TestWrapper>
);
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(
<TestWrapper>
<MessageList messages={messages} />
</TestWrapper>
);
expect(screen.queryByTestId("message-json-0")).not.toBeInTheDocument();
});
});

it("should handle messages with image attachments", () => {
const messagesWithAttachments: Message[] = [
{
Expand Down
55 changes: 50 additions & 5 deletions frontend/src/components/Chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,30 @@ function MediaWithFallback({ type, src, className }: { type: 'video' | 'audio';
return <audio src={src} controls onError={handleError} data-testid="audio-player" />
}

/**
* 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<HTMLDivElement>(null)
Expand Down Expand Up @@ -204,11 +228,32 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver
)}

{/* Text content (converted / primary) */}
{message.content && (
<Text className={message.isLoading ? styles.loadingEllipsis : styles.messageText}>
{message.content}
</Text>
)}
{message.content && (() => {
if (message.isLoading) {
return (
<Text className={styles.loadingEllipsis}>
{message.content}
</Text>
)
}
// For assistant / simulated_assistant messages, detect
// structured JSON responses (e.g. PromptShield verdicts) and
// render them pretty-printed inside a <pre> 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 (
<pre className={styles.messageJsonBlock} data-testid={`message-json-${index}`}>
{formatted}
</pre>
)
}
return (
<Text className={styles.messageText}>
{message.content}
</Text>
)
})()}

{/* Attachments (images, audio, video, files) */}
{message.attachments && message.attachments.length > 0 && (
Expand Down
Loading