mirror of
https://github.com/Funkoala14/KnowledgeBase_OOIN.git
synced 2025-06-07 23:29:42 +08:00
Compare commits
5 Commits
a415013f61
...
8bc4c8a2d4
Author | SHA1 | Date | |
---|---|---|---|
8bc4c8a2d4 | |||
34a457ef18 | |||
cdcd3374ad | |||
a4239cac87 | |||
30a8f474ec |
1652
package-lock.json
generated
1652
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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": {
|
||||
|
41
src/components/CodeBlock.jsx
Normal file
41
src/components/CodeBlock.jsx
Normal 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;
|
@ -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>}
|
||||
|
47
src/components/ErrorBoundary.jsx
Normal file
47
src/components/ErrorBoundary.jsx
Normal 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;
|
50
src/components/SafeMarkdown.jsx
Normal file
50
src/components/SafeMarkdown.jsx
Normal 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;
|
@ -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>`
|
||||
};
|
||||
|
@ -68,20 +68,18 @@ export default function HeaderWithNav() {
|
||||
Chat
|
||||
</Link>
|
||||
</li>
|
||||
{hasManagePermission && (
|
||||
<li className='nav-item'>
|
||||
<Link
|
||||
className={`nav-link ${isActive('/permissions') ? 'active' : ''}`}
|
||||
to='/permissions'
|
||||
>
|
||||
权限管理
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
<li className='nav-item'>
|
||||
<Link
|
||||
className={`nav-link ${isActive('/permissions') ? 'active' : ''}`}
|
||||
to='/permissions'
|
||||
>
|
||||
权限管理
|
||||
</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>
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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}`);
|
||||
// 使用第一个知识库ID作为URL参数
|
||||
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 response = await dispatch(
|
||||
createChatRecord({
|
||||
dataset_id_list: [dataset.id.replace(/-/g, '')],
|
||||
question: '选择当前知识库,创建聊天',
|
||||
})
|
||||
).unwrap();
|
||||
// 创建新的聊天记录
|
||||
const formattedIds = selectedDatasetIds.map((id) => id.replace(/-/g, ''));
|
||||
console.log('格式化后的知识库ID:', formattedIds);
|
||||
|
||||
if (response && response.conversation_id) {
|
||||
console.log(`创建成功,导航到 /chat/${dataset.id}/${response.conversation_id}`);
|
||||
navigate(`/chat/${dataset.id}/${response.conversation_id}`);
|
||||
} else {
|
||||
throw new Error('未能获取会话ID');
|
||||
try {
|
||||
// 尝试创建聊天记录
|
||||
const response = await dispatch(
|
||||
createChatRecord({
|
||||
dataset_id_list: formattedIds,
|
||||
question: '选择当前知识库,创建聊天',
|
||||
})
|
||||
).unwrap();
|
||||
|
||||
console.log('创建聊天响应:', response);
|
||||
|
||||
if (response && response.conversation_id) {
|
||||
// 使用第一个知识库ID作为URL参数
|
||||
const primaryDatasetId = selectedDatasetIds[0];
|
||||
console.log(`创建成功,导航到 /chat/${primaryDatasetId}/${response.conversation_id}`);
|
||||
navigate(`/chat/${primaryDatasetId}/${response.conversation_id}`);
|
||||
} else {
|
||||
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) => (
|
||||
<div key={dataset.id} className='col'>
|
||||
<div
|
||||
className={`card h-100 shadow-sm border-0 ${!isNavigating ? 'cursor-pointer' : ''} ${
|
||||
selectedDatasetId === dataset.id ? 'border-primary' : ''
|
||||
}`}
|
||||
onClick={() => handleSelectKnowledgeBase(dataset)}
|
||||
style={{ opacity: isNavigating && selectedDatasetId !== dataset.id ? 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>
|
||||
)}
|
||||
</h5>
|
||||
<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'}
|
||||
</span>
|
||||
datasets.map((dataset) => {
|
||||
const isSelected = selectedDatasetIds.includes(dataset.id);
|
||||
return (
|
||||
<div key={dataset.id} className='col'>
|
||||
<div
|
||||
className={`card h-100 shadow-sm ${!isNavigating ? 'cursor-pointer' : ''} ${
|
||||
isSelected ? 'border-gray border-2' : 'border-0'
|
||||
}`}
|
||||
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}
|
||||
{isSelected && <SvgIcon className='check-circle text-primary' />}
|
||||
</h5>
|
||||
<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'>
|
||||
{dataset.department || ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className='col-12'>
|
||||
<div className='alert alert-warning'>暂无可访问的知识库,请先申请知识库访问权限</div>
|
||||
|
@ -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}
|
||||
@ -354,10 +365,11 @@ export default function DatasetTab({ knowledgeBase }) {
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* File upload modal */}
|
||||
<FileUploadModal
|
||||
show={showAddFileModal}
|
||||
knowledgeBaseId={knowledgeBase.id}
|
||||
newFile={newFile}
|
||||
fileErrors={fileErrors}
|
||||
isSubmitting={isSubmitting}
|
||||
|
@ -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) {
|
||||
errors.department = '请选择部门';
|
||||
}
|
||||
|
||||
if (isAdmin && !knowledgeBaseForm.group) {
|
||||
errors.group = '请选择组别';
|
||||
}
|
||||
if (
|
||||
(knowledgeBaseForm.type === 'leader' || knowledgeBaseForm.type === 'member') &&
|
||||
!knowledgeBaseForm.department
|
||||
) {
|
||||
errors.department = '请选择部门';
|
||||
}
|
||||
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} /> */}
|
||||
|
||||
|
@ -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,41 +53,58 @@ const DocumentList = ({ knowledgeBaseId }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='table-responsive'>
|
||||
<table className='table table-hover'>
|
||||
<thead className='table-light'>
|
||||
<tr>
|
||||
<th scope='col'>文档名称</th>
|
||||
<th scope='col'>创建时间</th>
|
||||
<th scope='col'>更新时间</th>
|
||||
<th scope='col'>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((doc) => (
|
||||
<tr key={doc.id}>
|
||||
<td>{doc.document_name}</td>
|
||||
<td>{formatDateTime(doc.create_time)}</td>
|
||||
<td>{formatDateTime(doc.update_time)}</td>
|
||||
<td>
|
||||
<button
|
||||
className='btn btn-sm btn-outline-danger'
|
||||
onClick={() => handleDeleteDocument(doc.document_id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
<>
|
||||
<div className='table-responsive'>
|
||||
<table className='table table-hover'>
|
||||
<thead className='table-light'>
|
||||
<tr>
|
||||
<th scope='col'>文档名称</th>
|
||||
<th scope='col'>创建时间</th>
|
||||
<th scope='col'>更新时间</th>
|
||||
<th scope='col'>操作</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((doc) => (
|
||||
<tr key={doc.id}>
|
||||
<td>{doc.document_name}</td>
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{pagination.total > 0 && (
|
||||
<div className='d-flex justify-content-between align-items-center mt-3'>
|
||||
<p className='text-muted mb-0'>共 {pagination.total} 条记录</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{pagination.total > 0 && (
|
||||
<div className='d-flex justify-content-between align-items-center mt-3'>
|
||||
<p className='text-muted mb-0'>共 {pagination.total} 条记录</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DocumentPreviewModal
|
||||
show={previewModalVisible}
|
||||
documentId={selectedDocumentId}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
onClose={handleClosePreviewModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
@ -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}
|
||||
>
|
||||
|
@ -58,11 +58,7 @@ 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>
|
||||
|
@ -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) {
|
||||
// 对于member和leader级别的知识库,检查是否选择了部门和组别
|
||||
if (needDepartmentAndGroup && !isPrivate) {
|
||||
// 管理员必须选择部门
|
||||
if (isAdmin && !newKnowledgeBase.department) {
|
||||
errors.department = '创建member级别知识库时必须选择部门';
|
||||
errors.department = `创建${newKnowledgeBase.type === 'leader' ? '组长级' : '组内'}知识库时必须选择部门`;
|
||||
}
|
||||
|
||||
// 所有用户创建member级别知识库时必须选择组别
|
||||
// 所有用户创建member和leader级别知识库时必须选择组别
|
||||
if (!newKnowledgeBase.group) {
|
||||
errors.group = '创建member级别知识库时必须选择组别';
|
||||
errors.group = `创建${newKnowledgeBase.type === 'leader' ? '组长级' : '组内'}知识库时必须选择组别`;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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'>
|
||||
|
@ -72,7 +72,7 @@ function AppRouter() {
|
||||
</Mainlayout>
|
||||
}
|
||||
/>
|
||||
{/* 权限管理页面路由 - 仅对 leader 或 admin 角色可见 */}
|
||||
{/* 权限管理页面路由 */}
|
||||
<Route
|
||||
path='/permissions'
|
||||
element={
|
||||
|
@ -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 };
|
||||
|
@ -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';
|
||||
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;
|
||||
|
||||
// 同时更新新的状态结构
|
||||
// 检查是否是追加模式
|
||||
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.items = action.payload.results;
|
||||
}
|
||||
|
||||
state.history.status = 'succeeded';
|
||||
state.history.items = action.payload.results;
|
||||
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
|
||||
|
@ -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,25 +120,223 @@ export const fetchAvailableDatasets = createAsyncThunk(
|
||||
* @param {string} params.question - 用户问题
|
||||
* @param {string} params.conversation_id - 会话ID,可选
|
||||
*/
|
||||
export const createChatRecord = createAsyncThunk('chat/createChatRecord', async (params, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await post('/chat-history/', {
|
||||
dataset_id_list: params.dataset_id_list,
|
||||
question: params.question,
|
||||
conversation_id: params.conversation_id,
|
||||
});
|
||||
export const createChatRecord = createAsyncThunk(
|
||||
'chat/createChatRecord',
|
||||
async ({ question, conversation_id, dataset_id_list }, { dispatch, getState, rejectWithValue }) => {
|
||||
try {
|
||||
// 构建请求数据
|
||||
const requestBody = {
|
||||
question,
|
||||
dataset_id_list,
|
||||
};
|
||||
|
||||
// 处理返回格式
|
||||
if (response && response.code === 200) {
|
||||
return response.data;
|
||||
// 如果存在对话 ID,添加到请求中
|
||||
if (conversation_id) {
|
||||
requestBody.conversation_id = conversation_id;
|
||||
}
|
||||
|
||||
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('解析或处理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 || '创建聊天记录失败');
|
||||
}
|
||||
|
||||
return rejectWithValue(response.message || '创建聊天记录失败');
|
||||
} catch (error) {
|
||||
console.error('Error creating chat record:', error);
|
||||
return rejectWithValue(error.response?.data?.message || '创建聊天记录失败');
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取会话详情
|
||||
|
@ -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,14 +252,14 @@ 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 },
|
||||
});
|
||||
|
||||
|
||||
// 处理返回格式
|
||||
if (response.data && response.data.code === 200) {
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return rejectWithValue(error.response?.data?.message || '获取文档列表失败');
|
||||
@ -275,15 +277,17 @@ 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({
|
||||
type: 'success',
|
||||
message: '文档删除成功',
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
return document_id;
|
||||
} catch (error) {
|
||||
dispatch(
|
||||
|
@ -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;
|
||||
@ -143,4 +271,118 @@
|
||||
/* 下拉箭头颜色 */
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user