From b551f0fe2dcb82cac0f920d89dee9e1763e410fb Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 28 Mar 2026 21:14:56 +0200 Subject: [PATCH] feat(llm): basic Markdown rendering --- .../type_widgets/llm_chat/ChatMessage.tsx | 29 +++- .../widgets/type_widgets/llm_chat/LlmChat.css | 124 +++++++++++++++++- 2 files changed, 150 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx index 3e5c6affdf..10e76bcae4 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx @@ -1,7 +1,15 @@ +import { useMemo } from "preact/hooks"; +import { marked } from "marked"; import { t } from "../../../services/i18n.js"; import type { Citation } from "../../../services/llm_chat.js"; import "./LlmChat.css"; +// Configure marked for safe rendering +marked.setOptions({ + breaks: true, // Convert \n to
+ gfm: true // GitHub Flavored Markdown +}); + interface StoredMessage { id: string; role: "user" | "assistant" | "system"; @@ -18,14 +26,31 @@ interface Props { export default function ChatMessage({ message, isStreaming }: Props) { const roleLabel = message.role === "user" ? "You" : "Assistant"; + // Only render markdown for assistant messages + const renderedContent = useMemo(() => { + if (message.role === "assistant") { + return marked.parse(message.content) as string; + } + return null; + }, [message.content, message.role]); + return (
{roleLabel}
- {message.content} - {isStreaming && } + {message.role === "assistant" ? ( + <> +
+ {isStreaming && } + + ) : ( + message.content + )}
{message.citations && message.citations.length > 0 && (
diff --git a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css index 13c84658cc..79a6914c5d 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css +++ b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css @@ -48,11 +48,15 @@ } .llm-chat-message-content { - white-space: pre-wrap; word-wrap: break-word; line-height: 1.5; } +/* Preserve whitespace only for user messages (plain text) */ +.llm-chat-message-user .llm-chat-message-content { + white-space: pre-wrap; +} + .llm-chat-cursor { display: inline-block; width: 8px; @@ -243,3 +247,121 @@ opacity: 0.5; cursor: not-allowed; } + +/* Markdown styles */ +.llm-chat-markdown { + line-height: 1.6; +} + +.llm-chat-markdown p { + margin: 0 0 0.75em 0; +} + +.llm-chat-markdown p:last-child { + margin-bottom: 0; +} + +.llm-chat-markdown h1, +.llm-chat-markdown h2, +.llm-chat-markdown h3, +.llm-chat-markdown h4, +.llm-chat-markdown h5, +.llm-chat-markdown h6 { + margin: 1em 0 0.5em 0; + font-weight: 600; + line-height: 1.3; +} + +.llm-chat-markdown h1:first-child, +.llm-chat-markdown h2:first-child, +.llm-chat-markdown h3:first-child { + margin-top: 0; +} + +.llm-chat-markdown h1 { font-size: 1.4em; } +.llm-chat-markdown h2 { font-size: 1.25em; } +.llm-chat-markdown h3 { font-size: 1.1em; } + +.llm-chat-markdown ul, +.llm-chat-markdown ol { + margin: 0.5em 0; + padding-left: 1.5em; +} + +.llm-chat-markdown li { + margin: 0.25em 0; +} + +.llm-chat-markdown code { + background: var(--accented-background-color); + padding: 0.15em 0.4em; + border-radius: 4px; + font-family: var(--monospace-font-family, monospace); + font-size: 0.9em; +} + +.llm-chat-markdown pre { + background: var(--accented-background-color); + padding: 0.75em 1em; + border-radius: 6px; + overflow-x: auto; + margin: 0.75em 0; +} + +.llm-chat-markdown pre code { + background: none; + padding: 0; + font-size: 0.85em; +} + +.llm-chat-markdown blockquote { + margin: 0.75em 0; + padding: 0.5em 1em; + border-left: 3px solid var(--main-border-color); + background: var(--accented-background-color); +} + +.llm-chat-markdown blockquote p { + margin: 0; +} + +.llm-chat-markdown a { + color: var(--link-color, #007bff); + text-decoration: none; +} + +.llm-chat-markdown a:hover { + text-decoration: underline; +} + +.llm-chat-markdown hr { + border: none; + border-top: 1px solid var(--main-border-color); + margin: 1em 0; +} + +.llm-chat-markdown table { + border-collapse: collapse; + width: 100%; + margin: 0.75em 0; +} + +.llm-chat-markdown th, +.llm-chat-markdown td { + border: 1px solid var(--main-border-color); + padding: 0.5em 0.75em; + text-align: left; +} + +.llm-chat-markdown th { + background: var(--accented-background-color); + font-weight: 600; +} + +.llm-chat-markdown strong { + font-weight: 600; +} + +.llm-chat-markdown em { + font-style: italic; +}