Compare commits

...

5 Commits

26 changed files with 2802 additions and 287 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",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.2.0",
"react-syntax-highlighter": "^15.6.1",
"redux-persist": "^6.0.0",
"remark-gfm": "^4.0.1",
"uuid": "^11.1.0"
},
"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

@ -83,8 +83,8 @@ const CreateKnowledgeBaseModal = ({
const availableTypes = getAvailableTypes();
//
const isMemberTypeSelected = formData.type === 'member';
const needSelectGroup = isMemberTypeSelected;
const needDepartmentAndGroup = formData.type === 'member' || formData.type === 'leader';
const needSelectGroup = needDepartmentAndGroup;
return (
<div
@ -179,8 +179,8 @@ const CreateKnowledgeBaseModal = ({
{formErrors.type && <div className='text-danger small mt-1'>{formErrors.type}</div>}
</div>
{/* 仅当不是私有知识库时才显示部门选项 */}
{formData.type === 'member' && (
{/* 仅当不是私有知识库且需要部门和组别时才显示部门选项 */}
{needDepartmentAndGroup && (
<div className='mb-3'>
<label htmlFor='department' className='form-label'>
部门 {isAdmin && needSelectGroup && <span className='text-danger'>*</span>}
@ -219,8 +219,8 @@ const CreateKnowledgeBaseModal = ({
</div>
)}
{/* 仅当不是私有知识库时才显示组别选项 */}
{formData.type === 'member' && (
{/* 仅当不是私有知识库且需要部门和组别时才显示组别选项 */}
{needDepartmentAndGroup && (
<div className='mb-3'>
<label htmlFor='group' className='form-label'>
组别 {needSelectGroup && <span className='text-danger'>*</span>}

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

@ -120,4 +120,6 @@ export const icons = {
plus: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
</svg>`,
building: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="16" height="16" fill="currentColor"><path d="M48 0C21.5 0 0 21.5 0 48L0 464c0 26.5 21.5 48 48 48l96 0 0-80c0-26.5 21.5-48 48-48s48 21.5 48 48l0 80 96 0c26.5 0 48-21.5 48-48l0-416c0-26.5-21.5-48-48-48L48 0zM64 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm112-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zm80 16c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM80 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zm80 16c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM272 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16z"/></svg>`,
group:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" width="16" height="16" fill="currentColor"><path d="M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3zM609.3 512l-137.8 0c5.4-9.4 8.6-20.3 8.6-32l0-8c0-60.7-27.1-115.2-69.8-151.8c2.4-.1 4.7-.2 7.1-.2l61.4 0C567.8 320 640 392.2 640 481.3c0 17-13.8 30.7-30.7 30.7zM432 256c-31 0-59-12.6-79.3-32.9C372.4 196.5 384 163.6 384 128c0-26.8-6.6-52.1-18.3-74.3C384.3 40.1 407.2 32 432 32c61.9 0 112 50.1 112 112s-50.1 112-112 112z"/></svg>`
};

View File

@ -68,7 +68,6 @@ export default function HeaderWithNav() {
Chat
</Link>
</li>
{hasManagePermission && (
<li className='nav-item'>
<Link
className={`nav-link ${isActive('/permissions') ? 'active' : ''}`}
@ -77,11 +76,10 @@ export default function HeaderWithNav() {
权限管理
</Link>
</li>
)}
</ul>
{!!user ? (
<div className='d-flex align-items-center gap-3'>
<div className='position-relative'>
{/* <div className='position-relative'>
<button
className='btn btn-link text-dark p-0'
onClick={() => setShowNotifications(!showNotifications)}
@ -104,7 +102,7 @@ export default function HeaderWithNav() {
</span>
)}
</button>
</div>
</div> */}
<div className='flex-shrink-0 dropdown'>
<a
href='#'
@ -123,7 +121,7 @@ export default function HeaderWithNav() {
transform: 'translate(0px, 34px)',
}}
>
<li>
<li className='d-none'>
<Link
className='dropdown-item'
to='#'
@ -132,7 +130,7 @@ export default function HeaderWithNav() {
个人设置
</Link>
</li>
<li>
<li className='d-none'>
<hr className='dropdown-divider' />
</li>
<li>

View File

@ -100,7 +100,7 @@ export default function ChatSidebar({ chatHistory = [], onDeleteChat, isLoading
</div>
</Link>
<div
className='dropdown-hover-area position-absolute end-0 top-0 bottom-0'
className='dropdown-hover-area position-absolute end-0 top-0 bottom-0 d-none'
style={{ width: '40px' }}
onMouseEnter={() => handleMouseEnter(chat.conversation_id)}
onMouseLeave={handleMouseLeave}

View File

@ -6,6 +6,7 @@ import { showNotification } from '../../store/notification.slice';
import { createChatRecord, fetchAvailableDatasets, fetchConversationDetail } from '../../store/chat/chat.thunks';
import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks';
import SvgIcon from '../../components/SvgIcon';
import SafeMarkdown from '../../components/SafeMarkdown';
import { get } from '../../services/api';
export default function ChatWindow({ chatId, knowledgeBaseId }) {
@ -247,24 +248,28 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
position: 'relative',
}}
>
<div className='message-content'>{message.content}</div>
<div className='message-content'>
{message.role === 'user' ? (
message.content
) : (
<SafeMarkdown content={message.content} />
)}
{message.is_streaming && (
<span className='streaming-indicator'>
<span className='dot dot1'></span>
<span className='dot dot2'></span>
<span className='dot dot3'></span>
</span>
)}
</div>
</div>
<div className='message-time small text-muted mt-1'>
{message.created_at && new Date(message.created_at).toLocaleTimeString()}
{message.is_streaming && ' · 正在生成...'}
</div>
</div>
))}
{sendStatus === 'loading' && (
<div className='d-flex justify-content-start mb-3'>
<div className='chat-message p-3 rounded-3 bg-light'>
<div className='spinner-border spinner-border-sm text-secondary' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>

View File

@ -8,7 +8,7 @@ import SvgIcon from '../../components/SvgIcon';
export default function NewChat() {
const navigate = useNavigate();
const dispatch = useDispatch();
const [selectedDatasetId, setSelectedDatasetId] = useState(null);
const [selectedDatasetIds, setSelectedDatasetIds] = useState([]);
const [isNavigating, setIsNavigating] = useState(false);
// Redux store
@ -39,60 +39,124 @@ export default function NewChat() {
}
}, [error, dispatch]);
//
const handleSelectKnowledgeBase = async (dataset) => {
if (isNavigating) return; //
//
const handleToggleKnowledgeBase = (dataset) => {
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 {
setSelectedDatasetId(dataset.id);
setIsNavigating(true);
//
//
console.log('选中的知识库ID:', selectedDatasetIds);
//
//
const existingChat = chatHistory.find((chat) => {
// ID
//
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)) {
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;
});
if (existingChat) {
//
console.log(`找到现有聊天记录,直接导航到 /chat/${dataset.id}/${existingChat.conversation_id}`);
navigate(`/chat/${dataset.id}/${existingChat.conversation_id}`);
// 使IDURL
const primaryDatasetId = selectedDatasetIds[0];
console.log(`找到现有聊天记录,直接导航到 /chat/${primaryDatasetId}/${existingChat.conversation_id}`);
navigate(`/chat/${primaryDatasetId}/${existingChat.conversation_id}`);
} else {
// /chat/${dataset.id}
console.log(`未找到现有聊天记录直接创建新的聊天知识库ID: ${dataset.id}`);
//
console.log(`未找到现有聊天记录,直接创建新的聊天,选中的知识库ID: ${selectedDatasetIds.join(', ')}`);
// Chat.jsx
//
const formattedIds = selectedDatasetIds.map((id) => id.replace(/-/g, ''));
console.log('格式化后的知识库ID:', formattedIds);
try {
//
const response = await dispatch(
createChatRecord({
dataset_id_list: [dataset.id.replace(/-/g, '')],
dataset_id_list: formattedIds,
question: '选择当前知识库,创建聊天',
})
).unwrap();
console.log('创建聊天响应:', response);
if (response && response.conversation_id) {
console.log(`创建成功,导航到 /chat/${dataset.id}/${response.conversation_id}`);
navigate(`/chat/${dataset.id}/${response.conversation_id}`);
// 使IDURL
const primaryDatasetId = selectedDatasetIds[0];
console.log(`创建成功,导航到 /chat/${primaryDatasetId}/${response.conversation_id}`);
navigate(`/chat/${primaryDatasetId}/${response.conversation_id}`);
} else {
throw new Error('未能获取会话ID');
throw new Error('未能获取会话ID' + JSON.stringify(response));
}
} catch (apiError) {
// API
console.error('API调用失败:', apiError);
throw new Error(`API调用失败: ${apiError.message || '未知错误'}`);
}
}
} catch (error) {
console.error('导航或创建聊天失败:', error);
//
if (error.stack) {
console.error('错误堆栈:', error.stack);
}
//
dispatch(
showNotification({
message: `创建聊天失败: ${error.message || '请重试'}`,
type: 'danger',
})
);
} finally {
setIsNavigating(false);
setSelectedDatasetId(null);
}
};
@ -127,47 +191,63 @@ export default function NewChat() {
</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'>
{datasets.length > 0 ? (
datasets.map((dataset) => (
datasets.map((dataset) => {
const isSelected = selectedDatasetIds.includes(dataset.id);
return (
<div key={dataset.id} className='col'>
<div
className={`card h-100 shadow-sm border-0 ${!isNavigating ? 'cursor-pointer' : ''} ${
selectedDatasetId === dataset.id ? 'border-primary' : ''
className={`card h-100 shadow-sm ${!isNavigating ? 'cursor-pointer' : ''} ${
isSelected ? 'border-gray border-2' : 'border-0'
}`}
onClick={() => handleSelectKnowledgeBase(dataset)}
style={{ opacity: isNavigating && selectedDatasetId !== dataset.id ? 0.6 : 1 }}
onClick={() => handleToggleKnowledgeBase(dataset)}
style={{ opacity: isNavigating && !isSelected ? 0.6 : 1 }}
>
<div className='card-body'>
<h5 className='card-title d-flex justify-content-between align-items-center'>
{dataset.name}
{selectedDatasetId === dataset.id && isNavigating && (
<div
className='spinner-border spinner-border-sm text-primary'
role='status'
>
<span className='visually-hidden'>加载中...</span>
</div>
)}
{isSelected && <SvgIcon className='check-circle text-primary' />}
</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'>
<span className='d-flex align-items-center gap-1'>
<SvgIcon className='file' />
{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'}
{dataset.department || ''}
</span>
</div>
</div>
</div>
</div>
))
);
})
) : (
<div className='col-12'>
<div className='alert alert-warning'>暂无可访问的知识库请先申请知识库访问权限</div>

View File

@ -7,6 +7,7 @@ import SvgIcon from '../../../components/SvgIcon';
import Breadcrumb from './components/Breadcrumb';
import DocumentList from './components/DocumentList';
import FileUploadModal from './components/FileUploadModal';
import { getKnowledgeBaseDocuments } from '../../../store/knowledgeBase/knowledgeBase.thunks';
export default function DatasetTab({ knowledgeBase }) {
const dispatch = useDispatch();
@ -33,6 +34,14 @@ export default function DatasetTab({ knowledgeBase }) {
setDocuments(knowledgeBase.documents || []);
}, [knowledgeBase]);
//
useEffect(() => {
if (knowledgeBase?.id) {
dispatch(getKnowledgeBaseDocuments({ knowledge_base_id: knowledgeBase.id }));
}
}, [dispatch, knowledgeBase?.id]);
// Handle click outside dropdown
useEffect(() => {
function handleClickOutside(event) {
@ -268,8 +277,9 @@ export default function DatasetTab({ knowledgeBase }) {
{/* Toolbar */}
<div className='d-flex justify-content-between align-items-center mb-3'>
<div className='d-flex gap-2'>
<div className='d-flex gap-2' title={knowledgeBase.permissions?.can_edit ? '上传文件' : '无权限'}>
<button
disabled={!knowledgeBase.permissions?.can_edit}
className='btn btn-dark d-flex align-items-center gap-1'
onClick={() => setShowAddFileModal(true)}
>
@ -320,6 +330,7 @@ export default function DatasetTab({ knowledgeBase }) {
{/* Document list */}
<DocumentList
documents={filteredDocuments}
knowledgeBaseId={knowledgeBase.id}
selectedDocuments={selectedDocuments}
selectAll={selectAll}
onSelectAll={handleSelectAll}
@ -358,6 +369,7 @@ export default function DatasetTab({ knowledgeBase }) {
{/* File upload modal */}
<FileUploadModal
show={showAddFileModal}
knowledgeBaseId={knowledgeBase.id}
newFile={newFile}
fileErrors={fileErrors}
isSubmitting={isSubmitting}

View File

@ -6,16 +6,12 @@ import {
updateKnowledgeBase,
deleteKnowledgeBase,
changeKnowledgeBaseType,
getKnowledgeBaseDocuments,
} from '../../../store/knowledgeBase/knowledgeBase.thunks';
//
import Breadcrumb from './components/Breadcrumb';
import KnowledgeBaseForm from './components/KnowledgeBaseForm';
import DeleteConfirmModal from './components/DeleteConfirmModal';
import UserPermissionsManager from './components/UserPermissionsManager';
import FileUploadModal from './components/FileUploadModal';
import DocumentList from './components/DocumentList';
//
const departmentGroups = {
@ -52,13 +48,6 @@ export default function SettingsTab({ knowledgeBase }) {
const [availableGroups, setAvailableGroups] = useState([]);
const [showUploadModal, setShowUploadModal] = useState(false);
//
useEffect(() => {
if (knowledgeBase?.id) {
dispatch(getKnowledgeBaseDocuments({ knowledge_base_id: knowledgeBase.id }));
}
}, [dispatch, knowledgeBase?.id]);
//
useEffect(() => {
if (knowledgeBaseForm.department && departmentGroups[knowledgeBaseForm.department]) {
@ -135,8 +124,6 @@ export default function SettingsTab({ knowledgeBase }) {
// Validate knowledge base form
const validateForm = () => {
const errors = {};
//
const isPrivate = knowledgeBaseForm.type === 'private';
if (!knowledgeBaseForm.name.trim()) {
errors.name = '请输入知识库名称';
@ -151,16 +138,15 @@ export default function SettingsTab({ knowledgeBase }) {
}
//
if (!isPrivate) {
if (isAdmin && !knowledgeBaseForm.department) {
if (
(knowledgeBaseForm.type === 'leader' || knowledgeBaseForm.type === 'member') &&
!knowledgeBaseForm.department
) {
errors.department = '请选择部门';
}
if (isAdmin && !knowledgeBaseForm.group) {
if (knowledgeBaseForm.type === 'member' && !knowledgeBaseForm.group) {
errors.group = '请选择组别';
}
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
@ -199,6 +185,7 @@ export default function SettingsTab({ knowledgeBase }) {
if (isAdmin && !validateForm()) {
return;
}
console.log(newType, currentUser.role);
setIsSubmitting(true);
@ -206,6 +193,7 @@ export default function SettingsTab({ knowledgeBase }) {
const isPrivate = newType === 'private';
const department = isPrivate ? '' : isAdmin ? knowledgeBaseForm.department : currentUser.department || '';
const group = isPrivate ? '' : isAdmin ? knowledgeBaseForm.group : currentUser.group || '';
console.log(newType, currentUser.role);
dispatch(
changeKnowledgeBaseType({
@ -345,27 +333,6 @@ export default function SettingsTab({ knowledgeBase }) {
availableGroups={availableGroups}
/>
{/* Document Management Section */}
<div className='card border-0 shadow-sm mt-4'>
<div className='card-body'>
<div className='d-flex justify-content-between align-items-center mb-4'>
<h5 className='card-title m-0'>文档管理</h5>
<button className='btn btn-primary' onClick={() => setShowUploadModal(true)}>
上传文档
</button>
</div>
<DocumentList knowledgeBaseId={knowledgeBase.id} />
</div>
</div>
{/* File Upload Modal */}
<FileUploadModal
show={showUploadModal}
onClose={() => setShowUploadModal(false)}
knowledgeBaseId={knowledgeBase.id}
/>
{/* User Permissions Manager */}
{/* <UserPermissionsManager knowledgeBase={knowledgeBase} /> */}

View File

@ -1,7 +1,8 @@
import React from 'react';
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { formatDate } from '../../../../utils/dateUtils';
import { deleteKnowledgeBaseDocument } from '../../../../store/knowledgeBase/knowledgeBase.thunks';
import DocumentPreviewModal from './DocumentPreviewModal';
/**
* 知识库文档列表组件
@ -9,6 +10,8 @@ import { deleteKnowledgeBaseDocument } from '../../../../store/knowledgeBase/kno
const DocumentList = ({ knowledgeBaseId }) => {
const dispatch = useDispatch();
const { items, loading, pagination } = useSelector((state) => state.knowledgeBase.documents);
const [previewModalVisible, setPreviewModalVisible] = useState(false);
const [selectedDocumentId, setSelectedDocumentId] = useState(null);
const handleDeleteDocument = (documentId) => {
if (window.confirm('确定要删除此文档吗?')) {
@ -21,6 +24,16 @@ const DocumentList = ({ knowledgeBaseId }) => {
}
};
const handlePreviewDocument = (documentId) => {
setSelectedDocumentId(documentId);
setPreviewModalVisible(true);
};
const handleClosePreviewModal = () => {
setPreviewModalVisible(false);
setSelectedDocumentId(null);
};
if (loading) {
return (
<div className='text-center py-4'>
@ -40,6 +53,7 @@ const DocumentList = ({ knowledgeBaseId }) => {
}
return (
<>
<div className='table-responsive'>
<table className='table table-hover'>
<thead className='table-light'>
@ -57,12 +71,20 @@ const DocumentList = ({ knowledgeBaseId }) => {
<td>{formatDateTime(doc.create_time)}</td>
<td>{formatDateTime(doc.update_time)}</td>
<td>
<div className='btn-group' role='group'>
<button
className='btn btn-sm btn-outline-primary me-2'
onClick={() => handlePreviewDocument(doc.document_id)}
>
预览
</button>
<button
className='btn btn-sm btn-outline-danger'
onClick={() => handleDeleteDocument(doc.document_id)}
>
删除
</button>
</div>
</td>
</tr>
))}
@ -75,6 +97,14 @@ const DocumentList = ({ knowledgeBaseId }) => {
</div>
)}
</div>
<DocumentPreviewModal
show={previewModalVisible}
documentId={selectedDocumentId}
knowledgeBaseId={knowledgeBaseId}
onClose={handleClosePreviewModal}
/>
</>
);
};

View File

@ -0,0 +1,130 @@
import React, { useState, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { get } from '../../../../services/api';
import { showNotification } from '../../../../store/notification.slice';
/**
* 文档预览模态框组件
*/
const DocumentPreviewModal = ({ show, documentId, knowledgeBaseId, onClose }) => {
const dispatch = useDispatch();
const [documentContent, setDocumentContent] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (show && documentId && knowledgeBaseId) {
fetchDocumentContent();
}
}, [show, documentId, knowledgeBaseId]);
const fetchDocumentContent = async () => {
setLoading(true);
try {
const response = await get(`/knowledge-bases/${knowledgeBaseId}/document_content/`, {
params: { document_id: documentId },
});
// API
if (response.code === 200 && response.data) {
setDocumentContent(response.data);
} else if (response.data && response.data.code === 200) {
// data
setDocumentContent(response.data.data);
} else {
//
setDocumentContent(response);
}
} catch (error) {
console.error('获取文档内容失败:', error);
dispatch(
showNotification({
type: 'danger',
message: '获取文档内容失败',
})
);
} finally {
setLoading(false);
}
};
if (!show) return null;
return (
<div
className='modal-backdrop'
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1050,
}}
>
<div
className='modal-content bg-white rounded shadow'
style={{
width: '800px',
maxWidth: '90%',
maxHeight: '80vh',
padding: '20px',
overflow: 'auto',
}}
>
<div className='modal-header d-flex justify-content-between align-items-center mb-3'>
<h5 className='modal-title m-0'>{documentContent?.document_info?.name || '文档预览'}</h5>
<button type='button' className='btn-close' onClick={onClose} aria-label='Close'></button>
</div>
<div className='modal-body'>
{loading ? (
<div className='text-center py-4'>
<div className='spinner-border text-primary' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
</div>
) : documentContent ? (
<div className='document-content'>
{documentContent.content &&
documentContent.content.map((section, index) => {
let contentDisplay;
try {
// JSON
const parsedContent = JSON.parse(section.content);
contentDisplay = (
<pre className='bg-light p-3 rounded'>
{JSON.stringify(parsedContent, null, 2)}
</pre>
);
} catch (e) {
// JSON
contentDisplay = <p>{section.content}</p>;
}
return (
<div key={index} className='mb-3 p-3 border rounded'>
{contentDisplay}
</div>
);
})}
</div>
) : (
<div className='text-center py-4 text-muted'>
<p>无法获取文档内容</p>
</div>
)}
</div>
<div className='modal-footer'>
<button type='button' className='btn btn-secondary' onClick={onClose}>
关闭
</button>
</div>
</div>
</div>
);
};
export default DocumentPreviewModal;

View File

@ -74,7 +74,8 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
// Reset the file input
resetFileInput();
//
//
onClose();
} catch (error) {
console.error('Upload failed:', error);
setFileError('文件上传失败: ' + (error?.message || '未知错误'));
@ -184,7 +185,7 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
</button>
<button
type='button'
className='btn btn-primary'
className='btn btn-dark'
onClick={handleUpload}
disabled={!selectedFile || isUploading}
>

View File

@ -59,10 +59,6 @@ const KnowledgeBaseForm = ({
//
const showTypeChangeButton = hasTypeChanged || (isAdmin && hasDepartmentOrGroupChanged);
console.log(formData);
return (
<div className='card border-0 shadow-sm'>
<div className='card-body'>
@ -79,6 +75,7 @@ const KnowledgeBaseForm = ({
id='name'
name='name'
value={formData.name}
disabled={!formData.permissions.can_edit}
onChange={onInputChange}
/>
{formErrors.name && <div className='invalid-feedback'>{formErrors.name}</div>}
@ -94,6 +91,7 @@ const KnowledgeBaseForm = ({
name='desc'
rows='3'
value={formData.desc}
disabled={!formData.permissions.can_edit}
onChange={onInputChange}
></textarea>
{formErrors.desc && <div className='invalid-feedback'>{formErrors.desc}</div>}
@ -114,7 +112,7 @@ const KnowledgeBaseForm = ({
value={type.value}
checked={formData.type === type.value}
onChange={onInputChange}
disabled={false} //
disabled={!formData.permissions.can_edit}
/>
<label className='form-check-label' htmlFor={`type${type.value}`}>
{type.label}
@ -219,7 +217,7 @@ const KnowledgeBaseForm = ({
<div>
{hasTypeChanged && (
<p className='mb-0'>
知识库类型更改为 <strong>{formData.type}</strong>
知识库类型更改为 <strong>{formData.type}</strong>
</p>
)}
{isAdmin && hasDepartmentOrGroupChanged && <p className='mb-0'>部门/组别已更改</p>}
@ -248,7 +246,7 @@ const KnowledgeBaseForm = ({
)}
<div className='d-flex justify-content-between mt-4'>
<button type='submit' className='btn btn-primary' disabled={isSubmitting}>
<button type='submit' className='btn btn-primary' disabled={isSubmitting|| !formData.permissions.can_edit}>
{isSubmitting ? (
<>
<span
@ -267,7 +265,7 @@ const KnowledgeBaseForm = ({
className='btn btn-danger'
onClick={onDelete}
// disabled='true'
disabled={isSubmitting}
disabled={isSubmitting || !formData.permissions.can_edit}
>
删除知识库
</button>

View File

@ -242,8 +242,8 @@ export default function KnowledgeBase() {
const errors = {};
const isAdmin = currentUser?.role === 'admin';
const isLeader = currentUser?.role === 'leader';
// member
const needSelectGroup = newKnowledgeBase.type === 'member';
// member
const needDepartmentAndGroup = newKnowledgeBase.type === 'member' || newKnowledgeBase.type === 'leader';
//
const isPrivate = newKnowledgeBase.type === 'private';
@ -259,16 +259,16 @@ export default function KnowledgeBase() {
errors.type = '请选择知识库类型';
}
// member
if (needSelectGroup && !isPrivate) {
// memberleader
if (needDepartmentAndGroup && !isPrivate) {
//
if (isAdmin && !newKnowledgeBase.department) {
errors.department = '创建member级别知识库时必须选择部门';
errors.department = `创建${newKnowledgeBase.type === 'leader' ? '组长级' : '组内'}知识库时必须选择部门`;
}
// member
// memberleader
if (!newKnowledgeBase.group) {
errors.group = '创建member级别知识库时必须选择组别';
errors.group = `创建${newKnowledgeBase.type === 'leader' ? '组长级' : '组内'}知识库时必须选择组别`;
}
}

View File

@ -58,6 +58,9 @@ export default function KnowledgeCard({
</ul>
</div>
)}
<div className='text-muted d-flex align-items-center gap-1'>
<SvgIcon className={'group'} />{department || ''} {group || 'N/A'}
</div>
<p className='card-text text-muted mb-3' style={descriptionStyle} title={description}>
{description}
</p>
@ -95,6 +98,7 @@ export default function KnowledgeCard({
)}
{access === 'full' || access === 'read' ? (
<></>
) : (
// <button
// className='btn btn-outline-dark btn-sm d-flex align-items-center gap-1'
// onClick={handleNewChat}
@ -102,7 +106,6 @@ export default function KnowledgeCard({
// <SvgIcon className={'chat-dot'} />
//
// </button>
) : (
<button
className='btn btn-outline-dark btn-sm d-flex align-items-center gap-1'
onClick={handleRequestAccess}

View File

@ -6,15 +6,8 @@ import UserPermissions from './components/UserPermissions';
import './Permissions.css';
export default function PermissionsPage() {
const navigate = useNavigate();
const { user } = useSelector((state) => state.auth);
// leader admin
useEffect(() => {
if (!user || (user.role !== 'leader' && user.role !== 'admin')) {
navigate('/');
}
}, [user, navigate]);
return (
<div className='permissions-container'>

View File

@ -72,7 +72,7 @@ function AppRouter() {
</Mainlayout>
}
/>
{/* 权限管理页面路由 - 仅对 leader 或 admin 角色可见 */}
{/* 权限管理页面路由 */}
<Route
path='/permissions'
element={

View File

@ -214,6 +214,93 @@ export const switchToRealApi = async () => {
return isServerUp;
};
// Handle streaming requests
const streamRequest = async (url, data, onChunk, onError) => {
try {
if (isServerDown) {
console.log(`[MOCK MODE] STREAM ${url}`);
// 模拟流式响应
setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"这是模拟的","conversation_id":"mock-1234"}}'), 300);
setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"流式","conversation_id":"mock-1234"}}'), 600);
setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"响应","conversation_id":"mock-1234"}}'), 900);
setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"数据","conversation_id":"mock-1234","is_end":true}}'), 1200);
return { success: true, conversation_id: 'mock-1234' };
}
// 获取认证Token
const encryptedToken = sessionStorage.getItem('token') || '';
let token = '';
if (encryptedToken) {
token = CryptoJS.AES.decrypt(encryptedToken, secretKey).toString(CryptoJS.enc.Utf8);
}
// 使用fetch API进行流式请求
const response = await fetch(`/api${url}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Token ${token}` : '',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
// 获取响应体的reader
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let conversationId = null;
// 处理流式数据
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解码并处理数据
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// 按行分割并处理JSON
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 保留最后一行(可能不完整)
for (const line of lines) {
if (!line.trim()) continue;
try {
// 检查是否为SSE格式(data: {...})
let jsonStr = line;
if (line.startsWith('data:')) {
// 提取data:后面的JSON部分
jsonStr = line.substring(5).trim();
console.log('检测到SSE格式数据提取JSON:', jsonStr);
}
// 尝试解析JSON
const data = JSON.parse(jsonStr);
if (data.code === 200 && data.data && data.data.conversation_id) {
conversationId = data.data.conversation_id;
}
onChunk(jsonStr);
} catch (e) {
console.warn('Failed to parse JSON:', line, e);
}
}
}
return { success: true, conversation_id: conversationId };
} catch (error) {
console.error('Streaming request failed:', error);
if (onError) {
onError(error);
}
throw error;
}
};
// 权限相关API
export const applyPermission = (data) => {
return post('/permissions/', data);
@ -231,4 +318,4 @@ export const rejectPermission = (permissionId) => {
return post(`/permissions/reject_permission/${permissionId}`);
};
export { get, post, put, del, upload };
export { get, post, put, del, upload, streamRequest };

View File

@ -104,6 +104,25 @@ const chatSlice = createSlice({
addMessage: (state, action) => {
state.messages.items.push(action.payload);
},
// 更新消息(用于流式传输)
updateMessage: (state, action) => {
const { id, ...updates } = action.payload;
const messageIndex = state.messages.items.findIndex((msg) => msg.id === id);
if (messageIndex !== -1) {
// 更新现有消息
state.messages.items[messageIndex] = {
...state.messages.items[messageIndex],
...updates,
};
// 如果流式传输结束,更新发送消息状态
if (updates.is_streaming === false) {
state.sendMessage.status = 'succeeded';
}
}
},
},
extraReducers: (builder) => {
// 获取聊天列表
@ -114,14 +133,24 @@ const chatSlice = createSlice({
})
.addCase(fetchChats.fulfilled, (state, action) => {
state.list.status = 'succeeded';
// 检查是否是追加模式
if (action.payload.append) {
// 追加模式:将新结果添加到现有列表的前面
state.list.items = [...action.payload.results, ...state.list.items];
state.history.items = [...action.payload.results, ...state.history.items];
} else {
// 替换模式:使用新结果替换整个列表
state.list.items = action.payload.results;
state.list.total = action.payload.total;
state.list.page = action.payload.page;
state.list.page_size = action.payload.page_size;
// 同时更新新的状态结构
state.history.status = 'succeeded';
state.history.items = action.payload.results;
}
state.history.status = 'succeeded';
state.history.error = null;
})
.addCase(fetchChats.rejected, (state, action) => {
@ -232,62 +261,19 @@ const chatSlice = createSlice({
state.sendMessage.error = null;
})
.addCase(createChatRecord.fulfilled, (state, action) => {
state.sendMessage.status = 'succeeded';
// 添加新的消息
state.messages.items.push({
id: action.payload.id,
role: 'user',
content: action.meta.arg.question,
created_at: new Date().toISOString(),
});
// 添加助手回复
if (action.payload.role === 'assistant' && action.payload.content) {
state.messages.items.push({
id: action.payload.id,
role: 'assistant',
content: action.payload.content,
created_at: action.payload.created_at,
});
}
// 更新聊天记录列表
const chatExists = state.history.items.some(
(chat) => chat.conversation_id === action.payload.conversation_id
);
if (!chatExists) {
const newChat = {
// 更新状态以反映聊天已创建
if (action.payload.conversation_id && !state.currentChat.data) {
// 设置当前聊天的会话ID
state.currentChat.data = {
conversation_id: action.payload.conversation_id,
last_message: action.payload.content,
last_time: action.payload.created_at,
datasets: [
{
id: action.payload.dataset_id,
name: action.payload.dataset_name,
},
],
dataset_id_list: action.payload.dataset_id_list,
message_count: 2, // 用户问题和助手回复
// 其他信息将由流式更新填充
};
state.history.items.unshift(newChat);
} else {
// 更新已存在聊天的最后消息和时间
const chatIndex = state.history.items.findIndex(
(chat) => chat.conversation_id === action.payload.conversation_id
);
if (chatIndex !== -1) {
state.history.items[chatIndex].last_message = action.payload.content;
state.history.items[chatIndex].last_time = action.payload.created_at;
state.history.items[chatIndex].message_count += 2; // 新增用户问题和助手回复
}
}
// 不再在这里添加消息因为消息已经在thunk函数中添加
})
.addCase(createChatRecord.rejected, (state, action) => {
state.sendMessage.status = 'failed';
state.sendMessage.error = action.payload || '创建聊天记录失败';
state.sendMessage.error = action.error.message;
})
// 处理获取可用知识库
@ -334,6 +320,7 @@ export const {
resetMessages,
resetSendMessageStatus,
addMessage,
updateMessage,
} = chatSlice.actions;
// 导出 reducer

View File

@ -1,6 +1,7 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { get, post, put, del } from '../../services/api';
import { get, post, put, del, streamRequest } from '../../services/api';
import { showNotification } from '../notification.slice';
import { addMessage, updateMessage, setCurrentChat } from './chat.slice';
/**
* 获取聊天列表
@ -119,26 +120,224 @@ export const fetchAvailableDatasets = createAsyncThunk(
* @param {string} params.question - 用户问题
* @param {string} params.conversation_id - 会话ID可选
*/
export const createChatRecord = createAsyncThunk('chat/createChatRecord', async (params, { rejectWithValue }) => {
export const createChatRecord = createAsyncThunk(
'chat/createChatRecord',
async ({ question, conversation_id, dataset_id_list }, { dispatch, getState, rejectWithValue }) => {
try {
const response = await post('/chat-history/', {
dataset_id_list: params.dataset_id_list,
question: params.question,
conversation_id: params.conversation_id,
});
// 构建请求数据
const requestBody = {
question,
dataset_id_list,
};
// 处理返回格式
if (response && response.code === 200) {
return response.data;
// 如果存在对话 ID添加到请求中
if (conversation_id) {
requestBody.conversation_id = conversation_id;
}
return rejectWithValue(response.message || '创建聊天记录失败');
console.log('准备发送聊天请求:', requestBody);
// 先添加用户消息到聊天窗口
const userMessageId = Date.now().toString();
dispatch(
addMessage({
id: userMessageId,
role: 'user',
content: question,
created_at: new Date().toISOString(),
})
);
// 添加临时的助手消息(流式传输期间显示)
const assistantMessageId = (Date.now() + 1).toString();
dispatch(
addMessage({
id: assistantMessageId,
role: 'assistant',
content: '',
created_at: new Date().toISOString(),
is_streaming: true,
})
);
let finalMessage = '';
let conversationId = conversation_id;
// 使用流式请求函数处理
const result = await streamRequest(
'/chat-history/',
requestBody,
// 处理每个数据块
(chunkText) => {
try {
const data = JSON.parse(chunkText);
console.log('收到聊天数据块:', data);
if (data.code === 200) {
// 保存会话ID (无论消息类型只要找到会话ID就保存)
if (data.data && data.data.conversation_id && !conversationId) {
conversationId = data.data.conversation_id;
console.log('获取到会话ID:', conversationId);
}
// 处理各种可能的消息类型
const messageType = data.message;
// 处理部分内容更新
if ((messageType === 'partial' || messageType === '部分') && data.data) {
// 累加内容
if (data.data.content !== undefined) {
finalMessage += data.data.content;
console.log('累加内容:', finalMessage);
// 更新消息内容
dispatch(
updateMessage({
id: assistantMessageId,
content: finalMessage,
})
);
}
// 处理结束标志
if (data.data.is_end) {
console.log('检测到消息结束标志');
dispatch(
updateMessage({
id: assistantMessageId,
is_streaming: false,
})
);
}
}
// 处理开始流式传输的消息
else if (messageType === '开始流式传输' || messageType === 'start_streaming') {
console.log('开始流式传输会话ID:', data.data?.conversation_id);
}
// 处理完成消息
else if (
messageType === 'completed' ||
messageType === '完成' ||
messageType === 'end_streaming' ||
messageType === '结束流式传输'
) {
console.log('收到完成消息');
dispatch(
updateMessage({
id: assistantMessageId,
is_streaming: false,
})
);
}
// 其他类型的消息
else {
console.log('收到其他类型消息:', messageType);
// 如果有content字段也尝试更新
if (data.data && data.data.content !== undefined) {
finalMessage += data.data.content;
dispatch(
updateMessage({
id: assistantMessageId,
content: finalMessage,
})
);
}
}
} else {
console.warn('收到非成功状态码:', data.code, data.message);
}
} catch (error) {
console.error('Error creating chat record:', error);
return rejectWithValue(error.response?.data?.message || '创建聊天记录失败');
console.error('解析或处理JSON失败:', error, '原始数据:', chunkText);
}
},
// 处理错误
(error) => {
console.error('流式请求错误:', error);
dispatch(
updateMessage({
id: assistantMessageId,
content: `错误: ${error.message || '请求失败'}`,
is_streaming: false,
})
);
}
);
// 确保流式传输结束后标记消息已完成
dispatch(
updateMessage({
id: assistantMessageId,
is_streaming: false,
})
);
// 返回会话信息
const chatInfo = {
conversation_id: conversationId || result.conversation_id,
success: true,
};
// 如果聊天创建成功,添加到历史列表,并设置为当前激活聊天
if (chatInfo.conversation_id) {
// 获取知识库信息
const state = getState();
const availableDatasets = state.chat.availableDatasets.items || [];
// 创建一个新的聊天记录对象添加到历史列表
const newChatEntry = {
conversation_id: chatInfo.conversation_id,
datasets: dataset_id_list.map((id) => {
// 尝试查找知识库名称
const formattedId = id.includes('-')
? id
: id.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5');
const dataset = availableDatasets.find((ds) => ds.id === formattedId);
return {
id: formattedId,
name: dataset?.name || '新知识库对话',
};
}),
create_time: new Date().toISOString(),
last_message: question,
message_count: 2, // 用户问题和助手回复
};
// 更新当前聊天
dispatch({
type: 'chat/fetchChats/fulfilled',
payload: {
results: [newChatEntry],
total: 1,
append: true, // 标记为追加,而不是替换
},
});
// 设置为当前聊天
dispatch(
setCurrentChat({
conversation_id: chatInfo.conversation_id,
datasets: newChatEntry.datasets,
})
);
}
return chatInfo;
} catch (error) {
console.error('创建聊天记录失败:', error);
// 显示错误通知
dispatch(
showNotification({
message: `发送失败: ${error.message || '未知错误'}`,
type: 'danger',
})
);
return rejectWithValue(error.message || '创建聊天记录失败');
}
}
);
/**
* 获取会话详情
* @param {string} conversationId - 会话ID

View File

@ -146,6 +146,8 @@ export const changeKnowledgeBaseType = createAsyncThunk(
'knowledgeBase/changeType',
async ({ id, type, department, group }, { rejectWithValue }) => {
try {
console.log(id, type, department, group);
const response = await post(`/knowledge-bases/${id}/change_type/`, {
type,
department,
@ -250,7 +252,7 @@ export const getKnowledgeBaseDocuments = createAsyncThunk(
async ({ knowledge_base_id, page = 1, page_size = 10 }, { rejectWithValue }) => {
try {
const response = await get(`/knowledge-bases/${knowledge_base_id}/documents/`, {
params: { page, page_size }
params: { page, page_size },
});
// 处理返回格式
@ -275,7 +277,9 @@ export const deleteKnowledgeBaseDocument = createAsyncThunk(
'knowledgeBase/deleteDocument',
async ({ knowledge_base_id, document_id }, { rejectWithValue, dispatch }) => {
try {
await del(`/knowledge-bases/${knowledge_base_id}/documents/${document_id}/`);
console.log(knowledge_base_id, document_id);
await del(`/knowledge-bases/${knowledge_base_id}/delete_document?document_id=${document_id}`);
dispatch(
showNotification({

View File

@ -11,6 +11,134 @@
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 {
min-width: 20rem;
cursor: pointer;
@ -144,3 +272,117 @@
.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");
}
/* 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;
}
}
/* Streaming message indicator */
.streaming-indicator {
display: inline-flex;
align-items: center;
margin-left: 5px;
.dot {
width: 6px;
height: 6px;
background-color: #6c757d;
border-radius: 50%;
margin: 0 2px;
animation: pulse 1.5s infinite ease-in-out;
&.dot1 {
animation-delay: 0s;
}
&.dot2 {
animation-delay: 0.3s;
}
&.dot3 {
animation-delay: 0.6s;
}
}
@keyframes pulse {
0%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
50% {
transform: scale(1.2);
opacity: 1;
}
}
}