[dev]add markdown style

This commit is contained in:
susie-laptop 2025-04-02 20:24:53 -04:00
parent 30a8f474ec
commit a4239cac87
8 changed files with 2109 additions and 64 deletions

1652
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,9 +18,12 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-router-dom": "^7.2.0", "react-router-dom": "^7.2.0",
"react-syntax-highlighter": "^15.6.1",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"remark-gfm": "^4.0.1",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -0,0 +1,41 @@
import React, { useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import SvgIcon from './SvgIcon';
/**
* CodeBlock component renders a syntax highlighted code block with a copy button
*/
const CodeBlock = ({ language, value }) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(value).then(() => {
setCopied(true);
// Reset the copied state after 2 seconds
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className='code-block-container'>
<div className='code-block-header'>
{language && <span className='code-language-badge'>{language}</span>}
<button onClick={handleCopy} className='copy-button' title={copied ? 'Copied!' : 'Copy code'}>
{copied ? (
<span className='copied-indicator'> Copied!</span>
) : (
<span className='copy-icon'>
<SvgIcon className='copy' width='16' height='16' />
</span>
)}
</button>
</div>
<SyntaxHighlighter style={atomDark} language={language || 'text'} PreTag='div' wrapLongLines={true}>
{value}
</SyntaxHighlighter>
</div>
);
};
export default CodeBlock;

View File

@ -0,0 +1,47 @@
import React, { Component } from 'react';
/**
* Error Boundary component to catch errors in child components
* and display a fallback UI instead of crashing the whole app
*/
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// You can log the error to an error reporting service
console.error('Error caught by ErrorBoundary:', error);
console.error('Component stack:', errorInfo.componentStack);
}
render() {
const { children, fallback } = this.props;
if (this.state.hasError) {
// You can render any custom fallback UI
if (typeof fallback === 'function') {
return fallback(this.state.error);
}
return (
fallback || (
<div className='p-3 border rounded bg-light'>
<p className='text-danger mb-1'>Error rendering content</p>
<small className='text-muted'>The content couldn't be displayed properly.</small>
</div>
)
);
}
return children;
}
}
export default ErrorBoundary;

View File

@ -0,0 +1,50 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import ErrorBoundary from './ErrorBoundary';
import CodeBlock from './CodeBlock';
/**
* SafeMarkdown component that wraps ReactMarkdown with error handling
* Displays raw content as fallback if markdown parsing fails
*/
const SafeMarkdown = ({ content, className = 'markdown-content' }) => {
// Fallback UI that shows raw content when ReactMarkdown fails
const renderFallback = (error) => {
console.error('Markdown rendering error:', error);
return (
<div className={`${className} markdown-fallback`}>
<p className='text-danger mb-2'>
<small>Error rendering markdown. Showing raw content:</small>
</p>
<div className='p-2 border rounded'>{content}</div>
</div>
);
};
return (
<ErrorBoundary fallback={renderFallback}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Apply the className to the root element
root: ({ node, ...props }) => <div className={className} {...props} />,
code({ node, inline, className: codeClassName, children, ...props }) {
const match = /language-(\w+)/.exec(codeClassName || '');
return !inline && match ? (
<CodeBlock language={match[1]} value={String(children).replace(/\n$/, '')} />
) : (
<code className={codeClassName} {...props}>
{children}
</code>
);
},
}}
>
{content}
</ReactMarkdown>
</ErrorBoundary>
);
};
export default SafeMarkdown;

View File

