From 464edee32421362f18c463d5c9bfabf9f1566faa Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 18 May 2026 01:11:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=8B=E5=8A=A8=E6=B8=B2=E6=9F=93=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=9B=B4=E5=A4=9Amarkdown=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AIChatPanel.tsx | 99 ++++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 4 deletions(-) diff --git a/src/components/AIChatPanel.tsx b/src/components/AIChatPanel.tsx index e1dc6f6..198a006 100644 --- a/src/components/AIChatPanel.tsx +++ b/src/components/AIChatPanel.tsx @@ -137,6 +137,88 @@ const splitMarkdownByTables = (content: string): MarkdownSegment[] => { return segments; }; +const transformStrikethrough = (line: string): string => { + return line; +}; + +const transformTaskList = (line: string): string => { + return line.replace(/^(\s*[-*+]\s+)\[(x|X| )\]\s+/g, (_match, prefix: string, checked: string) => { + return `${prefix}${checked.trim() ? '☑' : '☐'} `; + }); +}; + +const transformBareLinks = (line: string): string => { + return line.replace(/(https?:\/\/[^\s<>()]+|www\.[^\s<>()]+)/g, (match, _url: string, offset: number, source: string) => { + const before = source.slice(Math.max(0, offset - 2), offset); + const previousChar = source[offset - 1]; + const lastOpenBracket = source.lastIndexOf('[', offset); + const lastCloseBracket = source.lastIndexOf(']', offset); + const nextCloseBracket = source.indexOf(']', offset + match.length); + const nextOpenParen = nextCloseBracket >= 0 ? source.slice(nextCloseBracket, nextCloseBracket + 2) : ''; + + // 已经是 Markdown 链接目标或链接文本时不重复转换。 + if (before === '](' || previousChar === '<' || (lastOpenBracket > lastCloseBracket && nextOpenParen === '](')) { + return match; + } + + const trailing = match.match(/[.,!?;:,。!?;:]+$/)?.[0] || ''; + const cleanUrl = trailing ? match.slice(0, -trailing.length) : match; + const href = cleanUrl.startsWith('www.') ? `https://${cleanUrl}` : cleanUrl; + + return `[${cleanUrl}](${href})${trailing}`; + }); +}; + +const transformLightweightGfm = (content: string): string => { + const lines = content.split('\n'); + let inFence = false; + + return lines.map((line) => { + if (/^\s*(```|~~~)/.test(line)) { + inFence = !inFence; + return line; + } + + if (inFence) return line; + + return transformBareLinks(transformStrikethrough(transformTaskList(line))); + }).join('\n'); +}; + +const renderStrikethroughNodes = (children: React.ReactNode): React.ReactNode => { + return React.Children.map(children, (child) => { + if (typeof child === 'string') { + const parts: React.ReactNode[] = []; + const regex = /~~([^~\n]+)~~/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(child)) !== null) { + if (match.index > lastIndex) { + parts.push(child.slice(lastIndex, match.index)); + } + + parts.push({match[1]}); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < child.length) { + parts.push(child.slice(lastIndex)); + } + + return parts.length > 0 ? parts : child; + } + + if (React.isValidElement(child) && (child as any).props?.children) { + return React.cloneElement(child as any, { + children: renderStrikethroughNodes((child as any).props.children), + }); + } + + return child; + }); +}; + export default function AIChatPanel({ isOpen, onClose, @@ -178,6 +260,15 @@ export default function AIChatPanel({ }; const markdownComponents = useMemo(() => ({ + del: ({ children }: any) => {children}, + p: ({ children }: any) =>

{renderStrikethroughNodes(children)}

, + li: ({ children }: any) =>
  • {renderStrikethroughNodes(children)}
  • , + h1: ({ children }: any) =>

    {renderStrikethroughNodes(children)}

    , + h2: ({ children }: any) =>

    {renderStrikethroughNodes(children)}

    , + h3: ({ children }: any) =>

    {renderStrikethroughNodes(children)}

    , + h4: ({ children }: any) =>

    {renderStrikethroughNodes(children)}

    , + h5: ({ children }: any) =>
    {renderStrikethroughNodes(children)}
    , + h6: ({ children }: any) =>
    {renderStrikethroughNodes(children)}
    , a: ({ href, children, ...props }: any) => { // 如果是内部链接(以 / 开头),使用 Next.js Link if (href?.startsWith('/')) { @@ -198,7 +289,7 @@ export default function AIChatPanel({ const inlineMarkdownComponents = useMemo(() => ({ ...markdownComponents, - p: ({ children }: any) => {children}, + p: ({ children }: any) => {renderStrikethroughNodes(children)}, }), [markdownComponents]); const renderAssistantContent = (content: string) => { @@ -206,7 +297,7 @@ export default function AIChatPanel({ if (segment.type === 'markdown') { return ( - {convertTitleToLink(segment.content)} + {transformLightweightGfm(convertTitleToLink(segment.content))} ); } @@ -227,7 +318,7 @@ export default function AIChatPanel({ style={{ textAlign: segment.align[cellIndex] }} > - {convertTitleToLink(cell)} + {transformLightweightGfm(convertTitleToLink(cell))} ))} @@ -246,7 +337,7 @@ export default function AIChatPanel({ style={{ textAlign: segment.align[cellIndex] }} > - {convertTitleToLink(cell)} + {transformLightweightGfm(convertTitleToLink(cell))} ))}