手动渲染表格以修复ai问片渲染表格报错
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
Generated
-13
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user