diff --git a/package.json b/package.json index 0ae2a53..bdead72 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ "react-icons": "^5.4.0", "react-markdown": "^10.1.0", "redis": "^4.6.7", - "remark-gfm": "^3.0.1", "server-only": "^0.0.1", "sharp": "^0.34.5", "socket.io": "^4.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6f3f65..43824d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,9 +134,6 @@ importers: redis: specifier: ^4.6.7 version: 4.7.1 - remark-gfm: - specifier: ^3.0.1 - version: 3.0.1 server-only: specifier: ^0.0.1 version: 0.0.1 @@ -7067,8 +7064,6 @@ packages: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} hasBin: true - remark-gfm@3.0.1: - resolution: {integrity: sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==} remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -17476,14 +17471,6 @@ snapshots: dependencies: jsesc: 3.1.0 - remark-gfm@3.0.1: - dependencies: - '@types/mdast': 3.0.15 - mdast-util-gfm: 2.0.2 - micromark-extension-gfm: 2.0.3 - unified: 10.1.2 - transitivePeerDependencies: - - supports-color remark-parse@11.0.0: dependencies: diff --git a/src/components/AIChatPanel.tsx b/src/components/AIChatPanel.tsx index fbbf21e..e1dc6f6 100644 --- a/src/components/AIChatPanel.tsx +++ b/src/components/AIChatPanel.tsx @@ -7,7 +7,6 @@ import { usePathname } from 'next/navigation'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import { VideoContext } from '@/lib/ai-orchestrator'; @@ -27,6 +26,117 @@ interface AIChatPanelProps { drawerWidth?: string; } +type MarkdownSegment = + | { type: 'markdown'; content: string } + | { + type: 'table'; + header: string[]; + align: Array<'left' | 'center' | 'right' | undefined>; + rows: string[][]; + }; + +const splitMarkdownTableRow = (line: string): string[] => { + const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, ''); + const cells: string[] = []; + let current = ''; + + for (let i = 0; i < trimmed.length; i++) { + const char = trimmed[i]; + if (char === '|' && trimmed[i - 1] !== '\\') { + cells.push(current.replace(/\\\|/g, '|').trim()); + current = ''; + } else { + current += char; + } + } + + cells.push(current.replace(/\\\|/g, '|').trim()); + return cells; +}; + +const getTableAlign = (cell: string): 'left' | 'center' | 'right' | undefined => { + const trimmed = cell.trim(); + if (!/^:?-{3,}:?$/.test(trimmed)) return undefined; + if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center'; + if (trimmed.endsWith(':')) return 'right'; + return 'left'; +}; + +const isTableDelimiterRow = (line: string): boolean => { + const cells = splitMarkdownTableRow(line); + return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())); +}; + +const normalizeTableRow = (cells: string[], length: number): string[] => { + if (cells.length === length) return cells; + if (cells.length > length) return cells.slice(0, length); + return [...cells, ...Array.from({ length: length - cells.length }, () => '')]; +}; + +const splitMarkdownByTables = (content: string): MarkdownSegment[] => { + const lines = content.split('\n'); + const segments: MarkdownSegment[] = []; + const markdownBuffer: string[] = []; + let inFence = false; + let i = 0; + + const flushMarkdown = () => { + const markdown = markdownBuffer.join('\n'); + if (markdown.trim()) { + segments.push({ type: 'markdown', content: markdown }); + } + markdownBuffer.length = 0; + }; + + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + if (/^(```|~~~)/.test(trimmed)) { + inFence = !inFence; + markdownBuffer.push(line); + i++; + continue; + } + + if ( + !inFence && + line.includes('|') && + i + 1 < lines.length && + isTableDelimiterRow(lines[i + 1]) + ) { + const header = splitMarkdownTableRow(line); + const delimiter = splitMarkdownTableRow(lines[i + 1]); + + if (header.length === delimiter.length) { + flushMarkdown(); + + const rows: string[][] = []; + i += 2; + + while (i < lines.length && lines[i].trim() && lines[i].includes('|')) { + rows.push(normalizeTableRow(splitMarkdownTableRow(lines[i]), header.length)); + i++; + } + + segments.push({ + type: 'table', + header, + align: delimiter.map(getTableAlign), + rows, + }); + continue; + } + } + + markdownBuffer.push(line); + i++; + } + + flushMarkdown(); + return segments; +}; + export default function AIChatPanel({ isOpen, onClose, @@ -67,6 +177,89 @@ export default function AIChatPanel({ }); }; + const markdownComponents = useMemo(() => ({ + a: ({ href, children, ...props }: any) => { + // 如果是内部链接(以 / 开头),使用 Next.js Link + if (href?.startsWith('/')) { + // 如果当前在 /play 页面且链接也是 /play,不做处理(返回纯文本) + if (pathname === '/play' && href.startsWith('/play')) { + return {children}; + } + return ( + + {children} + + ); + } + // 外部链接使用普通 a 标签 + return {children}; + }, + }), [pathname]); + + const inlineMarkdownComponents = useMemo(() => ({ + ...markdownComponents, + p: ({ children }: any) => {children}, + }), [markdownComponents]); + + const renderAssistantContent = (content: string) => { + return splitMarkdownByTables(content).map((segment, segmentIndex) => { + if (segment.type === 'markdown') { + return ( + + {convertTitleToLink(segment.content)} + + ); + } + + return ( +
+
+ + + + {segment.header.map((cell, cellIndex) => ( + + ))} + + + + {segment.rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ + {convertTitleToLink(cell)} + +
+ + {convertTitleToLink(cell)} + +
+
+
+ ); + }); + }; + // 自动滚动到底部 const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -446,29 +639,7 @@ export default function AIChatPanel({

) : (
- { - // 如果是内部链接(以 / 开头),使用 Next.js Link - if (href?.startsWith('/')) { - // 如果当前在 /play 页面且链接也是 /play,不做处理(返回纯文本) - if (pathname === '/play' && href.startsWith('/play')) { - return {children}; - } - return ( - - {children} - - ); - } - // 外部链接使用普通 a 标签 - return {children}; - } - }} - > - {convertTitleToLink(message.content)} - + {renderAssistantContent(message.content)}
)} @@ -652,29 +823,7 @@ export default function AIChatPanel({

) : (
- { - // 如果是内部链接(以 / 开头),使用 Next.js Link - if (href?.startsWith('/')) { - // 如果当前在 /play 页面且链接也是 /play,不做处理(返回纯文本) - if (pathname === '/play' && href.startsWith('/play')) { - return {children}; - } - return ( - - {children} - - ); - } - // 外部链接使用普通 a 标签 - return {children}; - } - }} - > - {convertTitleToLink(message.content)} - + {renderAssistantContent(message.content)}
)}