@ -6,6 +6,7 @@ import { showNotification } from '../../store/notification.slice';
import { createChatRecord, fetchAvailableDatasets, fetchConversationDetail } from '../../store/chat/chat.thunks'; import { createChatRecord, fetchAvailableDatasets, fetchConversationDetail } from '../../store/chat/chat.thunks';
import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks'; import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks';
import SvgIcon from '../../components/SvgIcon'; import SvgIcon from '../../components/SvgIcon';
import SafeMarkdown from '../../components/SafeMarkdown';
import { get } from '../../services/api'; import { get } from '../../services/api';
export default function ChatWindow({ chatId, knowledgeBaseId }) { export default function ChatWindow({ chatId, knowledgeBaseId }) {
@ -247,7 +248,13 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
position: 'relative', position: 'relative',
}} }}
> >
<div className='message-content'>{message.content}</div> <div className='message-content'>
{message.role === 'user' ? (
message.content
) : (
<SafeMarkdown content={message.content} />
)}
</div>
</div> </div>
<div className='message-time small text-muted mt-1'> <div className='message-time small text-muted mt-1'>
{message.created_at && new Date(message.created_at).toLocaleTimeString()} {message.created_at && new Date(message.created_at).toLocaleTimeString()}

View File

@ -8,7 +8,7 @@ import SvgIcon from '../../components/SvgIcon';
export default function NewChat() { export default function NewChat() {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [selectedDatasetId, setSelectedDatasetId] = useState(null); const [selectedDatasetIds, setSelectedDatasetIds] = useState([]);
const [isNavigating, setIsNavigating] = useState(false); const [isNavigating, setIsNavigating] = useState(false);
// Redux store // Redux store
@ -39,46 +39,89 @@ export default function NewChat() {
} }
}, [error, dispatch]); }, [error, dispatch]);
// //
const handleSelectKnowledgeBase = async (dataset) => { const handleToggleKnowledgeBase = (dataset) => {
if (isNavigating) return; // if (isNavigating) return; //
setSelectedDatasetIds((prev) => {
//
const isSelected = prev.includes(dataset.id);
if (isSelected) {
//
return prev.filter((id) => id !== dataset.id);
} else {
//
return [...prev, dataset.id];
}
});
};
//
const handleStartChat = async () => {
if (selectedDatasetIds.length === 0) {
dispatch(
showNotification({
message: '请至少选择一个知识库',
type: 'warning',
})
);
return;
}
if (isNavigating) return; //
try { try {
setSelectedDatasetId(dataset.id);
setIsNavigating(true); setIsNavigating(true);
// //
//
const existingChat = chatHistory.find((chat) => { const existingChat = chatHistory.find((chat) => {
// ID //
if (chat.datasets && Array.isArray(chat.datasets)) { if (chat.datasets && Array.isArray(chat.datasets)) {
return chat.datasets.some((ds) => ds.id === dataset.id); const chatDatasetIds = chat.datasets.map((ds) => ds.id);
return (
chatDatasetIds.length === selectedDatasetIds.length &&
selectedDatasetIds.every((id) => chatDatasetIds.includes(id))
);
} }
// //
if (chat.dataset_id_list && Array.isArray(chat.dataset_id_list)) { if (chat.dataset_id_list && Array.isArray(chat.dataset_id_list)) {
return chat.dataset_id_list.includes(dataset.id.replace(/-/g, '')); const formattedSelectedIds = selectedDatasetIds.map((id) => id.replace(/-/g, ''));
return (
chat.dataset_id_list.length === formattedSelectedIds.length &&
formattedSelectedIds.every((id) => chat.dataset_id_list.includes(id))
);
} }
return false; return false;
}); });
if (existingChat) { if (existingChat) {
// //
console.log(`找到现有聊天记录,直接导航到 /chat/${dataset.id}/${existingChat.conversation_id}`); // 使IDURL
navigate(`/chat/${dataset.id}/${existingChat.conversation_id}`); const primaryDatasetId = selectedDatasetIds[0];
console.log(`找到现有聊天记录,直接导航到 /chat/${primaryDatasetId}/${existingChat.conversation_id}`);
navigate(`/chat/${primaryDatasetId}/${existingChat.conversation_id}`);
} else { } else {
// /chat/${dataset.id} //
console.log(`未找到现有聊天记录直接创建新的聊天知识库ID: ${dataset.id}`); console.log(`未找到现有聊天记录,直接创建新的聊天,选中的知识库ID: ${selectedDatasetIds.join(', ')}`);
// Chat.jsx //
const formattedIds = selectedDatasetIds.map((id) => id.replace(/-/g, ''));
const response = await dispatch( const response = await dispatch(
createChatRecord({ createChatRecord({
dataset_id_list: [dataset.id.replace(/-/g, '')], dataset_id_list: formattedIds,
question: '选择当前知识库,创建聊天', question: '选择当前知识库,创建聊天',
}) })
).unwrap(); ).unwrap();
if (response && response.conversation_id) { if (response && response.conversation_id) {
console.log(`创建成功,导航到 /chat/${dataset.id}/${response.conversation_id}`); // 使IDURL
navigate(`/chat/${dataset.id}/${response.conversation_id}`); const primaryDatasetId = selectedDatasetIds[0];
console.log(`创建成功,导航到 /chat/${primaryDatasetId}/${response.conversation_id}`);
navigate(`/chat/${primaryDatasetId}/${response.conversation_id}`);
} else { } else {
throw new Error('未能获取会话ID'); throw new Error('未能获取会话ID');
} }
@ -92,7 +135,6 @@ export default function NewChat() {
}) })
); );
setIsNavigating(false); setIsNavigating(false);
setSelectedDatasetId(null);
} }
}; };
@ -127,47 +169,63 @@ export default function NewChat() {
</div> </div>
)} )}
<h4 className='mb-4'>选择知识库开始聊天</h4> <div className='d-flex justify-content-between align-items-center mb-4'>
<h4 className='m-0'>选择知识库开始聊天</h4>
<button
className='btn btn-dark'
onClick={handleStartChat}
disabled={selectedDatasetIds.length === 0 || isNavigating}
>
{isNavigating ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
加载中...
</>
) : (
<>开始聊天 {selectedDatasetIds.length > 0 && `(${selectedDatasetIds.length})`}</>
)}
</button>
</div>
{selectedDatasetIds.length > 0 && (
<div className='alert alert-dark mb-4'>已选择 {selectedDatasetIds.length} 个知识库</div>
)}
<div className='row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4 justify-content-center'> <div className='row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4 justify-content-center'>
{datasets.length > 0 ? ( {datasets.length > 0 ? (
datasets.map((dataset) => ( datasets.map((dataset) => {
const isSelected = selectedDatasetIds.includes(dataset.id);
return (
<div key={dataset.id} className='col'> <div key={dataset.id} className='col'>
<div <div
className={`card h-100 shadow-sm border-0 ${!isNavigating ? 'cursor-pointer' : ''} ${ className={`card h-100 shadow-sm ${!isNavigating ? 'cursor-pointer' : ''} ${
selectedDatasetId === dataset.id ? 'border-primary' : '' isSelected ? 'border-gray border-2' : 'border-0'
}`} }`}
onClick={() => handleSelectKnowledgeBase(dataset)} onClick={() => handleToggleKnowledgeBase(dataset)}
style={{ opacity: isNavigating && selectedDatasetId !== dataset.id ? 0.6 : 1 }} style={{ opacity: isNavigating && !isSelected ? 0.6 : 1 }}
> >
<div className='card-body'> <div className='card-body'>
<h5 className='card-title d-flex justify-content-between align-items-center'> <h5 className='card-title d-flex justify-content-between align-items-center'>
{dataset.name} {dataset.name}
{selectedDatasetId === dataset.id && isNavigating && ( {isSelected && <SvgIcon className='check-circle text-primary' />}
<div
className='spinner-border spinner-border-sm text-primary'
role='status'
>
<span className='visually-hidden'>加载中...</span>
</div>
)}
</h5> </h5>
<p className='card-text text-muted'>{dataset.desc || dataset.description || ''}</p> <p className='card-text text-muted'>
{dataset.desc || dataset.description || ''}
</p>
<div className='text-muted small d-flex align-items-center gap-2'> <div className='text-muted small d-flex align-items-center gap-2'>
<span className='d-flex align-items-center gap-1'> <span className='d-flex align-items-center gap-1'>
<SvgIcon className='file' /> {dataset.department || ''}
{dataset.document_count || 0} 文档
</span>
<span className='d-flex align-items-center gap-1'>
<SvgIcon className='clock' />
{dataset.create_time
? new Date(dataset.create_time).toLocaleDateString()
: 'N/A'}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
)) );
})
) : ( ) : (
<div className='col-12'> <div className='col-12'>
<div className='alert alert-warning'>暂无可访问的知识库请先申请知识库访问权限</div> <div className='alert alert-warning'>暂无可访问的知识库请先申请知识库访问权限</div>

View File

@ -11,6 +11,134 @@
top: 6.5rem; top: 6.5rem;
} }
/* Markdown styling in chat messages */
.markdown-content {
font-size: 0.95rem;
line-height: 1.6;
color: inherit;
/* Heading styles */
h1, h2, h3, h4, h5, h6 {
margin-top: 1rem;
margin-bottom: 0.75rem;
font-weight: 600;
}
h1 { font-size: 1.5rem; }
h2 { font-size: 1.35rem; }
h3 { font-size: 1.2rem; }
h4 { font-size: 1.1rem; }
h5, h6 { font-size: 1rem; }
/* Paragraph spacing */
p {
margin-bottom: 0.75rem;
}
/* Lists */
ul, ol {
padding-left: 1.5rem;
margin-bottom: 0.75rem;
}
/* Code blocks with syntax highlighting */
pre, pre.prism-code {
margin: 0.5rem 0 !important;
padding: 0.75rem !important;
border-radius: 0.375rem !important;
font-size: 0.85rem !important;
line-height: 1.5 !important;
/* Improve readability on dark background */
code span {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
}
}
/* Add copy button positioning for future enhancements */
.code-block-container {
position: relative;
margin: 0.75rem 0;
}
/* Inline code */
code {
background-color: rgba(0, 0, 0, 0.05);
padding: 0.15rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.9rem;
}
/* Block quotes */
blockquote {
border-left: 3px solid #dee2e6;
padding-left: 1rem;
margin-left: 0;
color: #6c757d;
}
/* Links */
a {
color: #0d6efd;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
/* Tables */
table {
width: 100%;
margin-bottom: 0.75rem;
border-collapse: collapse;
th, td {
padding: 0.5rem;
border: 1px solid #dee2e6;
}
th {
background-color: #f8f9fa;
}
}
/* Images */
img {
max-width: 100%;
height: auto;
border-radius: 0.25rem;
}
/* Horizontal rule */
hr {
margin: 1rem 0;
border: 0;
border-top: 1px solid #dee2e6;
}
}
/* Apply different text colors based on message background */
.bg-dark .markdown-content {
color: white;
code {
background-color: rgba(255, 255, 255, 0.1);
}
pre {
background-color: rgba(255, 255, 255, 0.1);
}
blockquote {
border-left-color: rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.8);
}
a {
color: #8bb9fe;
}
}
.knowledge-card { .knowledge-card {
min-width: 20rem; min-width: 20rem;
cursor: pointer; cursor: pointer;
@ -144,3 +272,78 @@
.dark-select { .dark-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='%23000' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='%23000' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
} }
/* Code Block Styles */
.code-block-container {
position: relative;
margin: 0.75rem 0;
border-radius: 0.375rem;
overflow: hidden;
background-color: #282c34; /* Dark background matching atomDark theme */
}
.code-block-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.75rem;
background-color: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.code-language-badge {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.copy-button {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 0.25rem;
border-radius: 0.25rem;
transition: all 0.2s;
&:hover {
color: rgba(255, 255, 255, 0.9);
background-color: rgba(255, 255, 255, 0.1);
}
}
.copied-indicator {
color: #10b981; /* Green color for success */
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Override the default SyntaxHighlighter styles */
.code-block-container pre {
margin: 0 !important;
border-radius: 0 !important; /* Remove rounded corners inside the container */
}
/* Markdown fallback styling */
.markdown-fallback {
font-size: 0.95rem;
.text-danger {
font-weight: 500;
}
pre {
white-space: pre-wrap;
word-break: break-word;
background-color: rgba(0, 0, 0, 0.05);
padding: 0.75rem;
border-radius: 0.375rem;
margin-top: 0.5rem;
}
}