From ed6ab1fd6746e834b35fd28b84f492400d6d64bb Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Mon, 18 May 2026 00:19:19 +0800
Subject: [PATCH] =?UTF-8?q?=E6=89=8B=E5=8A=A8=E6=B8=B2=E6=9F=93=E8=A1=A8?=
=?UTF-8?q?=E6=A0=BC=E4=BB=A5=E4=BF=AE=E5=A4=8Dai=E9=97=AE=E7=89=87?=
=?UTF-8?q?=E6=B8=B2=E6=9F=93=E8=A1=A8=E6=A0=BC=E6=8A=A5=E9=94=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package.json | 1 -
pnpm-lock.yaml | 13 --
src/components/AIChatPanel.tsx | 243 ++++++++++++++++++++++++++-------
3 files changed, 196 insertions(+), 61 deletions(-)
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) => (
+ |
+
+ {convertTitleToLink(cell)}
+
+ |
+ ))}
+
+
+
+ {segment.rows.map((row, rowIndex) => (
+
+ {row.map((cell, cellIndex) => (
+ |
+
+ {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)}
)}