更新AI聊天页面样式
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Input, Button, Upload, Tooltip, message as antdMessage, Space } from 'antd';
|
||||
import { SendOutlined, StopOutlined, PaperClipOutlined, PictureOutlined } from '@ant-design/icons';
|
||||
import { StopOutlined, PictureOutlined, CommentOutlined } from '@ant-design/icons';
|
||||
import type { VisionFile } from '../../types/dify_chat';
|
||||
import '../../styles/components/chat-with-llm/chat-input.css';
|
||||
|
||||
@@ -184,7 +184,8 @@ export default function ChatInput({
|
||||
icon={<PictureOutlined />}
|
||||
size="small"
|
||||
disabled={isDisabled}
|
||||
className="text-gray-500 hover:text-blue-500"
|
||||
className="chat-upload-button"
|
||||
shape="circle"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
@@ -192,21 +193,19 @@ export default function ChatInput({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 bg-white p-4">
|
||||
<div className="bg-white p-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 文件预览 */}
|
||||
{renderFilePreview()}
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div className="relative">
|
||||
<div className="flex items-end gap-2">
|
||||
{/* 输入区域 - 方块容器 */}
|
||||
<div className="chat-input-box">
|
||||
<div className="chat-input-content">
|
||||
{/* 上传按钮 */}
|
||||
<div className="flex items-end pb-1">
|
||||
{renderUploadButton()}
|
||||
</div>
|
||||
{renderUploadButton()}
|
||||
|
||||
{/* 文本输入 */}
|
||||
<div className="flex-1">
|
||||
<div className="chat-input-textarea-wrapper">
|
||||
<TextArea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
@@ -216,13 +215,12 @@ export default function ChatInput({
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoSize={{ minRows: 1, maxRows: 6 }}
|
||||
className="resize-none"
|
||||
style={{ paddingRight: '50px' }}
|
||||
className="chat-input-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 发送/停止按钮 */}
|
||||
<div className="flex items-end pb-1">
|
||||
<div className="chat-input-button">
|
||||
{isResponding ? (
|
||||
<Tooltip title="停止生成">
|
||||
<Button
|
||||
@@ -231,24 +229,25 @@ export default function ChatInput({
|
||||
icon={<StopOutlined />}
|
||||
onClick={handleStop}
|
||||
disabled={disabled}
|
||||
size="large"
|
||||
size="small"
|
||||
shape="circle"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="发送消息 (Enter)">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
icon={<CommentOutlined style={{ fontSize: '30px' }} />}
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !message.trim()}
|
||||
size="large"
|
||||
size="small"
|
||||
shape="circle"
|
||||
className='chat-input-button-send'
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字符计数和提示 */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,208 +1,179 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Tooltip, Avatar, Card, Image, Spin } from 'antd';
|
||||
import { AntDesignOutlined, SmileTwoTone, RobotOutlined } from '@ant-design/icons';
|
||||
import type { ChatItem, Feedbacktype, ThoughtItem, VisionFile } from '../../types/dify_chat';
|
||||
import { CHAT_CONFIG } from '../../config/chat';
|
||||
import Markdown from './markdown';
|
||||
import ThoughtProcess from './thought-process';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import '../../styles/components/chat-with-llm/chat-message.css';
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ChatItem;
|
||||
onFeedback?: (messageId: string, feedback: Feedbacktype) => void;
|
||||
isResponding?: boolean;
|
||||
onRegenerate?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息组件
|
||||
*/
|
||||
export default function ChatMessage({
|
||||
message,
|
||||
onFeedback,
|
||||
isResponding = false,
|
||||
onRegenerate
|
||||
}: ChatMessageProps) {
|
||||
const [feedback, setFeedback] = useState<'like' | 'dislike' | null>(
|
||||
message.feedback?.rating || null
|
||||
);
|
||||
|
||||
const { id, content, isAnswer, agent_thoughts, message_files, isOpeningStatement, suggestedQuestions, more } = message;
|
||||
const isAgentMode = !!agent_thoughts && agent_thoughts.length > 0;
|
||||
|
||||
/**
|
||||
* 处理反馈
|
||||
*/
|
||||
const handleFeedback = async (type: 'like' | 'dislike') => {
|
||||
if (!id || id.startsWith('placeholder-') || id.startsWith('question-') || id.startsWith('opening-'))
|
||||
return;
|
||||
|
||||
// 如果已经选择了相同的反馈,则取消选择
|
||||
const newFeedback = feedback === type ? null : type;
|
||||
setFeedback(newFeedback);
|
||||
|
||||
await onFeedback?.(id, {
|
||||
rating: newFeedback,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染AI回答内容
|
||||
*/
|
||||
const renderAnswerContent = () => {
|
||||
// console.log('🎨 渲染AI回答内容:', { content, isResponding, isAgentMode });
|
||||
|
||||
// 如果正在响应且没有内容
|
||||
if (isResponding && (isAgentMode ? (!content && (agent_thoughts || []).filter(item => !!item.thought || !!item.tool).length === 0) : !content)) {
|
||||
return (
|
||||
<div className="responding-indicator">
|
||||
<Spin size="small" />
|
||||
<span>AI 正在思考...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Agent模式(有思考过程)
|
||||
if (isAgentMode) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{agent_thoughts?.map((thought, index) => (
|
||||
<div key={index}>
|
||||
{thought.thought && (
|
||||
<div className={isResponding && index === agent_thoughts.length - 1 ? 'streaming-text' : ''}>
|
||||
<Markdown content={thought.thought} />
|
||||
</div>
|
||||
)}
|
||||
{thought.tool && (
|
||||
<ThoughtProcess
|
||||
thought={thought}
|
||||
isFinished={!!thought.observation || !isResponding}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 普通模式 - 恢复Markdown渲染
|
||||
return (
|
||||
<div>
|
||||
<div className={isResponding ? 'streaming-text' : ''}>
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染建议问题
|
||||
*/
|
||||
const renderSuggestedQuestions = () => {
|
||||
if (!suggestedQuestions || suggestedQuestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="suggested-questions">
|
||||
<div className="text-sm text-gray-500 mb-2">建议问题:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestedQuestions.map((question, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
size="small"
|
||||
type="dashed"
|
||||
className="question-button text-left"
|
||||
onClick={() => {
|
||||
// 这里可以添加点击建议问题的处理逻辑
|
||||
// console.log('Suggested question clicked:', question);
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染消息元信息
|
||||
*/
|
||||
const renderMessageMeta = () => {
|
||||
if (!more) return null;
|
||||
|
||||
return (
|
||||
<div className="message-timestamp">
|
||||
{more.time && <span>时间: {more.time}</span>}
|
||||
{more.tokens && <span className="ml-2">Token: {more.tokens}</span>}
|
||||
{more.latency && <span className="ml-2">延迟: {more.latency}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 如果是开场白,特殊处理
|
||||
if (isOpeningStatement) {
|
||||
return (
|
||||
<div className="chat-message">
|
||||
<Card className="message-card assistant" size="small">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* <Avatar icon={<RobotOutlined />} className="flex-shrink-0" />
|
||||
*/}
|
||||
<Avatar
|
||||
size={{ xs: 24, sm: 32, md: 40, lg: 64, xl: 80, xxl: 100 }}
|
||||
icon={<RobotOutlined />}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Markdown content={content} />
|
||||
{renderSuggestedQuestions()}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-message">
|
||||
<div className={`flex ${isAnswer ? 'justify-start' : 'justify-end'} mb-2`}>
|
||||
<div className={`message-card ${isAnswer ? 'assistant' : 'user'}`}>
|
||||
<Card size="small" className="shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
{/* 头像 */}
|
||||
<Avatar
|
||||
icon={isAnswer ? <RobotOutlined /> : <SmileTwoTone />}
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isAnswer ? '#1890ff' : '#52c41a'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 消息内容 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isAnswer ? renderAnswerContent() : (
|
||||
<div>
|
||||
<Markdown content={content} />
|
||||
{/* {renderImages(message_files)} */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 建议问题 */}
|
||||
{/* {isAnswer && renderSuggestedQuestions()} */}
|
||||
|
||||
{/* 消息元信息 */}
|
||||
{/* {renderMessageMeta()} */}
|
||||
|
||||
{/* 反馈按钮 */}
|
||||
{/* {isAnswer && (
|
||||
<div className="feedback-buttons">
|
||||
{renderFeedbackButtons()}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Card, Spin } from 'antd';
|
||||
import type { ChatItem, Feedbacktype, ThoughtItem, VisionFile } from '../../types/dify_chat';
|
||||
import { CHAT_CONFIG } from '../../config/chat';
|
||||
import Markdown from './markdown';
|
||||
import ThoughtProcess from './thought-process';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import '../../styles/components/chat-with-llm/chat-message.css';
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ChatItem;
|
||||
onFeedback?: (messageId: string, feedback: Feedbacktype) => void;
|
||||
isResponding?: boolean;
|
||||
onRegenerate?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息组件
|
||||
*/
|
||||
export default function ChatMessage({
|
||||
message,
|
||||
onFeedback,
|
||||
isResponding = false,
|
||||
onRegenerate
|
||||
}: ChatMessageProps) {
|
||||
const [feedback, setFeedback] = useState<'like' | 'dislike' | null>(
|
||||
message.feedback?.rating || null
|
||||
);
|
||||
|
||||
const { id, content, isAnswer, agent_thoughts, message_files, isOpeningStatement, suggestedQuestions, more } = message;
|
||||
const isAgentMode = !!agent_thoughts && agent_thoughts.length > 0;
|
||||
|
||||
/**
|
||||
* 处理反馈
|
||||
*/
|
||||
const handleFeedback = async (type: 'like' | 'dislike') => {
|
||||
if (!id || id.startsWith('placeholder-') || id.startsWith('question-') || id.startsWith('opening-'))
|
||||
return;
|
||||
|
||||
// 如果已经选择了相同的反馈,则取消选择
|
||||
const newFeedback = feedback === type ? null : type;
|
||||
setFeedback(newFeedback);
|
||||
|
||||
await onFeedback?.(id, {
|
||||
rating: newFeedback,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染AI回答内容
|
||||
*/
|
||||
const renderAnswerContent = () => {
|
||||
// console.log('🎨 渲染AI回答内容:', { content, isResponding, isAgentMode });
|
||||
|
||||
// 如果正在响应且没有内容
|
||||
if (isResponding && (isAgentMode ? (!content && (agent_thoughts || []).filter(item => !!item.thought || !!item.tool).length === 0) : !content)) {
|
||||
return (
|
||||
<div className="responding-indicator">
|
||||
<Spin size="small" />
|
||||
<span>AI 正在思考...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Agent模式(有思考过程)
|
||||
if (isAgentMode) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{agent_thoughts?.map((thought, index) => (
|
||||
<div key={index}>
|
||||
{thought.thought && (
|
||||
<div className={isResponding && index === agent_thoughts.length - 1 ? 'streaming-text' : ''}>
|
||||
<Markdown content={thought.thought} />
|
||||
</div>
|
||||
)}
|
||||
{thought.tool && (
|
||||
<ThoughtProcess
|
||||
thought={thought}
|
||||
isFinished={!!thought.observation || !isResponding}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 普通模式 - 恢复Markdown渲染
|
||||
return (
|
||||
<div>
|
||||
<div className={isResponding ? 'streaming-text' : ''}>
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染建议问题
|
||||
*/
|
||||
const renderSuggestedQuestions = () => {
|
||||
if (!suggestedQuestions || suggestedQuestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="suggested-questions">
|
||||
<div className="text-sm text-gray-500 mb-2">建议问题:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestedQuestions.map((question, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
size="small"
|
||||
type="dashed"
|
||||
className="question-button text-left"
|
||||
onClick={() => {
|
||||
// 这里可以添加点击建议问题的处理逻辑
|
||||
// console.log('Suggested question clicked:', question);
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染消息元信息
|
||||
*/
|
||||
const renderMessageMeta = () => {
|
||||
if (!more) return null;
|
||||
|
||||
return (
|
||||
<div className="message-timestamp">
|
||||
{more.time && <span>时间: {more.time}</span>}
|
||||
{more.tokens && <span className="ml-2">Token: {more.tokens}</span>}
|
||||
{more.latency && <span className="ml-2">延迟: {more.latency}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 如果是开场白,特殊处理
|
||||
if (isOpeningStatement) {
|
||||
return (
|
||||
<div className="chat-message">
|
||||
<Card className="message-card assistant" size="small">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<Markdown content={content} />
|
||||
{renderSuggestedQuestions()}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-message">
|
||||
<div className={`flex ${isAnswer ? 'justify-start' : 'justify-end'} mb-2`}>
|
||||
<div className={`message-card ${isAnswer ? 'assistant' : 'user'}`}>
|
||||
<Card size="small" className="shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
{/* 消息内容 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isAnswer ? renderAnswerContent() : (
|
||||
<div>
|
||||
<Markdown content={content} />
|
||||
{/* {renderImages(message_files)} */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+571
-570
File diff suppressed because it is too large
Load Diff
@@ -1,242 +1,89 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import '../../styles/components/chat-with-llm/markdown.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 渲染组件
|
||||
* 简化版本,支持基本的 Markdown 语法
|
||||
*/
|
||||
export default function Markdown({ content, className = '' }: MarkdownProps) {
|
||||
if (!content) return null;
|
||||
|
||||
/**
|
||||
* 简单的 Markdown 解析器
|
||||
*/
|
||||
const parseMarkdown = (text: string): React.ReactNode => {
|
||||
// 分割成行
|
||||
const lines = text.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
let currentParagraph: string[] = [];
|
||||
let inCodeBlock = false;
|
||||
let codeBlockContent: string[] = [];
|
||||
let codeBlockLanguage = '';
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (currentParagraph.length > 0) {
|
||||
const paragraphText = currentParagraph.join('\n');
|
||||
elements.push(
|
||||
<Paragraph key={elements.length} className={className}>
|
||||
{parseInlineElements(paragraphText)}
|
||||
</Paragraph>
|
||||
);
|
||||
currentParagraph = [];
|
||||
}
|
||||
};
|
||||
|
||||
const flushCodeBlock = () => {
|
||||
if (codeBlockContent.length > 0) {
|
||||
elements.push(
|
||||
<pre
|
||||
key={elements.length}
|
||||
className="bg-gray-800 text-white p-3 rounded-md overflow-x-auto my-2"
|
||||
>
|
||||
<code className={codeBlockLanguage ? `language-${codeBlockLanguage}` : ''}>
|
||||
{codeBlockContent.join('\n')}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
codeBlockContent = [];
|
||||
codeBlockLanguage = '';
|
||||
}
|
||||
};
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
// 处理代码块
|
||||
if (line.startsWith('```')) {
|
||||
if (inCodeBlock) {
|
||||
// 结束代码块
|
||||
flushCodeBlock();
|
||||
inCodeBlock = false;
|
||||
} else {
|
||||
// 开始代码块
|
||||
flushParagraph();
|
||||
inCodeBlock = true;
|
||||
codeBlockLanguage = line.slice(3).trim();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeBlockContent.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理标题
|
||||
if (line.startsWith('# ')) {
|
||||
flushParagraph();
|
||||
elements.push(
|
||||
<Title key={elements.length} level={1} className={className}>
|
||||
{parseInlineElements(line.slice(2))}
|
||||
</Title>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('## ')) {
|
||||
flushParagraph();
|
||||
elements.push(
|
||||
<Title key={elements.length} level={2} className={className}>
|
||||
{parseInlineElements(line.slice(3))}
|
||||
</Title>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('### ')) {
|
||||
flushParagraph();
|
||||
elements.push(
|
||||
<Title key={elements.length} level={3} className={className}>
|
||||
{parseInlineElements(line.slice(4))}
|
||||
</Title>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理列表
|
||||
if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
flushParagraph();
|
||||
elements.push(
|
||||
<ul key={elements.length} className="list-disc list-inside my-2">
|
||||
<li>{parseInlineElements(line.slice(2))}</li>
|
||||
</ul>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^\d+\.\s/.test(line)) {
|
||||
flushParagraph();
|
||||
const match = line.match(/^\d+\.\s(.*)$/);
|
||||
if (match) {
|
||||
elements.push(
|
||||
<ol key={elements.length} className="list-decimal list-inside my-2">
|
||||
<li>{parseInlineElements(match[1])}</li>
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理空行
|
||||
if (line.trim() === '') {
|
||||
flushParagraph();
|
||||
return;
|
||||
}
|
||||
|
||||
// 普通段落
|
||||
currentParagraph.push(line);
|
||||
});
|
||||
|
||||
// 处理剩余内容
|
||||
flushParagraph();
|
||||
flushCodeBlock();
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析行内元素(粗体、斜体、代码、链接等)
|
||||
*/
|
||||
const parseInlineElements = (text: string): React.ReactNode => {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let currentText = text;
|
||||
let key = 0;
|
||||
|
||||
// 处理行内代码
|
||||
currentText = currentText.replace(/`([^`]+)`/g, (match, code) => {
|
||||
const placeholder = `__CODE_${key}__`;
|
||||
parts.push(
|
||||
<Text key={`code-${key}`} code className="bg-gray-100 px-1 rounded">
|
||||
{code}
|
||||
</Text>
|
||||
);
|
||||
key++;
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// 处理粗体
|
||||
currentText = currentText.replace(/\*\*([^*]+)\*\*/g, (match, bold) => {
|
||||
const placeholder = `__BOLD_${key}__`;
|
||||
parts.push(
|
||||
<Text key={`bold-${key}`} strong>
|
||||
{bold}
|
||||
</Text>
|
||||
);
|
||||
key++;
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// 处理斜体
|
||||
currentText = currentText.replace(/\*([^*]+)\*/g, (match, italic) => {
|
||||
const placeholder = `__ITALIC_${key}__`;
|
||||
parts.push(
|
||||
<Text key={`italic-${key}`} italic>
|
||||
{italic}
|
||||
</Text>
|
||||
);
|
||||
key++;
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// 处理链接
|
||||
currentText = currentText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => {
|
||||
const placeholder = `__LINK_${key}__`;
|
||||
parts.push(
|
||||
<a
|
||||
key={`link-${key}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-700 underline"
|
||||
>
|
||||
{linkText}
|
||||
</a>
|
||||
);
|
||||
key++;
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// 重新组装文本
|
||||
const finalParts: React.ReactNode[] = [];
|
||||
const textParts = currentText.split(/(__(?:CODE|BOLD|ITALIC|LINK)_\d+__)/);
|
||||
|
||||
textParts.forEach((part, index) => {
|
||||
const match = part.match(/^__(\w+)_(\d+)__$/);
|
||||
if (match) {
|
||||
const [, type, partKey] = match;
|
||||
const component = parts.find((p: any) =>
|
||||
p.key === `${type.toLowerCase()}-${partKey}`
|
||||
);
|
||||
if (component) {
|
||||
finalParts.push(component);
|
||||
}
|
||||
} else if (part) {
|
||||
finalParts.push(part);
|
||||
}
|
||||
});
|
||||
|
||||
return finalParts.length === 1 ? finalParts[0] : finalParts;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`markdown-content ${className}`}>
|
||||
{parseMarkdown(content)}
|
||||
</div>
|
||||
);
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import RemarkMath from 'remark-math';
|
||||
import RemarkBreaks from 'remark-breaks';
|
||||
import RehypeKatex from 'rehype-katex';
|
||||
import RemarkGfm from 'remark-gfm';
|
||||
import '../../styles/components/chat-with-llm/markdown.css';
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 渲染组件
|
||||
* 使用 react-markdown 库进行标准 Markdown 解析,支持流式渲染
|
||||
*/
|
||||
export default function Markdown({ content, className = '' }: MarkdownProps) {
|
||||
console.log('🎨 [Markdown] 渲染组件:', {
|
||||
contentLength: content?.length || 0,
|
||||
contentPreview: content?.substring(0, 100) + (content && content.length > 100 ? '...' : ''),
|
||||
className,
|
||||
hasContent: !!content
|
||||
});
|
||||
|
||||
if (!content) {
|
||||
console.log('⚠️ [Markdown] 内容为空,返回null');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`markdown-content ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
||||
rehypePlugins={[RehypeKatex]}
|
||||
components={{
|
||||
code({ className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isCodeBlock = match;
|
||||
|
||||
if (isCodeBlock) {
|
||||
// 代码块
|
||||
return (
|
||||
<pre style={{
|
||||
backgroundColor: '#f6f8fa',
|
||||
padding: '16px',
|
||||
borderRadius: '6px',
|
||||
overflow: 'auto',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.45',
|
||||
margin: '1em 0'
|
||||
}}>
|
||||
<code style={{
|
||||
backgroundColor: 'transparent',
|
||||
padding: '0',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace'
|
||||
}}>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
} else {
|
||||
// 内联代码
|
||||
return (
|
||||
<code
|
||||
className={className}
|
||||
style={{
|
||||
backgroundColor: 'rgba(175, 184, 193, 0.2)',
|
||||
padding: '0.2em 0.4em',
|
||||
borderRadius: '3px',
|
||||
fontSize: '85%',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace'
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -202,6 +202,7 @@ const ChatSidebar = forwardRef<ChatSidebarRef, ChatSidebarProps>(({
|
||||
icon={<MoreOutlined />}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
// style={{ backgroundColor: '#00684A' }}
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
@@ -336,9 +337,9 @@ const ChatSidebar = forwardRef<ChatSidebarRef, ChatSidebarProps>(({
|
||||
</div>
|
||||
|
||||
{/* 侧边栏底部 - 固定在底部 */}
|
||||
{!collapsed && (
|
||||
<div className="p-4 border-t border-gray-100 flex-shrink-0">
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
{!collapsed && conversations.length > 0 && (
|
||||
<div className="sidebar-footer">
|
||||
<div className="stats-text">
|
||||
共 {conversations.length} 个对话
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user