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:
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
|
||||
Generated
+1470
-7
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -12,7 +12,9 @@
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.400.0",
|
||||
"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": {
|
||||
"@types/react": "^18.3.3",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { DocumentSegment } from '../../types/protocol'
|
||||
import type { LucideIcon } 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,
|
||||
'image/': FileImage,
|
||||
'audio/': FileAudio,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import type { LongTextSegment } from '../../types/protocol'
|
||||
import { ChevronDown, ChevronRight, Text } from 'lucide-react'
|
||||
import MarkdownRenderer from '../MarkdownRenderer'
|
||||
|
||||
export default function LongTextView({ segment }: { segment: LongTextSegment }) {
|
||||
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')
|
||||
|
||||
return (
|
||||
@@ -16,7 +17,7 @@ export default function LongTextView({ segment }: { segment: LongTextSegment })
|
||||
>
|
||||
<Text size={14} className="text-gray-400" />
|
||||
<span className="text-xs text-gray-500 flex-1">
|
||||
{collapsed ? '长文本素材' : '长文本素材'} · {segment.charCount} 字
|
||||
长文本素材 · {segment.charCount} 字
|
||||
</span>
|
||||
<span className="text-blue-500 text-xs">
|
||||
{collapsed ? '展开' : '收起'}
|
||||
@@ -26,15 +27,13 @@ export default function LongTextView({ segment }: { segment: LongTextSegment })
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed max-h-64 overflow-y-auto">
|
||||
{segment.content}
|
||||
</p>
|
||||
<div className="px-3 pb-3 max-h-96 overflow-y-auto">
|
||||
<MarkdownRenderer content={segment.content} />
|
||||
</div>
|
||||
)}
|
||||
{collapsed && (
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { TextSegment } from '../../types/protocol'
|
||||
import MarkdownRenderer from '../MarkdownRenderer'
|
||||
|
||||
export default function TextSegmentView({ segment }: { segment: TextSegment }) {
|
||||
return (
|
||||
<p className="text-gray-800 leading-relaxed whitespace-pre-wrap break-words">
|
||||
{segment.content}
|
||||
</p>
|
||||
<div className="text-gray-800">
|
||||
<MarkdownRenderer content={segment.content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user