Assistant answers render as markdown
react-markdown, lazy-loaded with the first answer (37 kB gzip chunk, main bundle unchanged). No raw HTML, images dropped, links open in a new tab. The system prompt asks for lists, inline code and fenced code blocks.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useRouterState } from "@tanstack/react-router"
|
||||
import { Bird, Bot, MessageCircle, Send, Square, Trash2 } from "lucide-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { AiService, type ChatItem } from "@/client"
|
||||
import { flowKeys } from "@/components/Flow/queries"
|
||||
@@ -23,6 +23,9 @@ import { type ChatTab, chatStore, useChat } from "./chatStore"
|
||||
import { type ChatEvent, streamTurn } from "./stream"
|
||||
import { type Action, type ControlItem, useControl } from "./useControl"
|
||||
|
||||
/** The renderer arrives with the first answer, not with the app. */
|
||||
const Markdown = lazy(() => import("./Markdown"))
|
||||
|
||||
export const aiStatusQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["ai", "status"],
|
||||
@@ -382,8 +385,14 @@ function Message({ item }: { item: Item }) {
|
||||
)
|
||||
case "assistant":
|
||||
return (
|
||||
<div className="self-start whitespace-pre-wrap break-words px-1 text-sm">
|
||||
{item.text}
|
||||
<div className="min-w-0 max-w-full self-start px-1 text-sm">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="whitespace-pre-wrap break-words">{item.text}</div>
|
||||
}
|
||||
>
|
||||
<Markdown text={item.text} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
case "error":
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import ReactMarkdown, { type Components } from "react-markdown"
|
||||
|
||||
/**
|
||||
* An assistant's answer as markdown, compact and on the panel's text size.
|
||||
*
|
||||
* Raw HTML is never rendered, and images are dropped so an answer cannot make
|
||||
* the browser fetch a URL on its own. A link opens in a new tab when clicked.
|
||||
*/
|
||||
const components: Components = {
|
||||
a: ({ node: _, ...props }) => (
|
||||
<a
|
||||
{...props}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2"
|
||||
/>
|
||||
),
|
||||
ul: ({ node: _, ...props }) => (
|
||||
<ul {...props} className="list-disc space-y-1 pl-5" />
|
||||
),
|
||||
ol: ({ node: _, ...props }) => (
|
||||
<ol {...props} className="list-decimal space-y-1 pl-5" />
|
||||
),
|
||||
h1: ({ node: _, ...props }) => <h3 {...props} className="font-semibold" />,
|
||||
h2: ({ node: _, ...props }) => <h3 {...props} className="font-semibold" />,
|
||||
h3: ({ node: _, ...props }) => <h3 {...props} className="font-semibold" />,
|
||||
h4: ({ node: _, ...props }) => <h4 {...props} className="font-medium" />,
|
||||
blockquote: ({ node: _, ...props }) => (
|
||||
<blockquote
|
||||
{...props}
|
||||
className="border-l-2 border-border pl-3 text-muted-foreground"
|
||||
/>
|
||||
),
|
||||
hr: () => <hr className="border-border" />,
|
||||
pre: ({ node: _, ...props }) => (
|
||||
<pre
|
||||
{...props}
|
||||
className="overflow-x-auto rounded-md bg-muted p-2 font-mono text-xs [&>code]:bg-transparent [&>code]:p-0"
|
||||
/>
|
||||
),
|
||||
code: ({ node: _, ...props }) => (
|
||||
<code
|
||||
{...props}
|
||||
className="rounded-sm bg-muted px-1 py-0.5 font-mono text-xs"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export default function Markdown({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-2 break-words">
|
||||
<ReactMarkdown components={components} disallowedElements={["img"]}>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user