feat: add Markdown rendering to text segments

- Add react-markdown + remark-gfm for GFM-compatible markdown rendering
- Create MarkdownRenderer component with custom Tailwind-styled elements
- Update TextSegmentView to render markdown instead of plain text
- Update LongTextView to render markdown when expanded
- Fix Lucide icon type in DocumentCard
This commit is contained in:
carry
2026-06-07 13:53:57 +08:00
parent 241156853c
commit fec598af62
7 changed files with 1653 additions and 19 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/ node_modules/
dist/ dist/
.DS_Store .DS_Store
*.tsbuildinfo
+1470 -7
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -12,7 +12,9 @@
"dependencies": { "dependencies": {
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
+167
View File
@@ -0,0 +1,167 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { Components } from 'react-markdown'
/**
* Shared markdown renderer that converts markdown to styled HTML.
* Used by TextSegmentView, LongTextView, and any other component
* that needs to render user/assistant markdown content.
*
* Styling principle: all elements fit within a chat bubble context —
* compact spacing, readable sizes, no oversized headings.
*/
const components: Components = {
// --- Block elements ---
h1: ({ children, ...props }) => (
<h1 className="text-base font-bold text-gray-900 mt-3 mb-1.5 first:mt-0" {...props}>
{children}
</h1>
),
h2: ({ children, ...props }) => (
<h2 className="text-sm font-bold text-gray-900 mt-2.5 mb-1 first:mt-0" {...props}>
{children}
</h2>
),
h3: ({ children, ...props }) => (
<h3 className="text-sm font-semibold text-gray-800 mt-2 mb-1 first:mt-0" {...props}>
{children}
</h3>
),
h4: ({ children, ...props }) => (
<h4 className="text-xs font-semibold text-gray-800 mt-1.5 mb-0.5" {...props}>
{children}
</h4>
),
p: ({ children, ...props }) => (
<p className="text-gray-800 leading-relaxed mb-1.5 last:mb-0" {...props}>
{children}
</p>
),
// --- Inline elements ---
strong: ({ children, ...props }) => (
<strong className="font-semibold text-gray-900" {...props}>
{children}
</strong>
),
em: ({ children, ...props }) => (
<em className="italic text-gray-700" {...props}>
{children}
</em>
),
a: ({ children, href, ...props }) => (
<a
href={href}
className="text-blue-600 underline underline-offset-2 hover:text-blue-800"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
),
code: ({ className, children, ...props }) => {
// Inline code (no className from rehype)
const isInline = !className
if (isInline) {
return (
<code
className="bg-gray-100 text-pink-600 text-xs font-mono px-1 py-0.5 rounded"
{...props}
>
{children}
</code>
)
}
// Fenced code block — rendered by `pre > code`, handled in `pre`
return (
<code className={className} {...props}>
{children}
</code>
)
},
pre: ({ children, ...props }) => (
<pre
className="bg-gray-900 text-gray-100 text-xs font-mono rounded-lg px-3 py-2 overflow-x-auto my-2 leading-relaxed"
{...props}
>
{children}
</pre>
),
// --- Lists ---
ul: ({ children, ...props }) => (
<ul className="list-disc list-inside mb-1.5 space-y-0.5 text-gray-800" {...props}>
{children}
</ul>
),
ol: ({ children, ...props }) => (
<ol className="list-decimal list-inside mb-1.5 space-y-0.5 text-gray-800" {...props}>
{children}
</ol>
),
li: ({ children, ...props }) => (
<li className="text-gray-800 leading-relaxed" {...props}>
{children}
</li>
),
// --- Blockquotes ---
blockquote: ({ children, ...props }) => (
<blockquote
className="border-l-3 border-blue-300 bg-blue-50/50 pl-3 py-1 my-1.5 text-gray-600 italic"
{...props}
>
{children}
</blockquote>
),
// --- Tables (GFM) ---
table: ({ children, ...props }) => (
<div className="overflow-x-auto my-2">
<table className="min-w-full text-xs border-collapse" {...props}>
{children}
</table>
</div>
),
thead: ({ children, ...props }) => (
<thead className="bg-gray-50" {...props}>
{children}
</thead>
),
th: ({ children, ...props }) => (
<th
className="border border-gray-200 px-2 py-1 text-left font-semibold text-gray-700"
{...props}
>
{children}
</th>
),
td: ({ children, ...props }) => (
<td className="border border-gray-200 px-2 py-1 text-gray-700" {...props}>
{children}
</td>
),
// --- Horizontal rule ---
hr: (props) => <hr className="my-3 border-gray-200" {...props} />,
// --- Strikethrough (GFM) ---
del: ({ children, ...props }) => (
<del className="text-gray-400 line-through" {...props}>
{children}
</del>
),
}
interface MarkdownRendererProps {
content: string
}
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
)
}
+2 -1
View File
@@ -1,7 +1,8 @@
import type { DocumentSegment } from '../../types/protocol' import type { DocumentSegment } from '../../types/protocol'
import type { LucideIcon } from 'lucide-react'
import { FileText, FileImage, FileAudio, File } from 'lucide-react' import { FileText, FileImage, FileAudio, File } from 'lucide-react'
const mimeIcons: Record<string, React.ComponentType<{ size?: number; className?: string }>> = { const mimeIcons: Record<string, LucideIcon> = {
'text/': FileText, 'text/': FileText,
'image/': FileImage, 'image/': FileImage,
'audio/': FileAudio, 'audio/': FileAudio,
+6 -7
View File
@@ -1,11 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import type { LongTextSegment } from '../../types/protocol' import type { LongTextSegment } from '../../types/protocol'
import { ChevronDown, ChevronRight, Text } from 'lucide-react' import { ChevronDown, ChevronRight, Text } from 'lucide-react'
import MarkdownRenderer from '../MarkdownRenderer'
export default function LongTextView({ segment }: { segment: LongTextSegment }) { export default function LongTextView({ segment }: { segment: LongTextSegment }) {
const [collapsed, setCollapsed] = useState(segment.collapsed) const [collapsed, setCollapsed] = useState(segment.collapsed)
// Show first ~2 lines when collapsed // Show first ~2 lines when collapsed (plain text preview, no markdown)
const preview = segment.content.split('\n').slice(0, 2).join('\n') const preview = segment.content.split('\n').slice(0, 2).join('\n')
return ( return (
@@ -16,7 +17,7 @@ export default function LongTextView({ segment }: { segment: LongTextSegment })
> >
<Text size={14} className="text-gray-400" /> <Text size={14} className="text-gray-400" />
<span className="text-xs text-gray-500 flex-1"> <span className="text-xs text-gray-500 flex-1">
{collapsed ? '长文本素材' : '长文本素材'} · {segment.charCount} · {segment.charCount}
</span> </span>
<span className="text-blue-500 text-xs"> <span className="text-blue-500 text-xs">
{collapsed ? '展开' : '收起'} {collapsed ? '展开' : '收起'}
@@ -26,15 +27,13 @@ export default function LongTextView({ segment }: { segment: LongTextSegment })
</span> </span>
</button> </button>
{!collapsed && ( {!collapsed && (
<div className="px-3 pb-3"> <div className="px-3 pb-3 max-h-96 overflow-y-auto">
<p className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed max-h-64 overflow-y-auto"> <MarkdownRenderer content={segment.content} />
{segment.content}
</p>
</div> </div>
)} )}
{collapsed && ( {collapsed && (
<div className="px-3 pb-2"> <div className="px-3 pb-2">
<p className="text-sm text-gray-400 whitespace-pre-wrap line-clamp-2 italic"> <p className="text-sm text-gray-400 line-clamp-2 italic">
{preview} {preview}
</p> </p>
</div> </div>
+4 -3
View File
@@ -1,9 +1,10 @@
import type { TextSegment } from '../../types/protocol' import type { TextSegment } from '../../types/protocol'
import MarkdownRenderer from '../MarkdownRenderer'
export default function TextSegmentView({ segment }: { segment: TextSegment }) { export default function TextSegmentView({ segment }: { segment: TextSegment }) {
return ( return (
<p className="text-gray-800 leading-relaxed whitespace-pre-wrap break-words"> <div className="text-gray-800">
{segment.content} <MarkdownRenderer content={segment.content} />
</p> </div>
) )
} }