手动渲染表格以修复ai问片渲染表格报错
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
-13
@@ -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
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
|
||||
@@ -17476,14 +17471,6 @@ snapshots:
|
||||
dependencies:
|
||||
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]:
|
||||
dependencies:
|
||||
|
||||
+196
-47
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user