手动渲染表格以修复ai问片渲染表格报错

This commit is contained in:
mtvpls
2026-05-18 00:50:11 +08:00
parent 24b3335e3b
commit ed6ab1fd67
3 changed files with 196 additions and 61 deletions
+196 -47
View File
@@ -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 <span>{children}</span>;
}
return (
<Link href={href} {...props}>
{children}
</Link>
);
}
// 外部链接使用普通 a 标签
return <a href={href} target="_blank" rel="noopener noreferrer" {...props}>{children}</a>;
},
}), [pathname]);
const inlineMarkdownComponents = useMemo(() => ({
...markdownComponents,
p: ({ children }: any) => <span>{children}</span>,
}), [markdownComponents]);
const renderAssistantContent = (content: string) => {
return splitMarkdownByTables(content).map((segment, segmentIndex) => {
if (segment.type === 'markdown') {
return (
<ReactMarkdown key={segmentIndex} components={markdownComponents}>
{convertTitleToLink(segment.content)}
</ReactMarkdown>
);
}
return (
<div
key={segmentIndex}
className='not-prose overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm ring-1 ring-black/5 dark:border-gray-700 dark:bg-gray-900 dark:ring-white/10'
>
<div className='overflow-x-auto'>
<table className='m-0 min-w-full border-separate border-spacing-0 text-left text-sm'>
<thead>
<tr className='bg-gradient-to-r from-purple-50 to-blue-50 dark:from-purple-950/40 dark:to-blue-950/40'>
{segment.header.map((cell, cellIndex) => (
<th
key={cellIndex}
className='whitespace-nowrap border-b border-gray-200 px-4 py-3 font-semibold text-gray-800 first:rounded-tl-xl last:rounded-tr-xl dark:border-gray-700 dark:text-gray-100'
style={{ textAlign: segment.align[cellIndex] }}
>
<ReactMarkdown components={inlineMarkdownComponents}>
{convertTitleToLink(cell)}
</ReactMarkdown>
</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-gray-100 dark:divide-gray-800'>
{segment.rows.map((row, rowIndex) => (
<tr
key={rowIndex}
className='transition-colors odd:bg-white even:bg-gray-50/70 hover:bg-purple-50/70 dark:odd:bg-gray-900 dark:even:bg-gray-800/40 dark:hover:bg-purple-950/25'
>
{row.map((cell, cellIndex) => (
<td
key={cellIndex}
className='px-4 py-3 align-top leading-relaxed text-gray-700 dark:text-gray-200'
style={{ textAlign: segment.align[cellIndex] }}
>
<ReactMarkdown components={inlineMarkdownComponents}>
{convertTitleToLink(cell)}
</ReactMarkdown>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
});
};
// 自动滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -446,29 +639,7 @@ export default function AIChatPanel({
</p>
) : (
<div className='prose prose-sm max-w-none dark:prose-invert prose-p:my-2 prose-p:leading-relaxed prose-pre:bg-gray-800 prose-pre:text-gray-100 dark:prose-pre:bg-gray-900 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:bg-purple-50 dark:prose-code:bg-purple-900/20 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none prose-a:text-inherit dark:prose-a:text-inherit prose-a:no-underline hover:prose-a:underline prose-strong:text-gray-900 dark:prose-strong:text-white prose-ul:my-2 prose-ol:my-2 prose-li:my-1'>
<ReactMarkdown
remarkPlugins={[remarkGfm as any]}
components={{
a: ({ node, href, children, ...props }) => {
// 如果是内部链接(以 / 开头),使用 Next.js Link
if (href?.startsWith('/')) {
// 如果当前在 /play 页面且链接也是 /play,不做处理(返回纯文本)
if (pathname === '/play' && href.startsWith('/play')) {
return <span>{children}</span>;
}
return (
<Link href={href} {...props}>
{children}
</Link>
);
}
// 外部链接使用普通 a 标签
return <a href={href} target="_blank" rel="noopener noreferrer" {...props}>{children}</a>;
}
}}
>
{convertTitleToLink(message.content)}
</ReactMarkdown>
{renderAssistantContent(message.content)}
</div>
)}
</div>
@@ -652,29 +823,7 @@ export default function AIChatPanel({
</p>
) : (
<div className='prose prose-sm max-w-none dark:prose-invert prose-p:my-2 prose-p:leading-relaxed prose-pre:bg-gray-800 prose-pre:text-gray-100 dark:prose-pre:bg-gray-900 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:bg-purple-50 dark:prose-code:bg-purple-900/20 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none prose-a:text-inherit dark:prose-a:text-inherit prose-a:no-underline hover:prose-a:underline prose-strong:text-gray-900 dark:prose-strong:text-white prose-ul:my-2 prose-ol:my-2 prose-li:my-1'>
<ReactMarkdown
remarkPlugins={[remarkGfm as any]}
components={{
a: ({ node, href, children, ...props }) => {
// 如果是内部链接(以 / 开头),使用 Next.js Link
if (href?.startsWith('/')) {
// 如果当前在 /play 页面且链接也是 /play,不做处理(返回纯文本)
if (pathname === '/play' && href.startsWith('/play')) {
return <span>{children}</span>;
}
return (
<Link href={href} {...props}>
{children}
</Link>
);
}
// 外部链接使用普通 a 标签
return <a href={href} target="_blank" rel="noopener noreferrer" {...props}>{children}</a>;
}
}}
>
{convertTitleToLink(message.content)}
</ReactMarkdown>
{renderAssistantContent(message.content)}
</div>
)}
</div>