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/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
Generated
+1470
-7
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -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",
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user