feat(web): render assistant Markdown

This commit is contained in:
Yichen Jiang
2026-07-23 00:39:27 +08:00
parent c5f5b29588
commit 6f624c67c4
15 changed files with 712 additions and 14 deletions
@@ -15,5 +15,6 @@ export type { MenuItem } from './Menu.tsx'
export { ConnectionBanner } from './ConnectionBanner.tsx'
export { FishLogo } from './FishLogo.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export { MessageText } from './markdown/MessageText.tsx'
export * from './icons/index.tsx'
@@ -0,0 +1,123 @@
.markdown {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
overflow-wrap: anywhere;
font: var(--dsw-font-markdown-base);
}
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) {
margin: 0;
}
.markdown h1 {
font: var(--dsw-font-markdown-h1);
}
.markdown h2 {
font: var(--dsw-font-markdown-h2);
}
.markdown h3 {
font: var(--dsw-font-markdown-h3);
}
.markdown :where(h4, h5, h6) {
font: var(--dsw-font-markdown-h4);
}
.markdown :where(strong, th) {
font-weight: var(--dsw-font-markdown-base-strong-font-weight);
}
.markdown :where(ul, ol) {
padding-inline-start: 24px;
}
.markdown li + li {
margin-block-start: 4px;
}
.markdown li > :where(ul, ol) {
margin-block-start: 4px;
}
.markdown blockquote {
padding-inline-start: 12px;
border-inline-start: 3px solid var(--dsw-alias-markdown-citation);
color: var(--dsw-alias-label-secondary);
}
.markdown a {
color: var(--dsw-alias-state-business-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown :not(pre) > code {
padding: 2px 4px;
border-radius: 4px;
background: var(--dsw-alias-markdown-inline-code);
font: var(--dsw-font-markdown-code);
}
.markdown pre {
max-width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
padding: 12px 16px;
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block);
}
.markdown pre code {
padding: 0;
background: transparent;
font: inherit;
overflow-wrap: normal;
word-break: normal;
white-space: pre;
}
.markdown hr {
width: 100%;
border: 0;
border-block-start: 1px solid var(--dsw-alias-markdown-citation);
}
.markdown input[type='checkbox'] {
margin: 0 8px 0 0;
accent-color: var(--dsw-alias-state-business-primary);
}
.tableScroll {
max-width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
}
.tableScroll table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
font: var(--dsw-font-markdown-table);
}
.tableScroll :where(th, td) {
padding: 6px 12px;
border: 1px solid var(--dsw-alias-markdown-citation);
text-align: start;
white-space: nowrap;
}
.tableScroll th {
background: var(--dsw-alias-markdown-code-block-banner);
font: var(--dsw-font-markdown-table-head);
}
.imageAlt {
color: var(--dsw-alias-label-tertiary);
font-style: italic;
}
@@ -0,0 +1,64 @@
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import css from './MarkdownText.module.css'
const remarkPlugins = [remarkGfm]
function sanitizeUrl(url: string): string {
try {
switch (new URL(url).protocol) {
case 'http:':
case 'https:':
case 'mailto:':
return url
default:
return ''
}
} catch {
return ''
}
}
const safeUrl: UrlTransform = url => sanitizeUrl(url)
const components: Components = {
a: ({ href = '', children }) => {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <>{children}</>
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
return (
<a
href={safeHref}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
)
},
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
table: ({ children }) => (
<div className={css.tableScroll}>
<table>{children}</table>
</div>
),
}
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection.
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
*/
export function MarkdownText({ text }: { text: string }) {
return (
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
components={components}
urlTransform={safeUrl}
>
{text}
</ReactMarkdown>
</div>
)
}
@@ -1,4 +1,4 @@
// MessageText: the single text-block rendering point (Markdown support later = swap this component's internals, zero card-structure changes).
// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText.
import css from './MessageText.module.css'