mirror of
https://github.com/Funkoala14/KnowledgeBase_OOIN.git
synced 2025-06-08 07:20:55 +08:00
[dev]updates
This commit is contained in:
parent
8dc75cc0e2
commit
01e60c5674
1
package-lock.json
generated
1
package-lock.json
generated
@ -21,6 +21,7 @@
|
|||||||
"react-router-dom": "^7.2.0",
|
"react-router-dom": "^7.2.0",
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"redux-persist": "^6.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
"react-router-dom": "^7.2.0",
|
"react-router-dom": "^7.2.0",
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"redux-persist": "^6.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
|
@ -3,9 +3,9 @@ import AppRouter from './router/router';
|
|||||||
import { checkAuthThunk } from './store/auth/auth.thunk';
|
import { checkAuthThunk } from './store/auth/auth.thunk';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { login } from './store/auth/auth.slice';
|
|
||||||
import { initWebSocket, closeWebSocket } from './services/websocket';
|
import { initWebSocket, closeWebSocket } from './services/websocket';
|
||||||
import { setWebSocketConnected } from './store/notificationCenter/notificationCenter.slice';
|
import { setWebSocketConnected } from './store/notificationCenter/notificationCenter.slice';
|
||||||
|
import NavigationGuard from './components/NavigationGuard';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -55,7 +55,12 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AppRouter></AppRouter>;
|
return (
|
||||||
|
<>
|
||||||
|
<NavigationGuard />
|
||||||
|
<AppRouter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
@ -58,7 +58,7 @@ export default function AccessRequestModal({
|
|||||||
...prev,
|
...prev,
|
||||||
permissions: {
|
permissions: {
|
||||||
can_read: true, // 只读权限始终为true
|
can_read: true, // 只读权限始终为true
|
||||||
can_edit: permissionType === '编辑权限',
|
can_edit: permissionType === '共享',
|
||||||
can_delete: false, // 管理权限暂时不开放
|
can_delete: false, // 管理权限暂时不开放
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -155,11 +155,11 @@ export default function AccessRequestModal({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
className='form-select'
|
className='form-select'
|
||||||
value={accessRequestData.permissions.can_edit ? '编辑权限' : '只读访问'}
|
value={accessRequestData.permissions.can_edit ? '共享' : '只读访问'}
|
||||||
onChange={(e) => handlePermissionChange(e.target.value)}
|
onChange={(e) => handlePermissionChange(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value='只读访问'>只读访问</option>
|
<option value='只读访问'>只读访问</option>
|
||||||
<option value='编辑权限'>编辑权限</option>
|
<option value='共享'>共享</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
37
src/components/NavigationGuard.jsx
Normal file
37
src/components/NavigationGuard.jsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导航守卫组件,用于在执行某些操作时防止用户离开页面
|
||||||
|
*/
|
||||||
|
const NavigationGuard = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isUploading = useSelector((state) => state.upload.isUploading);
|
||||||
|
const previousLocation = React.useRef(location);
|
||||||
|
|
||||||
|
// 检查是否在知识库详情页面
|
||||||
|
const isInKnowledgeBaseDetail =
|
||||||
|
location.pathname.includes('/knowledge-base/') && !location.pathname.endsWith('/knowledge-base/');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 如果不是导航到知识库详情页,且当前正在上传文件,且之前在知识库详情页
|
||||||
|
if (!isInKnowledgeBaseDetail && isUploading && previousLocation.current.pathname.includes('/knowledge-base/')) {
|
||||||
|
// 询问用户是否确认离开
|
||||||
|
const confirmLeave = window.confirm('正在上传文件,离开页面可能会中断上传。确定要离开吗?');
|
||||||
|
|
||||||
|
// 如果用户不确认,就回到之前的页面
|
||||||
|
if (!confirmLeave) {
|
||||||
|
navigate(previousLocation.current.pathname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新前一个位置
|
||||||
|
previousLocation.current = location;
|
||||||
|
}, [location, isUploading]);
|
||||||
|
|
||||||
|
return null; // 这个组件不渲染任何UI
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavigationGuard;
|
69
src/components/ResourceList.jsx
Normal file
69
src/components/ResourceList.jsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import SvgIcon from './SvgIcon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源列表组件 - 显示聊天回复中引用的资源
|
||||||
|
* @param {Object} props - 组件属性
|
||||||
|
* @param {Array} props.resources - 资源列表
|
||||||
|
*/
|
||||||
|
const ResourceList = ({ resources = [] }) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
if (!resources || resources.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最多显示3个资源,超过3个时折叠
|
||||||
|
const visibleResources = expanded ? resources : resources.slice(0, 3);
|
||||||
|
const hasMore = resources.length > 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='resource-list mt-3 border-top pt-3'>
|
||||||
|
<div className='d-flex align-items-center mb-2'>
|
||||||
|
<span className='text-muted me-2' style={{ fontSize: '0.9rem' }}>
|
||||||
|
资源引用
|
||||||
|
</span>
|
||||||
|
<span className='badge bg-light text-dark'>{resources.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className='resource-items'>
|
||||||
|
{visibleResources.map((resource, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='resource-item p-2 border rounded mb-2 d-flex align-items-center'
|
||||||
|
style={{ background: '#f8f9fa' }}
|
||||||
|
>
|
||||||
|
<div className='resource-icon me-2'>
|
||||||
|
<span className='badge bg-secondary me-1'>{(resource.similarity * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className='resource-content flex-grow-1'>
|
||||||
|
<div className='resource-title fw-medium'>{resource.document_name}</div>
|
||||||
|
<div className='resource-source text-muted small'>{resource.dataset_name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasMore && !expanded && (
|
||||||
|
<button
|
||||||
|
className='btn btn-sm btn-light d-flex align-items-center'
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
>
|
||||||
|
<span className='me-1'>查看更多</span>
|
||||||
|
<SvgIcon className='chevron-down' width='14' height='14' />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<button
|
||||||
|
className='btn btn-sm btn-light d-flex align-items-center'
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
>
|
||||||
|
<span className='me-1'>收起</span>
|
||||||
|
<SvgIcon className='chevron-up' width='14' height='14' />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResourceList;
|
@ -1,50 +1,176 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import './SafeMarkdown.scss';
|
||||||
import ErrorBoundary from './ErrorBoundary';
|
import ErrorBoundary from './ErrorBoundary';
|
||||||
import CodeBlock from './CodeBlock';
|
import CodeBlock from './CodeBlock';
|
||||||
|
import SvgIcon from './SvgIcon';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SafeMarkdown component that wraps ReactMarkdown with error handling
|
* SafeMarkdown component that wraps ReactMarkdown with error handling
|
||||||
* Displays raw content as fallback if markdown parsing fails
|
* Displays raw content as fallback if markdown parsing fails
|
||||||
*/
|
*/
|
||||||
const SafeMarkdown = ({ content, className = 'markdown-content' }) => {
|
const SafeMarkdown = ({ content, isStreaming = false }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [animatedContent, setAnimatedContent] = useState('');
|
||||||
|
const [typingSpeed, setTypingSpeed] = useState(10); // 打字速度(毫秒)
|
||||||
|
const prevContentRef = useRef('');
|
||||||
|
const isStreamingRef = useRef(isStreaming);
|
||||||
|
|
||||||
|
// 复制代码按钮处理函数
|
||||||
|
const handleCopy = (code) => {
|
||||||
|
navigator.clipboard.writeText(code).then(
|
||||||
|
() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
console.error('无法复制内容: ', err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取随机打字速度 - 使打字效果看起来更自然
|
||||||
|
const getRandomTypingSpeed = () => {
|
||||||
|
// 基础速度 + 随机变化
|
||||||
|
const baseSpeed = 20;
|
||||||
|
const variance = Math.random() * 20; // 0-20ms的随机变化
|
||||||
|
|
||||||
|
// 如果是标点符号,稍微停顿长一点
|
||||||
|
const nextChar = content.charAt(animatedContent.length);
|
||||||
|
const isPunctuation = ['.', ',', '!', '?', ';', ':', '\n'].includes(nextChar);
|
||||||
|
|
||||||
|
return isPunctuation ? baseSpeed + variance + 30 : baseSpeed + variance;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打字机效果
|
||||||
|
useEffect(() => {
|
||||||
|
// 更新流式状态引用
|
||||||
|
isStreamingRef.current = isStreaming;
|
||||||
|
|
||||||
|
// 如果内容是空,直接清空动画内容
|
||||||
|
if (!content) {
|
||||||
|
setAnimatedContent('');
|
||||||
|
prevContentRef.current = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有流式传输,或者内容已经完全相同,直接显示完整内容
|
||||||
|
if (!isStreaming || content === animatedContent) {
|
||||||
|
setAnimatedContent(content);
|
||||||
|
prevContentRef.current = content;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果内容比当前动画内容长,逐步添加
|
||||||
|
if (content.length > animatedContent.length) {
|
||||||
|
// 计算打字速度 - 根据内容长度动态调整
|
||||||
|
let speed = getRandomTypingSpeed();
|
||||||
|
if (content.length > 500) {
|
||||||
|
speed = speed * 0.5; // 内容很长时加速
|
||||||
|
} else if (content.length > 200) {
|
||||||
|
speed = speed * 0.7; // 内容较长时稍微加速
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
// 根据内容长度,决定一次添加多少字符,提高长文本的打字速度
|
||||||
|
let charsToAdd = 1;
|
||||||
|
if (content.length > 1000) {
|
||||||
|
charsToAdd = 3; // 对于非常长的内容,一次添加3个字符
|
||||||
|
} else if (content.length > 500) {
|
||||||
|
charsToAdd = 2; // 对于较长的内容,一次添加2个字符
|
||||||
|
}
|
||||||
|
|
||||||
|
const endIndex = Math.min(animatedContent.length + charsToAdd, content.length);
|
||||||
|
const nextChars = content.substring(animatedContent.length, endIndex);
|
||||||
|
|
||||||
|
setAnimatedContent((prev) => prev + nextChars);
|
||||||
|
}, speed);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存前一次的内容引用,用于检测内容变化
|
||||||
|
prevContentRef.current = content;
|
||||||
|
}, [content, animatedContent, isStreaming]);
|
||||||
|
|
||||||
// Fallback UI that shows raw content when ReactMarkdown fails
|
// Fallback UI that shows raw content when ReactMarkdown fails
|
||||||
const renderFallback = (error) => {
|
const renderFallback = (error) => {
|
||||||
console.error('Markdown rendering error:', error);
|
console.error('Markdown rendering error:', error);
|
||||||
return (
|
return (
|
||||||
<div className={`${className} markdown-fallback`}>
|
<div className='markdown-fallback'>
|
||||||
<p className='text-danger mb-2'>
|
<div className='text-danger'>Markdown渲染错误</div>
|
||||||
<small>Error rendering markdown. Showing raw content:</small>
|
<pre>{content}</pre>
|
||||||
</p>
|
|
||||||
<div className='p-2 border rounded'>{content}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
try {
|
||||||
<ErrorBoundary fallback={renderFallback}>
|
// 如果内容为空,直接返回空
|
||||||
<ReactMarkdown
|
if (!content) return null;
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
components={{
|
// 实际显示的内容 - 当流式传输时使用动画内容,否则使用完整内容
|
||||||
// Apply the className to the root element
|
const displayContent = isStreaming ? animatedContent : content;
|
||||||
root: ({ node, ...props }) => <div className={className} {...props} />,
|
|
||||||
code({ node, inline, className: codeClassName, children, ...props }) {
|
return (
|
||||||
const match = /language-(\w+)/.exec(codeClassName || '');
|
<ErrorBoundary fallback={renderFallback}>
|
||||||
return !inline && match ? (
|
<ReactMarkdown
|
||||||
<CodeBlock language={match[1]} value={String(children).replace(/\n$/, '')} />
|
remarkPlugins={[remarkGfm]}
|
||||||
) : (
|
components={{
|
||||||
<code className={codeClassName} {...props}>
|
// Apply the className to the root element
|
||||||
{children}
|
root: ({ node, ...props }) => <div className='markdown-content' {...props} />,
|
||||||
</code>
|
code({ node, inline, className, children, ...props }) {
|
||||||
);
|
const match = /language-(\w+)/.exec(className || '');
|
||||||
},
|
const language = match && match[1] ? match[1] : '';
|
||||||
}}
|
|
||||||
>
|
return !inline && match ? (
|
||||||
{content}
|
<div className='code-block-container'>
|
||||||
</ReactMarkdown>
|
<div className='code-block-header'>
|
||||||
</ErrorBoundary>
|
<span className='code-language-badge'>{language}</span>
|
||||||
);
|
{copied ? (
|
||||||
|
<span className='copied-indicator'>
|
||||||
|
<SvgIcon className='check' width='14' height='14' />
|
||||||
|
<span>已复制</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className='copy-button'
|
||||||
|
onClick={() => handleCopy(String(children).replace(/\n$/, ''))}
|
||||||
|
>
|
||||||
|
<SvgIcon className='copy' width='14' height='14' />
|
||||||
|
<span>复制</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SyntaxHighlighter style={vscDarkPlus} language={language} PreTag='div' {...props}>
|
||||||
|
{String(children).replace(/\n$/, '')}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayContent}
|
||||||
|
</ReactMarkdown>
|
||||||
|
{isStreaming && animatedContent !== content && <span className='typing-cursor'></span>}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Markdown渲染错误:', error);
|
||||||
|
// 降级处理,纯文本显示
|
||||||
|
return (
|
||||||
|
<div className='markdown-fallback'>
|
||||||
|
<div className='text-danger'>Markdown渲染错误</div>
|
||||||
|
<pre>{content}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SafeMarkdown;
|
export default SafeMarkdown;
|
||||||
|
172
src/components/SafeMarkdown.scss
Normal file
172
src/components/SafeMarkdown.scss
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
.markdown-content {
|
||||||
|
line-height: 1.6;
|
||||||
|
word-wrap: break-word;
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid #dfe2e5;
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin-left: 0;
|
||||||
|
color: #6a737d;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
|
||||||
|
font-size: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
padding-left: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #dfe2e5;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-container {
|
||||||
|
position: relative;
|
||||||
|
margin: 1rem 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.code-block-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #343a40;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: monospace;
|
||||||
|
|
||||||
|
.code-language-badge {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copied-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-fallback {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px dashed #dc3545;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.text-danger {
|
||||||
|
color: #dc3545;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 打字光标动画 */
|
||||||
|
.typing-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
height: 18px;
|
||||||
|
background-color: #333;
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: middle;
|
||||||
|
animation: cursor-blink 0.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cursor-blink {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗黑模式支持 */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.typing-cursor {
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content {
|
||||||
|
code {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import '../styles/style.scss';
|
|
||||||
import { updateProfileThunk } from '../store/auth/auth.thunk';
|
import { updateProfileThunk } from '../store/auth/auth.thunk';
|
||||||
|
|
||||||
// 部门和组别的映射关系
|
// 部门和组别的映射关系
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import HeaderWithNav from './HeaderWithNav';
|
import HeaderWithNav from './HeaderWithNav';
|
||||||
import '../styles/style.scss';
|
|
||||||
import NotificationSnackbar from '../components/NotificationSnackbar';
|
|
||||||
|
|
||||||
export default function Mainlayout({ children }) {
|
export default function Mainlayout({ children }) {
|
||||||
return (
|
return (
|
||||||
|
@ -8,6 +8,7 @@ import { Provider } from 'react-redux';
|
|||||||
import store, { persistor } from './store/store.js';
|
import store, { persistor } from './store/store.js';
|
||||||
import { PersistGate } from 'redux-persist/integration/react';
|
import { PersistGate } from 'redux-persist/integration/react';
|
||||||
import Loading from './components/Loading.jsx';
|
import Loading from './components/Loading.jsx';
|
||||||
|
import './styles/style.scss';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
// <StrictMode>
|
// <StrictMode>
|
||||||
|
@ -17,13 +17,13 @@ export default function Chat() {
|
|||||||
items: chatHistory,
|
items: chatHistory,
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
} = useSelector((state) => state.chat.history || { items: [], status: 'idle', error: null });
|
} = useSelector((state) => state.chat.list || { items: [], status: 'idle', error: null });
|
||||||
const operationStatus = useSelector((state) => state.chat.createSession?.status);
|
const operationStatus = useSelector((state) => state.chat.createSession?.status);
|
||||||
const operationError = useSelector((state) => state.chat.createSession?.error);
|
const operationError = useSelector((state) => state.chat.createSession?.error);
|
||||||
|
|
||||||
// 获取聊天记录列表
|
// 获取聊天记录列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchChats({ page: 1, page_size: 20 }));
|
dispatch(fetchChats());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
// 监听操作状态,显示通知
|
// 监听操作状态,显示通知
|
||||||
@ -47,77 +47,48 @@ export default function Chat() {
|
|||||||
|
|
||||||
// If we have a knowledgeBaseId but no chatId, check if we have an existing chat or create a new one
|
// If we have a knowledgeBaseId but no chatId, check if we have an existing chat or create a new one
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log('Chat.jsx: chatHistory', chatHistory);
|
||||||
|
|
||||||
// 只有当 knowledgeBaseId 存在但 chatId 不存在,且聊天历史已加载完成时才执行
|
// 只有当 knowledgeBaseId 存在但 chatId 不存在,且聊天历史已加载完成时才执行
|
||||||
if (knowledgeBaseId && !chatId && status === 'succeeded' && !status.includes('loading')) {
|
if (knowledgeBaseId && !chatId && status === 'succeeded' && !status.includes('loading')) {
|
||||||
console.log('Chat.jsx: 检查是否需要创建聊天...');
|
console.log('Chat.jsx: 创建新聊天...');
|
||||||
|
|
||||||
// 处理可能的多个知识库ID (以逗号分隔)
|
// 处理可能的多个知识库ID (以逗号分隔)
|
||||||
const knowledgeBaseIds = knowledgeBaseId.split(',').map((id) => id.trim());
|
const knowledgeBaseIds = knowledgeBaseId.split(',').map((id) => id.trim());
|
||||||
console.log('Chat.jsx: 处理知识库ID列表:', knowledgeBaseIds);
|
console.log('Chat.jsx: 处理知识库ID列表:', knowledgeBaseIds);
|
||||||
|
|
||||||
// 检查是否存在包含所有选中知识库的聊天记录
|
// 创建新聊天 - 使用新的API创建会话
|
||||||
const existingChat = chatHistory.find((chat) => {
|
dispatch(
|
||||||
// 没有datasets属性或不是数组,跳过
|
createConversation({
|
||||||
if (!chat.datasets || !Array.isArray(chat.datasets)) {
|
dataset_id_list: knowledgeBaseIds,
|
||||||
return false;
|
})
|
||||||
}
|
)
|
||||||
|
.unwrap()
|
||||||
// 获取当前聊天记录中的知识库ID列表
|
.then((response) => {
|
||||||
const chatDatasetIds = chat.datasets.map((ds) => ds.id);
|
// 创建成功,使用返回的conversation_id导航
|
||||||
|
if (response && response.conversation_id) {
|
||||||
// 检查所有选中的知识库是否都包含在这个聊天中
|
console.log(`Chat.jsx: 创建成功,导航到 /chat/${knowledgeBaseId}/${response.conversation_id}`);
|
||||||
// 并且聊天中的知识库数量要和选中的相同(完全匹配)
|
navigate(`/chat/${knowledgeBaseId}/${response.conversation_id}`);
|
||||||
return (
|
} else {
|
||||||
knowledgeBaseIds.length === chatDatasetIds.length &&
|
// 错误处理
|
||||||
knowledgeBaseIds.every((id) => chatDatasetIds.includes(id))
|
console.error('Chat.jsx: 创建失败,未能获取会话ID');
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Chat.jsx: existingChat', existingChat);
|
|
||||||
|
|
||||||
if (existingChat) {
|
|
||||||
console.log(
|
|
||||||
`Chat.jsx: 找到现有聊天记录,导航到 /chat/${knowledgeBaseId}/${existingChat.conversation_id}`
|
|
||||||
);
|
|
||||||
// 找到现有聊天记录,导航到该聊天页面
|
|
||||||
navigate(`/chat/${knowledgeBaseId}/${existingChat.conversation_id}`);
|
|
||||||
} else {
|
|
||||||
console.log('Chat.jsx: 创建新聊天...');
|
|
||||||
// 创建新聊天 - 使用新的API创建会话
|
|
||||||
dispatch(
|
|
||||||
createConversation({
|
|
||||||
dataset_id_list: knowledgeBaseIds,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.then((response) => {
|
|
||||||
// 创建成功,使用返回的conversation_id导航
|
|
||||||
if (response && response.conversation_id) {
|
|
||||||
console.log(
|
|
||||||
`Chat.jsx: 创建成功,导航到 /chat/${knowledgeBaseId}/${response.conversation_id}`
|
|
||||||
);
|
|
||||||
navigate(`/chat/${knowledgeBaseId}/${response.conversation_id}`);
|
|
||||||
} else {
|
|
||||||
// 错误处理
|
|
||||||
console.error('Chat.jsx: 创建失败,未能获取会话ID');
|
|
||||||
dispatch(
|
|
||||||
showNotification({
|
|
||||||
message: '创建聊天失败:未能获取会话ID',
|
|
||||||
type: 'danger',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Chat.jsx: 创建失败', error);
|
|
||||||
dispatch(
|
dispatch(
|
||||||
showNotification({
|
showNotification({
|
||||||
message: `创建聊天失败: ${error}`,
|
message: '创建聊天失败:未能获取会话ID',
|
||||||
type: 'danger',
|
type: 'danger',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
}
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Chat.jsx: 创建失败', error);
|
||||||
|
dispatch(
|
||||||
|
showNotification({
|
||||||
|
message: `创建聊天失败: ${error}`,
|
||||||
|
type: 'danger',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [knowledgeBaseId, chatId, chatHistory, status, navigate, dispatch]);
|
}, [knowledgeBaseId, chatId, chatHistory, status, navigate, dispatch]);
|
||||||
|
|
||||||
|
@ -93,8 +93,9 @@ export default function ChatSidebar({ chatHistory = [], onDeleteChat, isLoading
|
|||||||
chatId === chat.conversation_id ? 'fw-bold' : ''
|
chatId === chat.conversation_id ? 'fw-bold' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className='d-flex flex-column'>
|
<div className='d-flex flex-column w-100'>
|
||||||
<div className='text-truncate fw-medium'>
|
{chat.title && <div className='text-truncate mb-1'>{chat.title}</div>}
|
||||||
|
<div className='text-truncate fw-medium small text-secondary'>
|
||||||
{chat.datasets?.map((ds) => ds.name).join(', ') || '未命名知识库'}
|
{chat.datasets?.map((ds) => ds.name).join(', ') || '未命名知识库'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { fetchMessages } from '../../store/chat/chat.messages.thunks';
|
|
||||||
import { resetMessages, resetSendMessageStatus, addMessage } from '../../store/chat/chat.slice';
|
import { resetMessages, resetSendMessageStatus, addMessage } from '../../store/chat/chat.slice';
|
||||||
import { showNotification } from '../../store/notification.slice';
|
import { showNotification } from '../../store/notification.slice';
|
||||||
import { createChatRecord, fetchAvailableDatasets, fetchConversationDetail } from '../../store/chat/chat.thunks';
|
import { createChatRecord, fetchAvailableDatasets, fetchConversationDetail } from '../../store/chat/chat.thunks';
|
||||||
import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks';
|
import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks';
|
||||||
import SvgIcon from '../../components/SvgIcon';
|
import SvgIcon from '../../components/SvgIcon';
|
||||||
import SafeMarkdown from '../../components/SafeMarkdown';
|
import SafeMarkdown from '../../components/SafeMarkdown';
|
||||||
|
import ResourceList from '../../components/ResourceList';
|
||||||
import { get } from '../../services/api';
|
import { get } from '../../services/api';
|
||||||
|
|
||||||
export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
||||||
@ -17,11 +17,17 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
|||||||
const hasLoadedDetailRef = useRef({}); // 添加ref来跟踪已加载的会话
|
const hasLoadedDetailRef = useRef({}); // 添加ref来跟踪已加载的会话
|
||||||
|
|
||||||
// 从 Redux store 获取消息
|
// 从 Redux store 获取消息
|
||||||
const messages = useSelector((state) => state.chat.messages.items);
|
const chatList = useSelector((state) => state.chat.list.items);
|
||||||
const messageStatus = useSelector((state) => state.chat.messages.status);
|
const currentChatId = useSelector((state) => state.chat.currentChat.conversationId || chatId);
|
||||||
const messageError = useSelector((state) => state.chat.messages.error);
|
const currentChat = chatList.find((chat) => chat.conversation_id === currentChatId);
|
||||||
|
const messages = currentChat?.messages || [];
|
||||||
|
const messageStatus = useSelector((state) => state.chat.list.messageStatus);
|
||||||
|
const messageError = useSelector((state) => state.chat.list.messageError);
|
||||||
const { status: sendStatus, error: sendError } = useSelector((state) => state.chat.sendMessage);
|
const { status: sendStatus, error: sendError } = useSelector((state) => state.chat.sendMessage);
|
||||||
|
|
||||||
|
// 获取消息资源
|
||||||
|
const resources = useSelector((state) => state.chat.resources);
|
||||||
|
|
||||||
// 使用新的Redux状态结构
|
// 使用新的Redux状态结构
|
||||||
const knowledgeBases = useSelector((state) => state.knowledgeBase.knowledgeBases || []);
|
const knowledgeBases = useSelector((state) => state.knowledgeBase.knowledgeBases || []);
|
||||||
const knowledgeBase = knowledgeBases.find((kb) => kb.id === knowledgeBaseId);
|
const knowledgeBase = knowledgeBases.find((kb) => kb.id === knowledgeBaseId);
|
||||||
@ -43,6 +49,17 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
|||||||
// 监听知识库ID变更,确保保存在组件状态中
|
// 监听知识库ID变更,确保保存在组件状态中
|
||||||
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState([]);
|
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState([]);
|
||||||
|
|
||||||
|
// 当chatId改变时设置当前会话ID
|
||||||
|
useEffect(() => {
|
||||||
|
if (chatId) {
|
||||||
|
// 通过设置currentChat.conversationId确保消息显示在正确的会话下
|
||||||
|
dispatch({
|
||||||
|
type: 'chat/setCurrentChat',
|
||||||
|
payload: { conversation_id: chatId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [chatId, dispatch]);
|
||||||
|
|
||||||
// 当conversation或knowledgeBaseId更新时,更新selectedKnowledgeBaseIds
|
// 当conversation或knowledgeBaseId更新时,更新selectedKnowledgeBaseIds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 优先使用conversation中的知识库列表
|
// 优先使用conversation中的知识库列表
|
||||||
@ -105,7 +122,8 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
|||||||
|
|
||||||
// 组件卸载时清空消息
|
// 组件卸载时清空消息
|
||||||
return () => {
|
return () => {
|
||||||
dispatch(resetMessages());
|
// Don't reset messages when switching chats
|
||||||
|
// dispatch(resetMessages());
|
||||||
// 不要清空hasLoadedDetailRef,否则会导致重复加载
|
// 不要清空hasLoadedDetailRef,否则会导致重复加载
|
||||||
// hasLoadedDetailRef.current = {}; // 清理ref缓存
|
// hasLoadedDetailRef.current = {}; // 清理ref缓存
|
||||||
};
|
};
|
||||||
@ -253,19 +271,6 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 渲染错误状态
|
|
||||||
const renderError = () => (
|
|
||||||
<div className='alert alert-danger'>
|
|
||||||
<p className='mb-0'>
|
|
||||||
<strong>加载消息失败</strong>
|
|
||||||
</p>
|
|
||||||
<p className='mb-0 small'>{messageError}</p>
|
|
||||||
<button className='btn btn-outline-secondary mt-3' onClick={() => dispatch(fetchMessages(chatId))}>
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 渲染空消息状态
|
// 渲染空消息状态
|
||||||
const renderEmpty = () => {
|
const renderEmpty = () => {
|
||||||
if (loading) return null;
|
if (loading) return null;
|
||||||
@ -283,16 +288,11 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
|||||||
<div className='p-3 border-bottom'>
|
<div className='p-3 border-bottom'>
|
||||||
{conversation && conversation.datasets ? (
|
{conversation && conversation.datasets ? (
|
||||||
<>
|
<>
|
||||||
<h5 className='mb-0'>{conversation.datasets.map((dataset) => dataset.name).join(', ')}</h5>
|
<h5 className='mb-0'>{conversation.title}</h5>
|
||||||
{conversation.datasets.length > 0 && conversation.datasets[0].type && (
|
{conversation.datasets.length > 0 && conversation.datasets[0].type && (
|
||||||
<small className='text-muted'>类型: {conversation.datasets[0].type}</small>
|
<small className='text-muted'>类型: {conversation.datasets[0].type}</small>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : knowledgeBase ? (
|
|
||||||
<>
|
|
||||||
<h5 className='mb-0'>{knowledgeBase.name}</h5>
|
|
||||||
<small className='text-muted'>{knowledgeBase.description}</small>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<h5 className='mb-0'>{loading || availableDatasetsLoading ? '加载中...' : '聊天'}</h5>
|
<h5 className='mb-0'>{loading || availableDatasetsLoading ? '加载中...' : '聊天'}</h5>
|
||||||
)}
|
)}
|
||||||
@ -303,8 +303,6 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
|||||||
<div className='container'>
|
<div className='container'>
|
||||||
{messageStatus === 'loading'
|
{messageStatus === 'loading'
|
||||||
? renderLoading()
|
? renderLoading()
|
||||||
: messageStatus === 'failed'
|
|
||||||
? renderError()
|
|
||||||
: messages.length === 0
|
: messages.length === 0
|
||||||
? renderEmpty()
|
? renderEmpty()
|
||||||
: messages.map((message) => (
|
: messages.map((message) => (
|
||||||
@ -327,7 +325,10 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
|||||||
{message.role === 'user' ? (
|
{message.role === 'user' ? (
|
||||||
message.content
|
message.content
|
||||||
) : (
|
) : (
|
||||||
<SafeMarkdown content={message.content} />
|
<SafeMarkdown
|
||||||
|
content={message.content}
|
||||||
|
isStreaming={message.is_streaming}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{message.is_streaming && (
|
{message.is_streaming && (
|
||||||
<span className='streaming-indicator'>
|
<span className='streaming-indicator'>
|
||||||
@ -336,6 +337,14 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
|||||||
<span className='dot dot3'></span>
|
<span className='dot dot3'></span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 只在AI回复消息下方显示资源列表 */}
|
||||||
|
{message.role === 'assistant' &&
|
||||||
|
!message.is_streaming &&
|
||||||
|
resources.messageId === message.id &&
|
||||||
|
resources.items.length > 0 && (
|
||||||
|
<ResourceList resources={resources.items} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='message-time small text-muted mt-1'>
|
<div className='message-time small text-muted mt-1'>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { showNotification } from '../../store/notification.slice';
|
import { showNotification } from '../../store/notification.slice';
|
||||||
@ -17,14 +17,14 @@ export default function NewChat() {
|
|||||||
const error = useSelector((state) => state.chat.availableDatasets.error);
|
const error = useSelector((state) => state.chat.availableDatasets.error);
|
||||||
|
|
||||||
// 获取聊天历史记录
|
// 获取聊天历史记录
|
||||||
const chatHistory = useSelector((state) => state.chat.history.items || []);
|
const chatHistory = useSelector((state) => state.chat.list.items || []);
|
||||||
const chatHistoryLoading = useSelector((state) => state.chat.history.status === 'loading');
|
const chatHistoryLoading = useSelector((state) => state.chat.list.status === 'loading');
|
||||||
const chatCreationStatus = useSelector((state) => state.chat.sendMessage?.status);
|
const chatCreationStatus = useSelector((state) => state.chat.sendMessage?.status);
|
||||||
|
|
||||||
// 获取可用知识库列表和聊天历史
|
// 获取可用知识库列表和聊天历史
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchAvailableDatasets());
|
dispatch(fetchAvailableDatasets());
|
||||||
dispatch(fetchChats({ page: 1, page_size: 50 }));
|
dispatch(fetchChats());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
// 监听错误状态
|
// 监听错误状态
|
||||||
@ -77,65 +77,31 @@ export default function NewChat() {
|
|||||||
// 打印调试信息
|
// 打印调试信息
|
||||||
console.log('选中的知识库ID:', selectedDatasetIds);
|
console.log('选中的知识库ID:', selectedDatasetIds);
|
||||||
|
|
||||||
// 检查是否已存在包含所有选中知识库的聊天记录
|
// 创建新的聊天会话
|
||||||
// 注意:这里的逻辑简化了,实际可能需要更复杂的匹配算法
|
console.log(`创建新会话,选中的知识库ID: ${selectedDatasetIds.join(', ')}`);
|
||||||
const existingChat = chatHistory.find((chat) => {
|
|
||||||
// 检查聊天记录中的知识库是否完全匹配当前选择
|
try {
|
||||||
if (chat.datasets && Array.isArray(chat.datasets)) {
|
// 调用createConversation创建新会话(不发送消息)
|
||||||
const chatDatasetIds = chat.datasets.map((ds) => ds.id);
|
const response = await dispatch(
|
||||||
return (
|
createConversation({
|
||||||
chatDatasetIds.length === selectedDatasetIds.length &&
|
dataset_id_list: selectedDatasetIds,
|
||||||
selectedDatasetIds.every((id) => chatDatasetIds.includes(id))
|
})
|
||||||
);
|
).unwrap();
|
||||||
}
|
|
||||||
|
console.log('创建会话响应:', response);
|
||||||
// 兼容旧格式
|
|
||||||
if (chat.dataset_id_list && Array.isArray(chat.dataset_id_list)) {
|
if (response && response.conversation_id) {
|
||||||
const formattedSelectedIds = selectedDatasetIds.map((id) => id.replace(/-/g, ''));
|
// 使用所有知识库ID作为URL参数,以逗号分隔
|
||||||
return (
|
const knowledgeBaseIdsParam = selectedDatasetIds.join(',');
|
||||||
chat.dataset_id_list.length === formattedSelectedIds.length &&
|
console.log(`创建会话成功,导航到 /chat/${knowledgeBaseIdsParam}/${response.conversation_id}`);
|
||||||
formattedSelectedIds.every((id) => chat.dataset_id_list.includes(id))
|
navigate(`/chat/${knowledgeBaseIdsParam}/${response.conversation_id}`);
|
||||||
);
|
} else {
|
||||||
}
|
throw new Error('未能获取会话ID:' + JSON.stringify(response));
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingChat) {
|
|
||||||
// 找到现有聊天记录,导航到该聊天页面
|
|
||||||
// 使用所有知识库ID作为URL参数,以逗号分隔
|
|
||||||
const knowledgeBaseIdsParam = selectedDatasetIds.join(',');
|
|
||||||
console.log(
|
|
||||||
`找到现有聊天记录,直接导航到 /chat/${knowledgeBaseIdsParam}/${existingChat.conversation_id}`
|
|
||||||
);
|
|
||||||
navigate(`/chat/${knowledgeBaseIdsParam}/${existingChat.conversation_id}`);
|
|
||||||
} else {
|
|
||||||
// 没有找到现有聊天记录,创建新的聊天
|
|
||||||
console.log(`未找到现有聊天记录,创建新会话,选中的知识库ID: ${selectedDatasetIds.join(', ')}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 调用createConversation创建新会话(不发送消息)
|
|
||||||
const response = await dispatch(
|
|
||||||
createConversation({
|
|
||||||
dataset_id_list: selectedDatasetIds,
|
|
||||||
})
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
console.log('创建会话响应:', response);
|
|
||||||
|
|
||||||
if (response && response.conversation_id) {
|
|
||||||
// 使用所有知识库ID作为URL参数,以逗号分隔
|
|
||||||
const knowledgeBaseIdsParam = selectedDatasetIds.join(',');
|
|
||||||
console.log(`创建会话成功,导航到 /chat/${knowledgeBaseIdsParam}/${response.conversation_id}`);
|
|
||||||
navigate(`/chat/${knowledgeBaseIdsParam}/${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 (apiError) {
|
||||||
|
// 专门处理API调用错误
|
||||||
|
console.error('API调用失败:', apiError);
|
||||||
|
throw new Error(`API调用失败: ${apiError.message || '未知错误'}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('导航或创建聊天失败:', error);
|
console.error('导航或创建聊天失败:', error);
|
||||||
@ -157,6 +123,11 @@ export default function NewChat() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Refresh chat history when a new chat is created
|
||||||
|
const refreshChatHistory = useCallback(() => {
|
||||||
|
dispatch(fetchChats());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
// 渲染加载状态
|
// 渲染加载状态
|
||||||
if (isLoading || chatHistoryLoading) {
|
if (isLoading || chatHistoryLoading) {
|
||||||
return (
|
return (
|
||||||
|
@ -16,6 +16,7 @@ export default function DatasetTab({ knowledgeBase }) {
|
|||||||
const [selectAll, setSelectAll] = useState(false);
|
const [selectAll, setSelectAll] = useState(false);
|
||||||
const [showBatchDropdown, setShowBatchDropdown] = useState(false);
|
const [showBatchDropdown, setShowBatchDropdown] = useState(false);
|
||||||
const [showAddFileModal, setShowAddFileModal] = useState(false);
|
const [showAddFileModal, setShowAddFileModal] = useState(false);
|
||||||
|
const [isUploadModalMinimized, setIsUploadModalMinimized] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [newFile, setNewFile] = useState({
|
const [newFile, setNewFile] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@ -34,14 +35,13 @@ export default function DatasetTab({ knowledgeBase }) {
|
|||||||
setDocuments(knowledgeBase.documents || []);
|
setDocuments(knowledgeBase.documents || []);
|
||||||
}, [knowledgeBase]);
|
}, [knowledgeBase]);
|
||||||
|
|
||||||
// 获取文档列表
|
// 获取文档列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (knowledgeBase?.id) {
|
if (knowledgeBase?.id) {
|
||||||
dispatch(getKnowledgeBaseDocuments({ knowledge_base_id: knowledgeBase.id }));
|
dispatch(getKnowledgeBaseDocuments({ knowledge_base_id: knowledgeBase.id }));
|
||||||
}
|
}
|
||||||
}, [dispatch, knowledgeBase?.id]);
|
}, [dispatch, knowledgeBase?.id]);
|
||||||
|
|
||||||
|
|
||||||
// Handle click outside dropdown
|
// Handle click outside dropdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event) {
|
function handleClickOutside(event) {
|
||||||
@ -244,6 +244,8 @@ export default function DatasetTab({ knowledgeBase }) {
|
|||||||
});
|
});
|
||||||
setFileErrors({});
|
setFileErrors({});
|
||||||
setShowAddFileModal(false);
|
setShowAddFileModal(false);
|
||||||
|
// 同时重置最小化状态
|
||||||
|
setIsUploadModalMinimized(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle delete document
|
// Handle delete document
|
||||||
@ -270,6 +272,28 @@ export default function DatasetTab({ knowledgeBase }) {
|
|||||||
(doc.description && doc.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
(doc.description && doc.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 处理上传按钮点击
|
||||||
|
const handleAddFileClick = () => {
|
||||||
|
// 如果弹窗已经最小化,则恢复显示
|
||||||
|
if (isUploadModalMinimized) {
|
||||||
|
setIsUploadModalMinimized(false);
|
||||||
|
setShowAddFileModal(true);
|
||||||
|
} else {
|
||||||
|
// 否则正常显示上传弹窗
|
||||||
|
setShowAddFileModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理上传弹窗最小化状态变化
|
||||||
|
const handleUploadModalMinimizeChange = (isMinimized) => {
|
||||||
|
console.log('Upload modal minimize state changed:', isMinimized);
|
||||||
|
setIsUploadModalMinimized(isMinimized);
|
||||||
|
// 如果是最小化,则隐藏主弹窗但保持最小化状态显示
|
||||||
|
if (isMinimized) {
|
||||||
|
setShowAddFileModal(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Breadcrumb navigation */}
|
{/* Breadcrumb navigation */}
|
||||||
@ -281,7 +305,7 @@ export default function DatasetTab({ knowledgeBase }) {
|
|||||||
<button
|
<button
|
||||||
disabled={!knowledgeBase.permissions?.can_edit}
|
disabled={!knowledgeBase.permissions?.can_edit}
|
||||||
className='btn btn-dark d-flex align-items-center gap-1'
|
className='btn btn-dark d-flex align-items-center gap-1'
|
||||||
onClick={() => setShowAddFileModal(true)}
|
onClick={handleAddFileClick}
|
||||||
>
|
>
|
||||||
<SvgIcon className='plus' />
|
<SvgIcon className='plus' />
|
||||||
上传文件
|
上传文件
|
||||||
@ -380,6 +404,8 @@ export default function DatasetTab({ knowledgeBase }) {
|
|||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onUploadAreaClick={handleUploadAreaClick}
|
onUploadAreaClick={handleUploadAreaClick}
|
||||||
onUpload={handleFileUpload}
|
onUpload={handleFileUpload}
|
||||||
|
isMinimized={isUploadModalMinimized}
|
||||||
|
onMinimizeChange={handleUploadModalMinimizeChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { showNotification } from '../../../store/notification.slice';
|
import { showNotification } from '../../../store/notification.slice';
|
||||||
import { getKnowledgeBaseById } from '../../../store/knowledgeBase/knowledgeBase.thunks';
|
import { getKnowledgeBaseById } from '../../../store/knowledgeBase/knowledgeBase.thunks';
|
||||||
@ -10,6 +10,7 @@ import SettingsTab from './SettingsTab';
|
|||||||
export default function KnowledgeBaseDetail() {
|
export default function KnowledgeBaseDetail() {
|
||||||
const { id, tab } = useParams();
|
const { id, tab } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [activeTab, setActiveTab] = useState(tab === 'settings' ? 'settings' : 'datasets');
|
const [activeTab, setActiveTab] = useState(tab === 'settings' ? 'settings' : 'datasets');
|
||||||
|
|
||||||
@ -17,6 +18,8 @@ export default function KnowledgeBaseDetail() {
|
|||||||
const knowledgeBase = useSelector((state) => state.knowledgeBase.currentKnowledgeBase);
|
const knowledgeBase = useSelector((state) => state.knowledgeBase.currentKnowledgeBase);
|
||||||
const loading = useSelector((state) => state.knowledgeBase.loading);
|
const loading = useSelector((state) => state.knowledgeBase.loading);
|
||||||
const error = useSelector((state) => state.knowledgeBase.error);
|
const error = useSelector((state) => state.knowledgeBase.error);
|
||||||
|
// 获取全局上传状态
|
||||||
|
const isUploading = useSelector((state) => state.upload.isUploading);
|
||||||
|
|
||||||
// Fetch knowledge base details when component mounts or ID changes
|
// Fetch knowledge base details when component mounts or ID changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -32,6 +35,24 @@ export default function KnowledgeBaseDetail() {
|
|||||||
}
|
}
|
||||||
}, [tab]);
|
}, [tab]);
|
||||||
|
|
||||||
|
// 添加全局导航拦截
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e) => {
|
||||||
|
if (isUploading) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '正在上传文件,离开页面可能会中断上传。确定要离开吗?';
|
||||||
|
return e.returnValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建拦截beforeunload事件的监听器
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
};
|
||||||
|
}, [isUploading]);
|
||||||
|
|
||||||
// If knowledge base not found, show notification and redirect
|
// If knowledge base not found, show notification and redirect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && error) {
|
if (!loading && error) {
|
||||||
@ -47,6 +68,14 @@ export default function KnowledgeBaseDetail() {
|
|||||||
|
|
||||||
// Handle tab change
|
// Handle tab change
|
||||||
const handleTabChange = (tab) => {
|
const handleTabChange = (tab) => {
|
||||||
|
// 如果正在上传,阻止标签切换
|
||||||
|
if (isUploading && activeTab === 'datasets') {
|
||||||
|
const confirmLeave = window.confirm('正在上传文件,切换页面可能会中断上传。确定要离开吗?');
|
||||||
|
if (!confirmLeave) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
navigate(`/knowledge-base/${id}/${tab}`);
|
navigate(`/knowledge-base/${id}/${tab}`);
|
||||||
};
|
};
|
||||||
|
@ -124,7 +124,7 @@ const DocumentList = ({ knowledgeBaseId }) => {
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope='col'>文档名称</th>
|
<th scope='col'>文档名称</th>
|
||||||
<th scope='col'>创建时间</th>
|
<th scope='col'>创建时间</th>
|
||||||
<th scope='col'>更新时间</th>
|
<th scope='col'>上传用户</th>
|
||||||
<th scope='col'>操作</th>
|
<th scope='col'>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -141,7 +141,7 @@ const DocumentList = ({ knowledgeBaseId }) => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className='text-nowrap'>{formatDateTime(doc.create_time || doc.created_at)}</td>
|
<td className='text-nowrap'>{formatDateTime(doc.create_time || doc.created_at)}</td>
|
||||||
<td className='text-nowrap'>{formatDateTime(doc.update_time || doc.updated_at)}</td>
|
<td className='text-nowrap'>{doc.uploader_name || ''}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className='btn-group' role='group'>
|
<div className='btn-group' role='group'>
|
||||||
<button
|
<button
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import React, { useRef, useState, useEffect } from 'react';
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { uploadDocument, getKnowledgeBaseDocuments } from '../../../../store/knowledgeBase/knowledgeBase.thunks';
|
import { uploadDocument, getKnowledgeBaseDocuments } from '../../../../store/knowledgeBase/knowledgeBase.thunks';
|
||||||
|
import { setIsUploading } from '../../../../store/upload/upload.slice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件上传模态框组件
|
* 文件上传模态框组件
|
||||||
*/
|
*/
|
||||||
const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
|
const FileUploadModal = ({ show, knowledgeBaseId, onClose, onMinimizeChange, isMinimized: externalIsMinimized }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const modalRef = useRef(null);
|
const modalRef = useRef(null);
|
||||||
@ -13,6 +14,14 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [fileError, setFileError] = useState('');
|
const [fileError, setFileError] = useState('');
|
||||||
const [uploadResults, setUploadResults] = useState(null);
|
const [uploadResults, setUploadResults] = useState(null);
|
||||||
|
const [isMinimized, setIsMinimized] = useState(externalIsMinimized || false);
|
||||||
|
|
||||||
|
// 同步外部的最小化状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalIsMinimized !== undefined && externalIsMinimized !== isMinimized) {
|
||||||
|
setIsMinimized(externalIsMinimized);
|
||||||
|
}
|
||||||
|
}, [externalIsMinimized]);
|
||||||
|
|
||||||
// 处理上传区域点击事件
|
// 处理上传区域点击事件
|
||||||
const handleUploadAreaClick = () => {
|
const handleUploadAreaClick = () => {
|
||||||
@ -67,9 +76,12 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
// Update Redux state
|
||||||
|
dispatch({ type: 'upload/setIsUploading', payload: true });
|
||||||
setUploadResults(null);
|
setUploadResults(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Upload the files
|
||||||
const result = await dispatch(
|
const result = await dispatch(
|
||||||
uploadDocument({
|
uploadDocument({
|
||||||
knowledge_base_id: knowledgeBaseId,
|
knowledge_base_id: knowledgeBaseId,
|
||||||
@ -77,13 +89,17 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
|
|||||||
})
|
})
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
// 成功上传后刷新文档列表
|
// Refresh the documents list
|
||||||
dispatch(getKnowledgeBaseDocuments({ knowledge_base_id: knowledgeBaseId }));
|
dispatch(
|
||||||
|
getKnowledgeBaseDocuments({
|
||||||
|
knowledge_base_id: knowledgeBaseId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// 显示上传结果
|
// Update UI with results
|
||||||
setUploadResults(result);
|
setUploadResults(result);
|
||||||
|
|
||||||
// 如果没有失败的文件,就在3秒后自动关闭模态窗
|
// Close dialog after success
|
||||||
if (result.failed_count === 0) {
|
if (result.failed_count === 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
handleClose();
|
handleClose();
|
||||||
@ -94,6 +110,8 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
|
|||||||
setFileError('文件上传失败: ' + (error?.message || '未知错误'));
|
setFileError('文件上传失败: ' + (error?.message || '未知错误'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
// Reset Redux state
|
||||||
|
dispatch({ type: 'upload/setIsUploading', payload: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -101,25 +119,96 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
|
|||||||
// 只有在非上传状态才允许关闭
|
// 只有在非上传状态才允许关闭
|
||||||
if (!isUploading) {
|
if (!isUploading) {
|
||||||
resetFileInput();
|
resetFileInput();
|
||||||
|
setIsMinimized(false); // 重置最小化状态
|
||||||
onClose();
|
onClose();
|
||||||
|
} else {
|
||||||
|
// 如果正在上传,提示用户
|
||||||
|
alert('正在上传文件,请等待上传完成后再关闭。');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 清理函数
|
// 处理最小化/最大化切换
|
||||||
|
const toggleMinimize = () => {
|
||||||
|
const newMinimizedState = !isMinimized;
|
||||||
|
setIsMinimized(newMinimizedState);
|
||||||
|
|
||||||
|
// 通知父组件最小化状态变化
|
||||||
|
if (onMinimizeChange) {
|
||||||
|
onMinimizeChange(newMinimizedState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理显示状态变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
// 当弹窗被显示时,如果之前是最小化状态,则恢复
|
||||||
// 确保在组件卸载时清理所有引用
|
if (show && isMinimized) {
|
||||||
if (fileInputRef.current) {
|
setIsMinimized(false);
|
||||||
fileInputRef.current.value = '';
|
if (onMinimizeChange) {
|
||||||
|
onMinimizeChange(false);
|
||||||
}
|
}
|
||||||
if (modalRef.current) {
|
}
|
||||||
modalRef.current = null;
|
}, [show]);
|
||||||
|
|
||||||
|
// 添加离开页面的警告
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e) => {
|
||||||
|
if (isUploading) {
|
||||||
|
// 标准方式阻止页面关闭
|
||||||
|
e.preventDefault();
|
||||||
|
// Chrome需要这个
|
||||||
|
e.returnValue = '正在上传文件,离开页面可能会中断上传。确定要离开吗?';
|
||||||
|
return e.returnValue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
if (isUploading) {
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
};
|
||||||
|
}, [isUploading]);
|
||||||
|
|
||||||
|
// 如果不显示且不是最小化状态,则不渲染任何内容
|
||||||
|
if (!show && !isMinimized) return null;
|
||||||
|
|
||||||
|
// 最小化模式的渲染
|
||||||
|
if (isMinimized) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='minimized-upload-modal bg-white rounded shadow d-flex align-items-center'
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '20px',
|
||||||
|
right: '20px',
|
||||||
|
padding: '10px 15px',
|
||||||
|
zIndex: 1050,
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={toggleMinimize}
|
||||||
|
>
|
||||||
|
<div className='d-flex align-items-center'>
|
||||||
|
{isUploading ? (
|
||||||
|
<span className='spinner-border spinner-border-sm me-2' role='status' aria-hidden='true'></span>
|
||||||
|
) : (
|
||||||
|
<span className='me-2'>📁</span>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{isUploading
|
||||||
|
? `正在上传文件 (${selectedFiles.length})...`
|
||||||
|
: `文件上传 (${selectedFiles.length})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有在显示状态下渲染完整弹窗
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
|
// 完整模式的渲染
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
@ -149,13 +238,48 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
|
|||||||
>
|
>
|
||||||
<div className='modal-header d-flex justify-content-between align-items-center mb-3'>
|
<div className='modal-header d-flex justify-content-between align-items-center mb-3'>
|
||||||
<h5 className='modal-title m-0'>上传文档</h5>
|
<h5 className='modal-title m-0'>上传文档</h5>
|
||||||
<button
|
<div className='d-flex align-items-center' style={{ gap: '8px' }}>
|
||||||
type='button'
|
{/* Minimize button */}
|
||||||
className='btn-close'
|
<button
|
||||||
onClick={handleClose}
|
type='button'
|
||||||
disabled={isUploading}
|
onClick={toggleMinimize}
|
||||||
aria-label='Close'
|
aria-label='Minimize'
|
||||||
></button>
|
className='btn p-0 d-flex align-items-center justify-content-center'
|
||||||
|
style={{
|
||||||
|
width: '22px',
|
||||||
|
height: '22px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => (e.currentTarget.style.opacity = '0.75')}
|
||||||
|
onMouseOut={(e) => (e.currentTarget.style.opacity = '0.5')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 512 512'
|
||||||
|
width='14'
|
||||||
|
height='14'
|
||||||
|
fill='currentColor'
|
||||||
|
>
|
||||||
|
<path d='M32 416c-17.7 0-32 14.3-32 32s14.3 32 32 32l448 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 416z' />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='btn-close'
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isUploading}
|
||||||
|
aria-label='Close'
|
||||||
|
style={{
|
||||||
|
width: '22px',
|
||||||
|
height: '22px',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='modal-body'>
|
<div className='modal-body'>
|
||||||
<div
|
<div
|
||||||
|
@ -80,7 +80,7 @@ export default function UserPermissionDetails({ user, onClose, onSave }) {
|
|||||||
case 'read':
|
case 'read':
|
||||||
return '只读访问';
|
return '只读访问';
|
||||||
case 'edit':
|
case 'edit':
|
||||||
return '编辑权限';
|
return '共享';
|
||||||
case 'admin':
|
case 'admin':
|
||||||
return '管理权限';
|
return '管理权限';
|
||||||
default:
|
default:
|
||||||
@ -185,7 +185,7 @@ export default function UserPermissionDetails({ user, onClose, onSave }) {
|
|||||||
>
|
>
|
||||||
<option value='none'>无权限</option>
|
<option value='none'>无权限</option>
|
||||||
<option value='read'>只读访问</option>
|
<option value='read'>只读访问</option>
|
||||||
<option value='edit'>编辑权限</option>
|
<option value='edit'>共享</option>
|
||||||
<option value='admin'>管理权限</option>
|
<option value='admin'>管理权限</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
@ -256,7 +256,7 @@ export default function UserPermissionDetails({ user, onClose, onSave }) {
|
|||||||
>
|
>
|
||||||
<option value='none'>无权限</option>
|
<option value='none'>无权限</option>
|
||||||
<option value='read'>只读访问</option>
|
<option value='read'>只读访问</option>
|
||||||
<option value='edit'>编辑权限</option>
|
<option value='edit'>共享</option>
|
||||||
<option value='admin'>管理权限</option>
|
<option value='admin'>管理权限</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
@ -244,9 +244,9 @@ export default function Signup() {
|
|||||||
name='role'
|
name='role'
|
||||||
value={formData.role}
|
value={formData.role}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
disabled={loading}
|
disabled
|
||||||
>
|
>
|
||||||
<option value='member'>普通成员</option>
|
<option value='member'>组员</option>
|
||||||
<option value='leader'>组长</option>
|
<option value='leader'>组长</option>
|
||||||
<option value='admin'>管理员</option>
|
<option value='admin'>管理员</option>
|
||||||
</select>
|
</select>
|
||||||
|
@ -220,10 +220,30 @@ const streamRequest = async (url, data, onChunk, onError) => {
|
|||||||
if (isServerDown) {
|
if (isServerDown) {
|
||||||
console.log(`[MOCK MODE] STREAM ${url}`);
|
console.log(`[MOCK MODE] STREAM ${url}`);
|
||||||
// 模拟流式响应
|
// 模拟流式响应
|
||||||
setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"这是模拟的","conversation_id":"mock-1234"}}'), 300);
|
setTimeout(
|
||||||
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);
|
onChunk(
|
||||||
setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"数据","conversation_id":"mock-1234","is_end":true}}'), 1200);
|
'{"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' };
|
return { success: true, conversation_id: 'mock-1234' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,7 +259,7 @@ const streamRequest = async (url, data, onChunk, onError) => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': token ? `Token ${token}` : '',
|
Authorization: token ? `Token ${token}` : '',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
@ -248,50 +268,104 @@ const streamRequest = async (url, data, onChunk, onError) => {
|
|||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取响应体的reader
|
// 检查是否为SSE (Server-Sent Events)格式
|
||||||
const reader = response.body.getReader();
|
const contentType = response.headers.get('Content-Type');
|
||||||
const decoder = new TextDecoder();
|
const isSSE = contentType && contentType.includes('text/event-stream');
|
||||||
let buffer = '';
|
console.log('响应内容类型:', contentType, '是否SSE:', isSSE);
|
||||||
let conversationId = null;
|
|
||||||
|
|
||||||
// 处理流式数据
|
// 处理SSE格式
|
||||||
while (true) {
|
if (isSSE) {
|
||||||
const { done, value } = await reader.read();
|
const reader = response.body.getReader();
|
||||||
if (done) break;
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let conversationId = null;
|
||||||
|
|
||||||
// 解码并处理数据
|
// 处理流式数据
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
while (true) {
|
||||||
buffer += chunk;
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
// 按行分割并处理JSON
|
// 解码并处理数据 - 不使用stream选项以确保完整解码
|
||||||
const lines = buffer.split('\n');
|
const chunk = decoder.decode(value, { stream: false });
|
||||||
buffer = lines.pop() || ''; // 保留最后一行(可能不完整)
|
buffer += chunk;
|
||||||
|
|
||||||
for (const line of lines) {
|
// 按行分割并处理JSON
|
||||||
if (!line.trim()) continue;
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || ''; // 保留最后一行(可能不完整)
|
||||||
try {
|
|
||||||
// 检查是否为SSE格式(data: {...})
|
for (const line of lines) {
|
||||||
let jsonStr = line;
|
if (!line.trim()) continue;
|
||||||
if (line.startsWith('data:')) {
|
|
||||||
|
try {
|
||||||
// 提取data:后面的JSON部分
|
// 提取data:后面的JSON部分
|
||||||
jsonStr = line.substring(5).trim();
|
let jsonStr = line;
|
||||||
console.log('检测到SSE格式数据,提取JSON:', jsonStr);
|
if (line.startsWith('data:')) {
|
||||||
|
jsonStr = line.substring(5).trim();
|
||||||
|
console.log('SSE数据块:', jsonStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试解析JSON
|
||||||
|
const parsedData = JSON.parse(jsonStr);
|
||||||
|
if (parsedData.code === 200 && parsedData.data && parsedData.data.conversation_id) {
|
||||||
|
conversationId = parsedData.data.conversation_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即调用处理函数
|
||||||
|
onChunk(jsonStr);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('解析JSON失败:', line, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试解析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 };
|
return { success: true, conversation_id: conversationId };
|
||||||
|
}
|
||||||
|
// 处理常规JSON响应
|
||||||
|
else {
|
||||||
|
// 原始响应可能是单个JSON对象而不是流
|
||||||
|
const responseData = await response.json();
|
||||||
|
console.log('接收到非流式响应:', responseData);
|
||||||
|
|
||||||
|
if (responseData.code === 200) {
|
||||||
|
// 模拟分段处理
|
||||||
|
const content = responseData.data?.content || '';
|
||||||
|
const conversationId = responseData.data?.conversation_id;
|
||||||
|
|
||||||
|
// 每100个字符分段处理
|
||||||
|
let offset = 0;
|
||||||
|
const chunkSize = 100;
|
||||||
|
|
||||||
|
while (offset < content.length) {
|
||||||
|
const isLast = offset + chunkSize >= content.length;
|
||||||
|
const chunk = content.substring(offset, offset + chunkSize);
|
||||||
|
|
||||||
|
// 构造类似流式传输的JSON
|
||||||
|
const chunkData = {
|
||||||
|
code: 200,
|
||||||
|
message: 'partial',
|
||||||
|
data: {
|
||||||
|
content: chunk,
|
||||||
|
conversation_id: conversationId,
|
||||||
|
is_end: isLast,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 调用处理函数
|
||||||
|
onChunk(JSON.stringify(chunkData));
|
||||||
|
|
||||||
|
// 暂停一下让UI有时间更新
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
offset += chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, conversation_id: conversationId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是成功响应,直接传递原始数据
|
||||||
|
onChunk(JSON.stringify(responseData));
|
||||||
|
return { success: responseData.code === 200, conversation_id: responseData.data?.conversation_id };
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Streaming request failed:', error);
|
console.error('Streaming request failed:', error);
|
||||||
if (onError) {
|
if (onError) {
|
||||||
@ -318,4 +392,21 @@ export const rejectPermission = (permissionId) => {
|
|||||||
return post(`/permissions/reject_permission/${permissionId}`);
|
return post(`/permissions/reject_permission/${permissionId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聊天回复相关资源列表
|
||||||
|
* @param {Object} data - 请求参数
|
||||||
|
* @param {Array} data.dataset_id_list - 知识库ID列表
|
||||||
|
* @param {string} data.question - 用户问题
|
||||||
|
* @returns {Promise<Object>} API响应
|
||||||
|
*/
|
||||||
|
export const fetchChatResources = async (data) => {
|
||||||
|
try {
|
||||||
|
const response = await post('/chat-history/hit_test/', data);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取聊天资源失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export { get, post, put, del, upload, streamRequest };
|
export { get, post, put, del, upload, streamRequest };
|
||||||
|
@ -35,7 +35,7 @@ export const calculateExpiresAt = (duration) => {
|
|||||||
* @deprecated 请使用Redux thunk版本
|
* @deprecated 请使用Redux thunk版本
|
||||||
* @param {Object} requestData - 请求数据
|
* @param {Object} requestData - 请求数据
|
||||||
* @param {string} requestData.id - 知识库ID
|
* @param {string} requestData.id - 知识库ID
|
||||||
* @param {string} requestData.accessType - 访问类型,如 '只读访问', '编辑权限'
|
* @param {string} requestData.accessType - 访问类型,如 '只读访问', '共享'
|
||||||
* @param {string} requestData.duration - 访问时长,如 '一周', '一个月'
|
* @param {string} requestData.duration - 访问时长,如 '一周', '一个月'
|
||||||
* @param {string} requestData.reason - 申请原因
|
* @param {string} requestData.reason - 申请原因
|
||||||
* @returns {Promise} - API 请求的 Promise
|
* @returns {Promise} - API 请求的 Promise
|
||||||
@ -45,7 +45,7 @@ export const legacyRequestKnowledgeBaseAccess = async (requestData) => {
|
|||||||
knowledge_base: requestData.id,
|
knowledge_base: requestData.id,
|
||||||
permissions: {
|
permissions: {
|
||||||
can_read: true,
|
can_read: true,
|
||||||
can_edit: requestData.accessType === '编辑权限',
|
can_edit: requestData.accessType === '共享',
|
||||||
can_delete: false,
|
can_delete: false,
|
||||||
},
|
},
|
||||||
reason: requestData.reason,
|
reason: requestData.reason,
|
||||||
|
@ -17,9 +17,11 @@ let socket = null;
|
|||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
let pingInterval = null;
|
let pingInterval = null;
|
||||||
let reconnectAttempts = 0; // 添加重连尝试计数器
|
let reconnectAttempts = 0; // 添加重连尝试计数器
|
||||||
|
let globalReconnectAttempts = 0; // 添加全局重连计数器
|
||||||
const RECONNECT_DELAY = 5000; // 5秒后尝试重连
|
const RECONNECT_DELAY = 5000; // 5秒后尝试重连
|
||||||
const PING_INTERVAL = 30000; // 30秒发送一次ping
|
const PING_INTERVAL = 30000; // 30秒发送一次ping
|
||||||
const MAX_RECONNECT_ATTEMPTS = 3; // 最大重连尝试次数
|
const MAX_RECONNECT_ATTEMPTS = 3; // 最大重连尝试次数
|
||||||
|
const MAX_GLOBAL_RECONNECT_ATTEMPTS = 3; // 单个会话中允许的总重连次数
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化WebSocket连接
|
* 初始化WebSocket连接
|
||||||
@ -27,36 +29,63 @@ const MAX_RECONNECT_ATTEMPTS = 3; // 最大重连尝试次数
|
|||||||
*/
|
*/
|
||||||
export const initWebSocket = () => {
|
export const initWebSocket = () => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
// 检查全局重连次数
|
||||||
|
if (globalReconnectAttempts >= MAX_GLOBAL_RECONNECT_ATTEMPTS) {
|
||||||
|
console.warn(`已达到全局最大重连次数(${MAX_GLOBAL_RECONNECT_ATTEMPTS}),不再尝试重连`);
|
||||||
|
reject(new Error('Maximum global reconnection attempts reached'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 如果已经有一个连接,先关闭它
|
// 如果已经有一个连接,先关闭它
|
||||||
if (socket && socket.readyState !== WebSocket.CLOSED) {
|
if (socket && socket.readyState !== WebSocket.CLOSED) {
|
||||||
socket.close();
|
console.log('关闭已有WebSocket连接');
|
||||||
|
socket.close(1000, 'Normal closure, reconnecting');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除之前的定时器
|
// 清除之前的定时器
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) {
|
||||||
if (pingInterval) clearInterval(pingInterval);
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pingInterval) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
pingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 从sessionStorage获取token
|
// 从sessionStorage获取token
|
||||||
const encryptedToken = sessionStorage.getItem('token');
|
const encryptedToken = sessionStorage.getItem('token');
|
||||||
let token = '';
|
|
||||||
if (!encryptedToken) {
|
if (!encryptedToken) {
|
||||||
console.error('No token found, cannot connect to notification service');
|
console.error('No token found, cannot connect to notification service');
|
||||||
store.dispatch(setWebSocketConnected(false));
|
store.dispatch(setWebSocketConnected(false));
|
||||||
reject(new Error('No token found'));
|
reject(new Error('No token found'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (encryptedToken) {
|
|
||||||
|
let token = '';
|
||||||
|
try {
|
||||||
token = CryptoJS.AES.decrypt(encryptedToken, secretKey).toString(CryptoJS.enc.Utf8);
|
token = CryptoJS.AES.decrypt(encryptedToken, secretKey).toString(CryptoJS.enc.Utf8);
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Token decryption resulted in empty string');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to decrypt token:', e);
|
||||||
|
store.dispatch(setWebSocketConnected(false));
|
||||||
|
reject(new Error('Invalid token'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsUrl = `${WS_BASE_URL}/ws/notifications/?token=${token}`;
|
const wsUrl = `${WS_BASE_URL}/ws/notifications/?token=${token}`;
|
||||||
console.log('WebSocket URL:', wsUrl);
|
console.log('正在连接WebSocket...', wsUrl.substring(0, wsUrl.indexOf('?')));
|
||||||
|
|
||||||
socket = new WebSocket(wsUrl);
|
socket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
// 连接建立时的处理
|
// 连接建立时的处理
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
console.log('WebSocket connection established');
|
console.log('WebSocket 连接成功!');
|
||||||
reconnectAttempts = 0; // 连接成功后重置重连计数器
|
reconnectAttempts = 0; // 连接成功后重置当前重连计数器
|
||||||
|
// 不重置全局重连计数器,确保总重连次数限制
|
||||||
|
|
||||||
// 更新Redux中的连接状态
|
// 更新Redux中的连接状态
|
||||||
store.dispatch(setWebSocketConnected(true));
|
store.dispatch(setWebSocketConnected(true));
|
||||||
@ -65,8 +94,9 @@ export const initWebSocket = () => {
|
|||||||
subscribeToNotifications();
|
subscribeToNotifications();
|
||||||
|
|
||||||
// 设置定时发送ping消息
|
// 设置定时发送ping消息
|
||||||
|
if (pingInterval) clearInterval(pingInterval);
|
||||||
pingInterval = setInterval(() => {
|
pingInterval = setInterval(() => {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
sendPing();
|
sendPing();
|
||||||
}
|
}
|
||||||
}, PING_INTERVAL);
|
}, PING_INTERVAL);
|
||||||
@ -80,47 +110,68 @@ export const initWebSocket = () => {
|
|||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
handleWebSocketMessage(data);
|
handleWebSocketMessage(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing WebSocket message:', error);
|
console.error('解析WebSocket消息失败:', error, 'Raw message:', event.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 错误处理
|
// 错误处理
|
||||||
socket.onerror = (error) => {
|
socket.onerror = (error) => {
|
||||||
console.error('WebSocket error:', error);
|
console.error('WebSocket连接错误:', error);
|
||||||
// 更新Redux中的连接状态
|
// 不立即更新Redux状态,让onclose处理
|
||||||
store.dispatch(setWebSocketConnected(false));
|
|
||||||
reject(error);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 连接关闭时的处理
|
// 连接关闭时的处理
|
||||||
socket.onclose = (event) => {
|
socket.onclose = (event) => {
|
||||||
console.log(`WebSocket connection closed: ${event.code} ${event.reason}`);
|
console.log(
|
||||||
|
`WebSocket连接关闭: 代码=${event.code} 原因="${event.reason || '未知'}" 是否干净=${event.wasClean}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 清除ping定时器
|
||||||
|
if (pingInterval) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
pingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
// 更新Redux中的连接状态
|
// 更新Redux中的连接状态
|
||||||
store.dispatch(setWebSocketConnected(false));
|
store.dispatch(setWebSocketConnected(false));
|
||||||
|
|
||||||
// 清除ping定时器
|
|
||||||
if (pingInterval) clearInterval(pingInterval);
|
|
||||||
|
|
||||||
// 如果不是正常关闭,尝试重连
|
// 如果不是正常关闭,尝试重连
|
||||||
if (event.code !== 1000) {
|
if (!event.wasClean && event.code !== 1000 && event.code !== 1001) {
|
||||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
// 检查是否已达到最大重连次数
|
||||||
|
if (
|
||||||
|
reconnectAttempts < MAX_RECONNECT_ATTEMPTS &&
|
||||||
|
globalReconnectAttempts < MAX_GLOBAL_RECONNECT_ATTEMPTS
|
||||||
|
) {
|
||||||
reconnectAttempts++;
|
reconnectAttempts++;
|
||||||
console.log(`WebSocket reconnection attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
|
globalReconnectAttempts++;
|
||||||
|
|
||||||
|
const delay = Math.min(RECONNECT_DELAY * reconnectAttempts, 15000); // 指数退避,但最大15秒
|
||||||
|
console.log(
|
||||||
|
`WebSocket将在${
|
||||||
|
delay / 1000
|
||||||
|
}秒后尝试重连 (第${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}次, 总计${globalReconnectAttempts}/${MAX_GLOBAL_RECONNECT_ATTEMPTS}次)`
|
||||||
|
);
|
||||||
|
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
console.log('Attempting to reconnect WebSocket...');
|
console.log('正在尝试重新连接WebSocket...');
|
||||||
initWebSocket().catch((err) => {
|
initWebSocket()
|
||||||
console.error('Failed to reconnect WebSocket:', err);
|
.then(() => {
|
||||||
// 重连失败时更新Redux中的连接状态
|
console.log('WebSocket重连成功');
|
||||||
store.dispatch(setWebSocketConnected(false));
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}, RECONNECT_DELAY);
|
console.error('WebSocket重连失败:', err);
|
||||||
|
// 重连失败时更新Redux中的连接状态
|
||||||
|
store.dispatch(setWebSocketConnected(false));
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
} else {
|
} else {
|
||||||
console.log('Maximum reconnection attempts reached. Giving up.');
|
const msg = `已达到最大重连次数 (当前${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}, 总计${globalReconnectAttempts}/${MAX_GLOBAL_RECONNECT_ATTEMPTS})`;
|
||||||
|
console.warn(msg);
|
||||||
// 达到最大重连次数时更新Redux中的连接状态
|
// 达到最大重连次数时更新Redux中的连接状态
|
||||||
store.dispatch(setWebSocketConnected(false));
|
store.dispatch(setWebSocketConnected(false));
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.log('正常关闭,不会尝试重连');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -150,10 +201,16 @@ export const subscribeToNotifications = () => {
|
|||||||
*/
|
*/
|
||||||
export const sendPing = () => {
|
export const sendPing = () => {
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
const pingMessage = {
|
try {
|
||||||
type: 'ping',
|
const pingMessage = {
|
||||||
};
|
type: 'ping',
|
||||||
socket.send(JSON.stringify(pingMessage));
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
socket.send(JSON.stringify(pingMessage));
|
||||||
|
console.debug('已发送 ping 消息');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送 ping 消息失败:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -176,53 +233,91 @@ export const acknowledgeNotification = (notificationId) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 关闭WebSocket连接
|
* 关闭WebSocket连接
|
||||||
|
* @param {boolean} [permanent=false] 是否永久关闭不再重连
|
||||||
*/
|
*/
|
||||||
export const closeWebSocket = () => {
|
export const closeWebSocket = (permanent = false) => {
|
||||||
if (socket) {
|
// 停止重连尝试
|
||||||
socket.close(1000, 'Normal closure');
|
|
||||||
socket = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
clearTimeout(reconnectTimer);
|
clearTimeout(reconnectTimer);
|
||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 停止ping
|
||||||
if (pingInterval) {
|
if (pingInterval) {
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
pingInterval = null;
|
pingInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果是永久关闭,重置重连计数器
|
||||||
|
if (permanent) {
|
||||||
|
globalReconnectAttempts = MAX_GLOBAL_RECONNECT_ATTEMPTS; // 设置为最大值阻止重连
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭连接
|
||||||
|
if (socket) {
|
||||||
|
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
||||||
|
console.log(`手动关闭WebSocket连接${permanent ? '(永久)' : ''}`);
|
||||||
|
socket.close(1000, '用户主动关闭');
|
||||||
|
}
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
// 更新Redux中的连接状态
|
// 更新Redux中的连接状态
|
||||||
store.dispatch(setWebSocketConnected(false));
|
store.dispatch(setWebSocketConnected(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置WebSocket连接状态,允许重新尝试连接
|
||||||
|
*/
|
||||||
|
export const resetWebSocketState = () => {
|
||||||
|
closeWebSocket(true); // 先关闭当前连接
|
||||||
|
|
||||||
|
// 重置所有计数器和状态
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
globalReconnectAttempts = 0;
|
||||||
|
|
||||||
|
console.log('WebSocket连接状态已重置,可以重新尝试连接');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理接收到的WebSocket消息
|
* 处理接收到的WebSocket消息
|
||||||
* @param {Object} data 解析后的消息数据
|
* @param {Object} data 解析后的消息数据
|
||||||
*/
|
*/
|
||||||
const handleWebSocketMessage = (data) => {
|
const handleWebSocketMessage = (data) => {
|
||||||
switch (data.type) {
|
if (!data || typeof data !== 'object') {
|
||||||
case 'connection_established':
|
console.warn('收到无效的WebSocket消息:', data);
|
||||||
console.log(`Connection established for user: ${data.user_id}`);
|
return;
|
||||||
break;
|
}
|
||||||
|
|
||||||
case 'notification':
|
try {
|
||||||
console.log('Received notification:', data);
|
switch (data.type) {
|
||||||
// 将通知添加到Redux store
|
case 'connection_established':
|
||||||
store.dispatch(addNotification(processNotification(data)));
|
console.log(`WebSocket连接已建立,用户ID: ${data.user_id}`);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'pong':
|
case 'notification':
|
||||||
console.log(`Received pong at ${data.timestamp}`);
|
if (!data.data) {
|
||||||
break;
|
console.warn('收到无效的通知数据:', data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('收到新通知:', data.data.title);
|
||||||
|
// 将通知添加到Redux store
|
||||||
|
store.dispatch(addNotification(processNotification(data)));
|
||||||
|
break;
|
||||||
|
|
||||||
case 'error':
|
case 'pong':
|
||||||
console.error(`WebSocket error: ${data.code} - ${data.message}`);
|
console.debug(`收到pong响应,时间戳: ${data.timestamp}`);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
case 'error':
|
||||||
console.log('Received unknown message type:', data);
|
console.error(`WebSocket错误: ${data.code} - ${data.message}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log('收到未知类型的消息:', data.type, data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理WebSocket消息时发生错误:', error, 'Message:', data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,25 +1,6 @@
|
|||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { get, post } from '../../services/api';
|
import { get, post } from '../../services/api';
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取聊天消息
|
|
||||||
* @param {string} chatId - 聊天ID
|
|
||||||
*/
|
|
||||||
export const fetchMessages = createAsyncThunk('chat/fetchMessages', async (chatId, { rejectWithValue }) => {
|
|
||||||
try {
|
|
||||||
const response = await get(`/chat-history/${chatId}/messages/`);
|
|
||||||
|
|
||||||
// 处理返回格式
|
|
||||||
if (response && response.code === 200) {
|
|
||||||
return response.data.messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data?.messages || [];
|
|
||||||
} catch (error) {
|
|
||||||
return rejectWithValue(error.response?.data?.message || 'Failed to fetch messages');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送聊天消息
|
* 发送聊天消息
|
||||||
* @param {Object} params - 消息参数
|
* @param {Object} params - 消息参数
|
||||||
|
@ -9,28 +9,16 @@ import {
|
|||||||
fetchConversationDetail,
|
fetchConversationDetail,
|
||||||
createConversation,
|
createConversation,
|
||||||
} from './chat.thunks';
|
} from './chat.thunks';
|
||||||
import { fetchMessages, sendMessage } from './chat.messages.thunks';
|
import { sendMessage } from './chat.messages.thunks';
|
||||||
|
|
||||||
// 初始状态
|
// 初始状态
|
||||||
const initialState = {
|
const initialState = {
|
||||||
// Chat history state
|
|
||||||
history: {
|
|
||||||
items: [],
|
|
||||||
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
// Chat session creation state
|
// Chat session creation state
|
||||||
createSession: {
|
createSession: {
|
||||||
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
|
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
|
||||||
error: null,
|
error: null,
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
},
|
},
|
||||||
// Chat messages state
|
|
||||||
messages: {
|
|
||||||
items: [],
|
|
||||||
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
// Send message state
|
// Send message state
|
||||||
sendMessage: {
|
sendMessage: {
|
||||||
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
|
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
|
||||||
@ -47,20 +35,28 @@ const initialState = {
|
|||||||
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
|
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
// 兼容旧版本的state结构
|
// 聊天列表状态
|
||||||
list: {
|
list: {
|
||||||
items: [],
|
items: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
|
||||||
page_size: 10,
|
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
|
messageStatus: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
|
||||||
|
messageError: null,
|
||||||
},
|
},
|
||||||
// 当前聊天
|
// 当前聊天
|
||||||
currentChat: {
|
currentChat: {
|
||||||
data: null,
|
data: null,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
|
conversationId: null, // 当前选择的会话ID
|
||||||
|
},
|
||||||
|
// 聊天资源引用
|
||||||
|
resources: {
|
||||||
|
messageId: null,
|
||||||
|
items: [],
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -80,19 +76,31 @@ const chatSlice = createSlice({
|
|||||||
state.currentChat.data = null;
|
state.currentChat.data = null;
|
||||||
state.currentChat.status = 'idle';
|
state.currentChat.status = 'idle';
|
||||||
state.currentChat.error = null;
|
state.currentChat.error = null;
|
||||||
|
state.currentChat.conversationId = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设置当前聊天
|
// 设置当前聊天
|
||||||
setCurrentChat: (state, action) => {
|
setCurrentChat: (state, action) => {
|
||||||
state.currentChat.data = action.payload;
|
state.currentChat.data = action.payload;
|
||||||
state.currentChat.status = 'succeeded';
|
state.currentChat.status = 'succeeded';
|
||||||
|
if (action.payload && action.payload.conversation_id) {
|
||||||
|
state.currentChat.conversationId = action.payload.conversation_id;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 重置消息状态
|
// 重置消息状态
|
||||||
resetMessages: (state) => {
|
resetMessages: (state) => {
|
||||||
state.messages.items = [];
|
if (state.currentChat.conversationId) {
|
||||||
state.messages.status = 'idle';
|
const chatIndex = state.list.items.findIndex(
|
||||||
state.messages.error = null;
|
(chat) => chat.conversation_id === state.currentChat.conversationId
|
||||||
|
);
|
||||||
|
if (chatIndex !== -1) {
|
||||||
|
// 只重置当前会话的消息
|
||||||
|
state.list.items[chatIndex].messages = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.list.messageStatus = 'idle';
|
||||||
|
state.list.messageError = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 重置发送消息状态
|
// 重置发送消息状态
|
||||||
@ -103,64 +111,138 @@ const chatSlice = createSlice({
|
|||||||
|
|
||||||
// 添加消息
|
// 添加消息
|
||||||
addMessage: (state, action) => {
|
addMessage: (state, action) => {
|
||||||
state.messages.items.push(action.payload);
|
const conversationId = state.currentChat.conversationId;
|
||||||
|
if (conversationId) {
|
||||||
|
const chatIndex = state.list.items.findIndex((chat) => chat.conversation_id === conversationId);
|
||||||
|
console.log(chatIndex, 'chatIndex');
|
||||||
|
if (chatIndex !== -1) {
|
||||||
|
// 确保messages数组存在
|
||||||
|
if (!state.list.items[chatIndex].messages) {
|
||||||
|
state.list.items[chatIndex].messages = [];
|
||||||
|
}
|
||||||
|
// 添加消息到对应会话
|
||||||
|
state.list.items[chatIndex].messages.push(action.payload);
|
||||||
|
|
||||||
|
// 更新最后一条消息和消息数量
|
||||||
|
if (action.payload.role === 'user') {
|
||||||
|
state.list.items[chatIndex].last_message = action.payload.content;
|
||||||
|
if (state.list.items[chatIndex].message_count) {
|
||||||
|
state.list.items[chatIndex].message_count += 1;
|
||||||
|
} else {
|
||||||
|
state.list.items[chatIndex].message_count = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 更新消息(用于流式传输)
|
// 更新消息
|
||||||
updateMessage: (state, action) => {
|
updateMessage: (state, action) => {
|
||||||
const { id, ...updates } = action.payload;
|
const { id, ...updates } = action.payload;
|
||||||
const messageIndex = state.messages.items.findIndex((msg) => msg.id === id);
|
const conversationId = state.currentChat.conversationId;
|
||||||
|
|
||||||
if (messageIndex !== -1) {
|
if (conversationId) {
|
||||||
// 更新现有消息
|
const chatIndex = state.list.items.findIndex((chat) => chat.conversation_id === conversationId);
|
||||||
state.messages.items[messageIndex] = {
|
|
||||||
...state.messages.items[messageIndex],
|
|
||||||
...updates,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 如果流式传输结束,更新发送消息状态
|
if (chatIndex !== -1 && state.list.items[chatIndex].messages) {
|
||||||
if (updates.is_streaming === false) {
|
const messageIndex = state.list.items[chatIndex].messages.findIndex((msg) => msg.id === id);
|
||||||
state.sendMessage.status = 'succeeded';
|
|
||||||
|
if (messageIndex !== -1) {
|
||||||
|
// 更新现有消息
|
||||||
|
state.list.items[chatIndex].messages[messageIndex] = {
|
||||||
|
...state.list.items[chatIndex].messages[messageIndex],
|
||||||
|
...updates,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果流式传输结束,更新发送消息状态
|
||||||
|
if (updates.is_streaming === false) {
|
||||||
|
state.sendMessage.status = 'succeeded';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 添加资源
|
||||||
|
setMessageResources: (state, action) => {
|
||||||
|
const { messageId, resources } = action.payload;
|
||||||
|
state.resources = {
|
||||||
|
messageId,
|
||||||
|
items: resources,
|
||||||
|
status: 'succeeded',
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除资源
|
||||||
|
clearMessageResources: (state) => {
|
||||||
|
state.resources = {
|
||||||
|
messageId: null,
|
||||||
|
items: [],
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置资源加载状态
|
||||||
|
setResourcesLoading: (state, action) => {
|
||||||
|
state.resources.status = 'loading';
|
||||||
|
state.resources.messageId = action.payload;
|
||||||
|
state.resources.error = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置资源加载失败
|
||||||
|
setResourcesError: (state, action) => {
|
||||||
|
state.resources.status = 'failed';
|
||||||
|
state.resources.error = action.payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新特定会话的消息
|
||||||
|
updateChatMessages: (state, action) => {
|
||||||
|
const { conversationId, messages } = action.payload;
|
||||||
|
const chatIndex = state.list.items.findIndex((chat) => chat.conversation_id === conversationId);
|
||||||
|
|
||||||
|
if (chatIndex !== -1) {
|
||||||
|
state.list.items[chatIndex].messages = messages;
|
||||||
|
// 同时更新当前会话ID
|
||||||
|
state.currentChat.conversationId = conversationId;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
// 获取聊天列表
|
// 获取聊天列表
|
||||||
builder
|
builder
|
||||||
.addCase(fetchChats.pending, (state) => {
|
.addCase(fetchChats.pending, (state) => {
|
||||||
state.list.status = 'loading';
|
state.list.status = 'loading';
|
||||||
state.history.status = 'loading';
|
|
||||||
})
|
})
|
||||||
.addCase(fetchChats.fulfilled, (state, action) => {
|
.addCase(fetchChats.fulfilled, (state, action) => {
|
||||||
state.list.status = 'succeeded';
|
state.list.status = 'succeeded';
|
||||||
|
console.log(action.payload, '当前list.items:', state.list.items);
|
||||||
|
|
||||||
// 检查是否是追加模式
|
// 检查是否是追加模式
|
||||||
if (action.payload.append) {
|
if (action.payload.append) {
|
||||||
// 追加模式:将新结果添加到现有列表的前面
|
// 追加模式:将新结果添加到现有列表的前面
|
||||||
state.list.items = [...action.payload.results, ...state.list.items];
|
// 确保每个聊天项都有messages数组
|
||||||
state.history.items = [...action.payload.results, ...state.history.items];
|
const newResults = action.payload.results.map((chat) => ({
|
||||||
|
...chat,
|
||||||
|
messages: chat.messages || [],
|
||||||
|
}));
|
||||||
|
state.list.items = [...newResults, ...state.list.items];
|
||||||
} else {
|
} else {
|
||||||
// 替换模式:使用新结果替换整个列表
|
// 替换模式:使用新结果替换整个列表
|
||||||
state.list.items = action.payload.results;
|
// 确保每个聊天项都有messages数组
|
||||||
|
state.list.items = (action.payload.results || []).map((chat) => ({
|
||||||
|
...chat,
|
||||||
|
messages: chat.messages || [],
|
||||||
|
}));
|
||||||
state.list.total = action.payload.total;
|
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.list.error = null;
|
||||||
state.history.error = null;
|
|
||||||
})
|
})
|
||||||
.addCase(fetchChats.rejected, (state, action) => {
|
.addCase(fetchChats.rejected, (state, action) => {
|
||||||
state.list.status = 'failed';
|
state.list.status = 'failed';
|
||||||
state.list.error = action.payload || action.error.message;
|
state.list.error = action.payload || action.error.message;
|
||||||
|
|
||||||
// 同时更新新的状态结构
|
|
||||||
state.history.status = 'failed';
|
|
||||||
state.history.error = action.payload || action.error.message;
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建聊天
|
// 创建聊天
|
||||||
@ -185,16 +267,14 @@ const chatSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(deleteChat.fulfilled, (state, action) => {
|
.addCase(deleteChat.fulfilled, (state, action) => {
|
||||||
state.operations.status = 'succeeded';
|
state.operations.status = 'succeeded';
|
||||||
// 更新旧的状态结构
|
// 更新聊天列表
|
||||||
state.list.items = state.list.items.filter((chat) => chat.id !== action.payload);
|
state.list.items = state.list.items.filter((chat) => chat.conversation_id !== action.payload);
|
||||||
// 更新新的状态结构
|
|
||||||
state.history.items = state.history.items.filter((chat) => chat.conversation_id !== action.payload);
|
|
||||||
|
|
||||||
if (state.list.total > 0) {
|
if (state.list.total > 0) {
|
||||||
state.list.total -= 1;
|
state.list.total -= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.currentChat.data && state.currentChat.data.id === action.payload) {
|
if (state.currentChat.data && state.currentChat.data.conversation_id === action.payload) {
|
||||||
state.currentChat.data = null;
|
state.currentChat.data = null;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -222,20 +302,6 @@ const chatSlice = createSlice({
|
|||||||
state.operations.error = action.payload || action.error.message;
|
state.operations.error = action.payload || action.error.message;
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取聊天消息
|
|
||||||
.addCase(fetchMessages.pending, (state) => {
|
|
||||||
state.messages.status = 'loading';
|
|
||||||
state.messages.error = null;
|
|
||||||
})
|
|
||||||
.addCase(fetchMessages.fulfilled, (state, action) => {
|
|
||||||
state.messages.status = 'succeeded';
|
|
||||||
state.messages.items = action.payload;
|
|
||||||
})
|
|
||||||
.addCase(fetchMessages.rejected, (state, action) => {
|
|
||||||
state.messages.status = 'failed';
|
|
||||||
state.messages.error = action.error.message;
|
|
||||||
})
|
|
||||||
|
|
||||||
// 发送聊天消息
|
// 发送聊天消息
|
||||||
.addCase(sendMessage.pending, (state) => {
|
.addCase(sendMessage.pending, (state) => {
|
||||||
state.sendMessage.status = 'loading';
|
state.sendMessage.status = 'loading';
|
||||||
@ -348,6 +414,11 @@ export const {
|
|||||||
resetSendMessageStatus,
|
resetSendMessageStatus,
|
||||||
addMessage,
|
addMessage,
|
||||||
updateMessage,
|
updateMessage,
|
||||||
|
setMessageResources,
|
||||||
|
clearMessageResources,
|
||||||
|
setResourcesLoading,
|
||||||
|
setResourcesError,
|
||||||
|
updateChatMessages,
|
||||||
} = chatSlice.actions;
|
} = chatSlice.actions;
|
||||||
|
|
||||||
// 导出 reducer
|
// 导出 reducer
|
||||||
|
@ -1,29 +1,32 @@
|
|||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { get, post, put, del, streamRequest } from '../../services/api';
|
import { get, post, put, del, streamRequest, fetchChatResources } from '../../services/api';
|
||||||
import { showNotification } from '../notification.slice';
|
import { showNotification } from '../notification.slice';
|
||||||
import { addMessage, updateMessage, setCurrentChat } from './chat.slice';
|
import {
|
||||||
|
addMessage,
|
||||||
|
updateMessage,
|
||||||
|
setCurrentChat,
|
||||||
|
setMessageResources,
|
||||||
|
setResourcesLoading,
|
||||||
|
setResourcesError,
|
||||||
|
} from './chat.slice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取聊天列表
|
* 获取聊天列表
|
||||||
* @param {Object} params - 查询参数
|
* @param {Object} params - 查询参数
|
||||||
* @param {number} params.page - 页码
|
|
||||||
* @param {number} params.page_size - 每页数量
|
|
||||||
*/
|
*/
|
||||||
export const fetchChats = createAsyncThunk('chat/fetchChats', async (params = {}, { rejectWithValue }) => {
|
export const fetchChats = createAsyncThunk('chat/fetchChats', async (_, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await get('/chat-history/', { params });
|
const response = await get('/chat-history/');
|
||||||
|
|
||||||
// 处理返回格式
|
// 处理返回格式
|
||||||
if (response && response.code === 200) {
|
if (response && response.code === 200) {
|
||||||
return {
|
return {
|
||||||
results: response.data.results,
|
results: response.data,
|
||||||
total: response.data.total,
|
total: response.data.length,
|
||||||
page: response.data.page || 1,
|
|
||||||
page_size: response.data.page_size || 10,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { results: [], total: 0, page: 1, page_size: 10 };
|
return { results: [], total: 0 };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching chats:', error);
|
console.error('Error fetching chats:', error);
|
||||||
return rejectWithValue(error.response?.data?.message || 'Failed to fetch chats');
|
return rejectWithValue(error.response?.data?.message || 'Failed to fetch chats');
|
||||||
@ -134,6 +137,15 @@ export const createChatRecord = createAsyncThunk(
|
|||||||
|
|
||||||
// 先添加用户消息到聊天窗口
|
// 先添加用户消息到聊天窗口
|
||||||
const userMessageId = Date.now().toString();
|
const userMessageId = Date.now().toString();
|
||||||
|
|
||||||
|
// 先设置当前会话ID,这样addMessage可以找到正确的聊天项
|
||||||
|
if (conversation_id) {
|
||||||
|
dispatch({
|
||||||
|
type: 'chat/setCurrentChat',
|
||||||
|
payload: { conversation_id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
addMessage({
|
addMessage({
|
||||||
id: userMessageId,
|
id: userMessageId,
|
||||||
@ -158,6 +170,15 @@ export const createChatRecord = createAsyncThunk(
|
|||||||
let finalMessage = '';
|
let finalMessage = '';
|
||||||
let conversationId = conversation_id;
|
let conversationId = conversation_id;
|
||||||
|
|
||||||
|
// 同时获取聊天资源 - 在后台发送请求
|
||||||
|
dispatch(
|
||||||
|
getChatResources({
|
||||||
|
dataset_id_list,
|
||||||
|
question,
|
||||||
|
messageId: assistantMessageId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// 使用流式请求函数处理
|
// 使用流式请求函数处理
|
||||||
const result = await streamRequest(
|
const result = await streamRequest(
|
||||||
'/chat-history/',
|
'/chat-history/',
|
||||||
@ -168,11 +189,17 @@ export const createChatRecord = createAsyncThunk(
|
|||||||
const data = JSON.parse(chunkText);
|
const data = JSON.parse(chunkText);
|
||||||
console.log('收到聊天数据块:', data);
|
console.log('收到聊天数据块:', data);
|
||||||
|
|
||||||
if (data.code === 200) {
|
if (data.code === 200 || data.code === 201) {
|
||||||
// 保存会话ID (无论消息类型,只要找到会话ID就保存)
|
// 保存会话ID (无论消息类型,只要找到会话ID就保存)
|
||||||
if (data.data && data.data.conversation_id && !conversationId) {
|
if (data.data && data.data.conversation_id && !conversationId) {
|
||||||
conversationId = data.data.conversation_id;
|
conversationId = data.data.conversation_id;
|
||||||
console.log('获取到会话ID:', conversationId);
|
console.log('获取到会话ID:', conversationId);
|
||||||
|
|
||||||
|
// 设置当前会话ID,使消息更新能找到正确的聊天项
|
||||||
|
dispatch({
|
||||||
|
type: 'chat/setCurrentChat',
|
||||||
|
payload: { conversation_id: conversationId },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理各种可能的消息类型
|
// 处理各种可能的消息类型
|
||||||
@ -190,6 +217,7 @@ export const createChatRecord = createAsyncThunk(
|
|||||||
updateMessage({
|
updateMessage({
|
||||||
id: assistantMessageId,
|
id: assistantMessageId,
|
||||||
content: finalMessage,
|
content: finalMessage,
|
||||||
|
is_streaming: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -234,6 +262,7 @@ export const createChatRecord = createAsyncThunk(
|
|||||||
updateMessage({
|
updateMessage({
|
||||||
id: assistantMessageId,
|
id: assistantMessageId,
|
||||||
content: finalMessage,
|
content: finalMessage,
|
||||||
|
is_streaming: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -277,7 +306,7 @@ export const createChatRecord = createAsyncThunk(
|
|||||||
// 获取知识库信息
|
// 获取知识库信息
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const availableDatasets = state.chat.availableDatasets.items || [];
|
const availableDatasets = state.chat.availableDatasets.items || [];
|
||||||
const existingChats = state.chat.history.items || [];
|
const existingChats = state.chat.list.items || [];
|
||||||
|
|
||||||
// 检查是否已存在此会话ID的记录
|
// 检查是否已存在此会话ID的记录
|
||||||
const existingChat = existingChats.find((chat) => chat.conversation_id === chatInfo.conversation_id);
|
const existingChat = existingChats.find((chat) => chat.conversation_id === chatInfo.conversation_id);
|
||||||
@ -303,6 +332,7 @@ export const createChatRecord = createAsyncThunk(
|
|||||||
create_time: new Date().toISOString(),
|
create_time: new Date().toISOString(),
|
||||||
last_message: question,
|
last_message: question,
|
||||||
message_count: 2, // 用户问题和助手回复
|
message_count: 2, // 用户问题和助手回复
|
||||||
|
messages: [], // 确保有消息数组
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新当前聊天
|
// 更新当前聊天
|
||||||
@ -375,6 +405,16 @@ export const fetchConversationDetail = createAsyncThunk(
|
|||||||
currentChat?.conversation_id === conversationId
|
currentChat?.conversation_id === conversationId
|
||||||
) {
|
) {
|
||||||
console.log('使用新创建的会话数据,跳过详情请求:', conversationId);
|
console.log('使用新创建的会话数据,跳过详情请求:', conversationId);
|
||||||
|
|
||||||
|
// 确保设置当前会话ID
|
||||||
|
dispatch({
|
||||||
|
type: 'chat/setCurrentChat',
|
||||||
|
payload: {
|
||||||
|
...currentChat,
|
||||||
|
conversation_id: conversationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return currentChat;
|
return currentChat;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -383,12 +423,25 @@ export const fetchConversationDetail = createAsyncThunk(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response && response.code === 200) {
|
if (response && response.code === 200) {
|
||||||
// 如果存在消息,更新Redux状态
|
// 找到对应的chat item并添加消息
|
||||||
if (response.data.messages) {
|
if (response.data.messages) {
|
||||||
dispatch({
|
const chatList = state.chat.list.items;
|
||||||
type: 'chat/fetchMessages/fulfilled',
|
const chatIndex = chatList.findIndex((chat) => chat.conversation_id === conversationId);
|
||||||
payload: response.data.messages,
|
console.log(chatIndex, 'chatIndex');
|
||||||
});
|
|
||||||
|
if (chatIndex !== -1) {
|
||||||
|
// 直接更新该聊天的消息
|
||||||
|
dispatch({
|
||||||
|
type: 'chat/updateChatMessages',
|
||||||
|
payload: {
|
||||||
|
conversationId,
|
||||||
|
messages: response.data.messages,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 如果不存在该聊天,先通过fetchChats刷新列表
|
||||||
|
await dispatch(fetchChats());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -450,6 +503,7 @@ export const createConversation = createAsyncThunk(
|
|||||||
create_time: new Date().toISOString(),
|
create_time: new Date().toISOString(),
|
||||||
last_message: '',
|
last_message: '',
|
||||||
message_count: 0,
|
message_count: 0,
|
||||||
|
messages: [], // 确保有消息数组
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新聊天历史列表
|
// 更新聊天历史列表
|
||||||
@ -489,3 +543,58 @@ export const createConversation = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聊天回复的相关资源
|
||||||
|
* @param {Object} params - 聊天参数
|
||||||
|
* @param {string[]} params.dataset_id_list - 知识库ID列表
|
||||||
|
* @param {string} params.question - 用户问题
|
||||||
|
* @param {string} params.messageId - 消息ID,用于关联资源
|
||||||
|
*/
|
||||||
|
export const getChatResources = createAsyncThunk(
|
||||||
|
'chat/getChatResources',
|
||||||
|
async ({ dataset_id_list, question, messageId }, { dispatch }) => {
|
||||||
|
try {
|
||||||
|
// 设置资源加载状态
|
||||||
|
dispatch(setResourcesLoading(messageId));
|
||||||
|
|
||||||
|
// 调用API获取资源
|
||||||
|
const response = await fetchChatResources({
|
||||||
|
dataset_id_list,
|
||||||
|
question,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理响应
|
||||||
|
if (response && response.code === 200 && response.data) {
|
||||||
|
const { matched_documents } = response.data;
|
||||||
|
|
||||||
|
// 将资源添加到store
|
||||||
|
dispatch(
|
||||||
|
setMessageResources({
|
||||||
|
messageId,
|
||||||
|
resources: matched_documents || [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return matched_documents;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取聊天资源失败:', error);
|
||||||
|
|
||||||
|
// 设置错误状态
|
||||||
|
dispatch(setResourcesError(error.message || '获取资源失败'));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取存在的聊天(如果存在)
|
||||||
|
export const getExistingChat = (conversationId) => (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const existingChats = state.chat.list.items;
|
||||||
|
|
||||||
|
// 只通过会话ID查找聊天
|
||||||
|
return existingChats.find((chat) => chat.conversation_id === conversationId);
|
||||||
|
};
|
||||||
|
@ -7,6 +7,7 @@ import knowledgeBaseReducer from './knowledgeBase/knowledgeBase.slice.js';
|
|||||||
import chatReducer from './chat/chat.slice.js';
|
import chatReducer from './chat/chat.slice.js';
|
||||||
import permissionsReducer from './permissions/permissions.slice.js';
|
import permissionsReducer from './permissions/permissions.slice.js';
|
||||||
import notificationCenterReducer from './notificationCenter/notificationCenter.slice.js';
|
import notificationCenterReducer from './notificationCenter/notificationCenter.slice.js';
|
||||||
|
import uploadReducer from './upload/upload.slice.js';
|
||||||
|
|
||||||
const rootRducer = combineReducers({
|
const rootRducer = combineReducers({
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
@ -15,6 +16,7 @@ const rootRducer = combineReducers({
|
|||||||
chat: chatReducer,
|
chat: chatReducer,
|
||||||
permissions: permissionsReducer,
|
permissions: permissionsReducer,
|
||||||
notificationCenter: notificationCenterReducer,
|
notificationCenter: notificationCenterReducer,
|
||||||
|
upload: uploadReducer,
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistConfig = {
|
const persistConfig = {
|
||||||
@ -26,13 +28,21 @@ const persistConfig = {
|
|||||||
// Persist configuration
|
// Persist configuration
|
||||||
const persistedReducer = persistReducer(persistConfig, rootRducer);
|
const persistedReducer = persistReducer(persistConfig, rootRducer);
|
||||||
|
|
||||||
|
// Create the store with middleware
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: persistedReducer,
|
reducer: persistedReducer,
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
serializableCheck: false, // Disable serializable check for redux-persist
|
serializableCheck: {
|
||||||
|
// Ignore these action types
|
||||||
|
ignoredActions: ['persist/PERSIST'],
|
||||||
|
// Ignore these field paths in all actions
|
||||||
|
ignoredActionPaths: ['meta.arg', 'payload.timestamp'],
|
||||||
|
// Ignore these paths in the state
|
||||||
|
ignoredPaths: ['items.dates'],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
devTools: true,
|
devTools: process.env.NODE_ENV !== 'production',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create the persistor to manage rehydrating the store
|
// Create the persistor to manage rehydrating the store
|
||||||
|
19
src/store/upload/upload.slice.js
Normal file
19
src/store/upload/upload.slice.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
isUploading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadSlice = createSlice({
|
||||||
|
name: 'upload',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setIsUploading: (state, action) => {
|
||||||
|
console.log('Setting isUploading to:', action.payload);
|
||||||
|
state.isUploading = action.payload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setIsUploading } = uploadSlice.actions;
|
||||||
|
export default uploadSlice.reducer;
|
@ -444,3 +444,25 @@
|
|||||||
padding-right: 0.75rem;
|
padding-right: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResourceList 组件样式
|
||||||
|
.resource-list {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
.resource-item {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f0f0f0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-source {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user