手动渲染表格以修复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
-1
View File
@@ -67,7 +67,6 @@
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"redis": "^4.6.7", "redis": "^4.6.7",
"remark-gfm": "^3.0.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
-13
View File
@@ -134,9 +134,6 @@ importers:
redis: redis:
specifier: ^4.6.7 specifier: ^4.6.7
version: 4.7.1 version: 4.7.1
remark-gfm:
specifier: ^3.0.1
version: 3.0.1
server-only: server-only:
specifier: ^0.0.1 specifier: ^0.0.1
version: 0.0.1 version: 0.0.1
@@ -7067,8 +7064,6 @@ packages:
resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==}
[email protected]: [email protected]:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
@@ -17476,14 +17471,6 @@ snapshots:
dependencies: dependencies:
jsesc: 3.1.0 jsesc: 3.1.0
[email protected]:
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
[email protected]: [email protected]:
dependencies: dependencies:
+196 -47
View File
@@ -7,7 +7,6 @@ import { usePathname } from 'next/navigation';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import { VideoContext } from '@/lib/ai-orchestrator'; import { VideoContext } from '@/lib/ai-orchestrator';
@@ -27,6 +26,117 @@ interface AIChatPanelProps {
drawerWidth?: string; 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({ export default function AIChatPanel({
isOpen, isOpen,
onClose, 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 = () => { const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -446,29 +639,7 @@ export default function AIChatPanel({
</p> </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'> <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 {renderAssistantContent(message.content)}
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>
</div> </div>
)} )}
</div> </div>
@@ -652,29 +823,7 @@ export default function AIChatPanel({
</p> </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'> <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 {renderAssistantContent(message.content)}
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>
</div> </div>
)} )}
</div> </div>