feat(frontend): Day 21 knowledge base management frontend completed
This commit is contained in:
185
frontend/src/api/knowledgeBaseApi.ts
Normal file
185
frontend/src/api/knowledgeBaseApi.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${API_BASE_URL}/api/v1`,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 知识库类型定义
|
||||
*/
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
difyDatasetId: string;
|
||||
fileCount: number;
|
||||
totalSizeBytes: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
_count?: {
|
||||
documents: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: string;
|
||||
kbId: string;
|
||||
userId: string;
|
||||
filename: string;
|
||||
fileType: string;
|
||||
fileSizeBytes: number;
|
||||
fileUrl: string;
|
||||
difyDocumentId: string;
|
||||
status: 'uploading' | 'parsing' | 'indexing' | 'completed' | 'error';
|
||||
progress: number;
|
||||
errorMessage?: string;
|
||||
segmentsCount?: number;
|
||||
tokensCount?: number;
|
||||
uploadedAt: string;
|
||||
processedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateKnowledgeBaseRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateKnowledgeBaseRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseStats {
|
||||
totalDocuments: number;
|
||||
completedDocuments: number;
|
||||
processingDocuments: number;
|
||||
errorDocuments: number;
|
||||
totalSizeBytes: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识库管理API
|
||||
*/
|
||||
export const knowledgeBaseApi = {
|
||||
/**
|
||||
* 获取知识库列表
|
||||
*/
|
||||
async getList(): Promise<KnowledgeBase[]> {
|
||||
const response = await api.get('/knowledge-bases');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取知识库详情
|
||||
*/
|
||||
async getById(id: string): Promise<KnowledgeBase> {
|
||||
const response = await api.get(`/knowledge-bases/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建知识库
|
||||
*/
|
||||
async create(data: CreateKnowledgeBaseRequest): Promise<KnowledgeBase> {
|
||||
const response = await api.post('/knowledge-bases', data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新知识库
|
||||
*/
|
||||
async update(id: string, data: UpdateKnowledgeBaseRequest): Promise<KnowledgeBase> {
|
||||
const response = await api.put(`/knowledge-bases/${id}`, data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除知识库
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
await api.delete(`/knowledge-bases/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取知识库统计信息
|
||||
*/
|
||||
async getStats(id: string): Promise<KnowledgeBaseStats> {
|
||||
const response = await api.get(`/knowledge-bases/${id}/stats`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 检索知识库
|
||||
*/
|
||||
async search(id: string, query: string, topK: number = 3): Promise<any> {
|
||||
const response = await api.get(`/knowledge-bases/${id}/search`, {
|
||||
params: { query, top_k: topK },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 文档管理API
|
||||
*/
|
||||
export const documentApi = {
|
||||
/**
|
||||
* 获取文档列表
|
||||
*/
|
||||
async getList(kbId: string): Promise<Document[]> {
|
||||
const response = await api.get(`/knowledge-bases/${kbId}/documents`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取文档详情
|
||||
*/
|
||||
async getById(id: string): Promise<Document> {
|
||||
const response = await api.get(`/documents/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传文档
|
||||
*/
|
||||
async upload(kbId: string, file: File, onProgress?: (progress: number) => void): Promise<Document> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await api.post(`/knowledge-bases/${kbId}/documents`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total && onProgress) {
|
||||
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress(percentCompleted);
|
||||
}
|
||||
},
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除文档
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
await api.delete(`/documents/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 重新处理文档
|
||||
*/
|
||||
async reprocess(id: string): Promise<void> {
|
||||
await api.post(`/documents/${id}/reprocess`);
|
||||
},
|
||||
};
|
||||
|
||||
115
frontend/src/components/knowledge/CreateKBDialog.tsx
Normal file
115
frontend/src/components/knowledge/CreateKBDialog.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Form, Input, message } from 'antd';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface CreateKBDialogProps {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onCreate: (name: string, description?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const CreateKBDialog: React.FC<CreateKBDialogProps> = ({
|
||||
open,
|
||||
onCancel,
|
||||
onCreate,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
await onCreate(values.name, values.description);
|
||||
|
||||
message.success('知识库创建成功!');
|
||||
form.resetFields();
|
||||
onCancel();
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) {
|
||||
// 表单验证错误
|
||||
return;
|
||||
}
|
||||
message.error(error.message || '创建失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="创建知识库"
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
confirmLoading={loading}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="知识库名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入知识库名称' },
|
||||
{ max: 50, message: '名称不能超过50个字符' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="例如:文献综述资料"
|
||||
maxLength={50}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述(选填)"
|
||||
rules={[
|
||||
{ max: 200, message: '描述不能超过200个字符' },
|
||||
]}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="简要描述该知识库的用途..."
|
||||
rows={4}
|
||||
maxLength={200}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: '#f0f5ff',
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
color: '#595959'
|
||||
}}>
|
||||
<p style={{ margin: 0, marginBottom: 8 }}>
|
||||
📌 <strong>提示</strong>
|
||||
</p>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
<li>每个用户最多创建 3 个知识库</li>
|
||||
<li>每个知识库最多上传 50 个文档</li>
|
||||
<li>支持的文件格式:PDF、DOC、DOCX、TXT、MD</li>
|
||||
<li>单个文件最大10MB</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateKBDialog;
|
||||
|
||||
211
frontend/src/components/knowledge/DocumentList.tsx
Normal file
211
frontend/src/components/knowledge/DocumentList.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React from 'react';
|
||||
import { Table, Tag, Button, Popconfirm, Space, Typography, Progress, Tooltip } from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { Document } from '../../api/knowledgeBaseApi';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface DocumentListProps {
|
||||
documents: Document[];
|
||||
loading: boolean;
|
||||
onDelete: (doc: Document) => void;
|
||||
onReprocess: (doc: Document) => void;
|
||||
}
|
||||
|
||||
const DocumentList: React.FC<DocumentListProps> = ({
|
||||
documents,
|
||||
loading,
|
||||
onDelete,
|
||||
onReprocess,
|
||||
}) => {
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const getStatusTag = (status: Document['status']) => {
|
||||
const statusConfig = {
|
||||
uploading: {
|
||||
color: 'blue',
|
||||
icon: <LoadingOutlined />,
|
||||
text: '上传中'
|
||||
},
|
||||
parsing: {
|
||||
color: 'processing',
|
||||
icon: <ClockCircleOutlined />,
|
||||
text: '解析中'
|
||||
},
|
||||
indexing: {
|
||||
color: 'processing',
|
||||
icon: <ClockCircleOutlined />,
|
||||
text: '索引中'
|
||||
},
|
||||
completed: {
|
||||
color: 'success',
|
||||
icon: <CheckCircleOutlined />,
|
||||
text: '已就绪'
|
||||
},
|
||||
error: {
|
||||
color: 'error',
|
||||
icon: <CloseCircleOutlined />,
|
||||
text: '失败'
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || statusConfig.uploading;
|
||||
|
||||
return (
|
||||
<Tag icon={config.icon} color={config.color}>
|
||||
{config.text}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Document> = [
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'filename',
|
||||
key: 'filename',
|
||||
ellipsis: true,
|
||||
render: (filename: string) => (
|
||||
<Space>
|
||||
<FileTextOutlined style={{ color: '#1890ff' }} />
|
||||
<Text ellipsis>{filename}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '文件大小',
|
||||
dataIndex: 'fileSizeBytes',
|
||||
key: 'fileSizeBytes',
|
||||
width: 120,
|
||||
render: (size: number) => formatFileSize(size),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
render: (status: Document['status'], record: Document) => {
|
||||
if (status === 'error' && record.errorMessage) {
|
||||
return (
|
||||
<Tooltip title={record.errorMessage}>
|
||||
{getStatusTag(status)}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return getStatusTag(status);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
width: 150,
|
||||
render: (progress: number, record: Document) => {
|
||||
if (record.status === 'completed') {
|
||||
return (
|
||||
<Space size={4}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{record.segmentsCount || 0} 段
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>|</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{record.tokensCount || 0} tokens
|
||||
</Text>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
if (record.status === 'error') {
|
||||
return <Text type="danger">处理失败</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Progress
|
||||
percent={progress}
|
||||
size="small"
|
||||
status={progress === 100 ? 'success' : 'active'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '上传时间',
|
||||
dataIndex: 'uploadedAt',
|
||||
key: 'uploadedAt',
|
||||
width: 180,
|
||||
render: (date: string) => new Date(date).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: (_: any, record: Document) => (
|
||||
<Space size="small">
|
||||
{record.status === 'error' && (
|
||||
<Tooltip title="重新处理">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => onReprocess(record)}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
description={`删除文档 "${record.filename}"?`}
|
||||
onConfirm={() => onDelete(record)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={documents}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 个文档`,
|
||||
}}
|
||||
locale={{
|
||||
emptyText: '暂无文档,请上传文件',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentList;
|
||||
|
||||
144
frontend/src/components/knowledge/DocumentUpload.tsx
Normal file
144
frontend/src/components/knowledge/DocumentUpload.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Upload, message, Progress, Card } from 'antd';
|
||||
import { InboxOutlined } from '@ant-design/icons';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { useKnowledgeBaseStore } from '../../stores/useKnowledgeBaseStore';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
interface DocumentUploadProps {
|
||||
kbId: string;
|
||||
onUploadSuccess: () => void;
|
||||
disabled?: boolean;
|
||||
maxDocuments?: number;
|
||||
currentDocumentCount?: number;
|
||||
}
|
||||
|
||||
const DocumentUpload: React.FC<DocumentUploadProps> = ({
|
||||
kbId,
|
||||
onUploadSuccess,
|
||||
disabled = false,
|
||||
maxDocuments = 50,
|
||||
currentDocumentCount = 0,
|
||||
}) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
];
|
||||
|
||||
const allowedExtensions = ['.pdf', '.doc', '.docx', '.txt', '.md'];
|
||||
|
||||
const isAtLimit = currentDocumentCount >= maxDocuments;
|
||||
|
||||
const beforeUpload = (file: File) => {
|
||||
// 检查文档数量限制
|
||||
if (isAtLimit) {
|
||||
message.error(`已达到文档数量上限(${maxDocuments}个)`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
if (!allowedTypes.includes(file.type) && !allowedExtensions.some(ext => file.name.endsWith(ext))) {
|
||||
message.error(`不支持的文件类型。支持:PDF、DOC、DOCX、TXT、MD`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
// 检查文件大小 (10MB)
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
message.error('文件大小不能超过 10MB');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
return false; // 阻止自动上传,我们手动处理
|
||||
};
|
||||
|
||||
const customRequest: UploadProps['customRequest'] = async (options) => {
|
||||
const { file, onSuccess, onError } = options;
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
// 使用 knowledgeBaseStore 的 uploadDocument 方法
|
||||
await useKnowledgeBaseStore.getState().uploadDocument(
|
||||
kbId,
|
||||
file as File,
|
||||
(progress) => {
|
||||
setUploadProgress(progress);
|
||||
}
|
||||
);
|
||||
|
||||
message.success(`${(file as File).name} 上传成功`);
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess('ok');
|
||||
}
|
||||
|
||||
// 通知父组件刷新文档列表
|
||||
onUploadSuccess();
|
||||
} catch (error: any) {
|
||||
console.error('Upload error:', error);
|
||||
message.error(error.message || '上传失败');
|
||||
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Dragger
|
||||
name="file"
|
||||
multiple={false}
|
||||
beforeUpload={beforeUpload}
|
||||
customRequest={customRequest}
|
||||
disabled={disabled || isAtLimit || uploading}
|
||||
showUploadList={false}
|
||||
style={{ marginBottom: uploading ? 16 : 0 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
{isAtLimit ? `已达到文档数量上限(${maxDocuments}个)` : '点击或拖拽文件到此区域上传'}
|
||||
</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持格式:PDF、DOC、DOCX、TXT、MD | 单个文件最大10MB
|
||||
</p>
|
||||
<p className="ant-upload-hint" style={{ marginTop: 8, fontSize: 13, color: '#8c8c8c' }}>
|
||||
当前已上传: {currentDocumentCount}/{maxDocuments} 个文档
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
{uploading && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
status={uploadProgress === 100 ? 'success' : 'active'}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
/>
|
||||
<p style={{ marginTop: 8, textAlign: 'center', color: '#595959', fontSize: 13 }}>
|
||||
{uploadProgress < 100 ? '正在上传...' : '上传完成,正在处理...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentUpload;
|
||||
|
||||
111
frontend/src/components/knowledge/EditKBDialog.tsx
Normal file
111
frontend/src/components/knowledge/EditKBDialog.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, message } from 'antd';
|
||||
import type { KnowledgeBase } from '../../api/knowledgeBaseApi';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface EditKBDialogProps {
|
||||
open: boolean;
|
||||
knowledgeBase: KnowledgeBase | null;
|
||||
onCancel: () => void;
|
||||
onUpdate: (id: string, name: string, description?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const EditKBDialog: React.FC<EditKBDialogProps> = ({
|
||||
open,
|
||||
knowledgeBase,
|
||||
onCancel,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 当知识库数据变化时,更新表单
|
||||
useEffect(() => {
|
||||
if (open && knowledgeBase) {
|
||||
form.setFieldsValue({
|
||||
name: knowledgeBase.name,
|
||||
description: knowledgeBase.description || '',
|
||||
});
|
||||
}
|
||||
}, [open, knowledgeBase, form]);
|
||||
|
||||
const handleOk = async () => {
|
||||
if (!knowledgeBase) return;
|
||||
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
await onUpdate(knowledgeBase.id, values.name, values.description);
|
||||
|
||||
message.success('知识库更新成功!');
|
||||
onCancel();
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) {
|
||||
// 表单验证错误
|
||||
return;
|
||||
}
|
||||
message.error(error.message || '更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="编辑知识库"
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={loading}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="知识库名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入知识库名称' },
|
||||
{ max: 50, message: '名称不能超过50个字符' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="例如:文献综述资料"
|
||||
maxLength={50}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述(选填)"
|
||||
rules={[
|
||||
{ max: 200, message: '描述不能超过200个字符' },
|
||||
]}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="简要描述该知识库的用途..."
|
||||
rows={4}
|
||||
maxLength={200}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditKBDialog;
|
||||
|
||||
203
frontend/src/components/knowledge/KnowledgeBaseList.tsx
Normal file
203
frontend/src/components/knowledge/KnowledgeBaseList.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React from 'react';
|
||||
import { Card, Button, Empty, Tag, Popconfirm, Space, Typography } from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
FolderOutlined,
|
||||
FileTextOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { KnowledgeBase } from '../../api/knowledgeBaseApi';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
interface KnowledgeBaseListProps {
|
||||
knowledgeBases: KnowledgeBase[];
|
||||
loading: boolean;
|
||||
onCreateClick: () => void;
|
||||
onEditClick: (kb: KnowledgeBase) => void;
|
||||
onDeleteClick: (kb: KnowledgeBase) => void;
|
||||
onSelectClick: (kb: KnowledgeBase) => void;
|
||||
}
|
||||
|
||||
const KnowledgeBaseList: React.FC<KnowledgeBaseListProps> = ({
|
||||
knowledgeBases,
|
||||
loading,
|
||||
onCreateClick,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
onSelectClick,
|
||||
}) => {
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const canCreateMore = knowledgeBases.length < 3;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 标题和创建按钮 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24
|
||||
}}>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>我的知识库</Title>
|
||||
<Text type="secondary">
|
||||
已使用 {knowledgeBases.length}/3 个知识库
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onCreateClick}
|
||||
disabled={!canCreateMore || loading}
|
||||
>
|
||||
创建知识库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 配额提示 */}
|
||||
{!canCreateMore && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Tag color="warning">
|
||||
已达到知识库数量上限(3个)
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 知识库列表 */}
|
||||
{knowledgeBases.length === 0 ? (
|
||||
<Card>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="还没有知识库,创建第一个吧!"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onCreateClick}
|
||||
>
|
||||
创建知识库
|
||||
</Button>
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
gap: 16
|
||||
}}>
|
||||
{knowledgeBases.map((kb) => (
|
||||
<Card
|
||||
key={kb.id}
|
||||
hoverable
|
||||
onClick={() => onSelectClick(kb)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
actions={[
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditClick(kb);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确认删除?"
|
||||
description={`删除知识库 "${kb.name}" 将同时删除其中的所有文档,此操作不可恢复。`}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
onDeleteClick(kb);
|
||||
}}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
avatar={
|
||||
<div style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
background: '#1890ff20',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<FolderOutlined style={{ fontSize: 24, color: '#1890ff' }} />
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<Text strong ellipsis style={{ flex: 1 }}>
|
||||
{kb.name}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 2 }}
|
||||
type="secondary"
|
||||
style={{ marginBottom: 12, minHeight: 44 }}
|
||||
>
|
||||
{kb.description || '暂无描述'}
|
||||
</Paragraph>
|
||||
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<FileTextOutlined style={{ marginRight: 8, color: '#8c8c8c' }} />
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{kb._count?.documents || kb.fileCount || 0} 个文档
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
总大小: {formatFileSize(kb.totalSizeBytes)}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
创建于 {new Date(kb.createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KnowledgeBaseList;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Typography, Space, Alert, Spin, message } from 'antd'
|
||||
import { Space, Alert, Spin, message } from 'antd'
|
||||
import { RobotOutlined } from '@ant-design/icons'
|
||||
import { agentApi, type AgentConfig } from '../api/agentApi'
|
||||
import conversationApi, { type Conversation, type Message } from '../api/conversationApi'
|
||||
@@ -9,8 +9,6 @@ import MessageInput from '../components/chat/MessageInput'
|
||||
import ModelSelector, { type ModelType } from '../components/chat/ModelSelector'
|
||||
import { useProjectStore } from '../stores/useProjectStore'
|
||||
|
||||
const { Title, Paragraph } = Typography
|
||||
|
||||
const AgentChatPage = () => {
|
||||
const { agentId } = useParams()
|
||||
const { currentProject } = useProjectStore()
|
||||
@@ -71,10 +69,8 @@ const AgentChatPage = () => {
|
||||
title: `与${agent.name}的对话`,
|
||||
})
|
||||
|
||||
if (response.success && response.data) {
|
||||
setConversation(response.data)
|
||||
setConversation(response.data.data || null)
|
||||
setMessages([])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create conversation:', err)
|
||||
message.error('创建对话失败')
|
||||
|
||||
@@ -1,225 +1,281 @@
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Progress,
|
||||
Alert,
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined,
|
||||
FolderOutlined,
|
||||
FileTextOutlined,
|
||||
UploadOutlined,
|
||||
DeleteOutlined,
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Tabs, Button, message } from 'antd';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useKnowledgeBaseStore } from '../stores/useKnowledgeBaseStore';
|
||||
import KnowledgeBaseList from '../components/knowledge/KnowledgeBaseList';
|
||||
import CreateKBDialog from '../components/knowledge/CreateKBDialog';
|
||||
import EditKBDialog from '../components/knowledge/EditKBDialog';
|
||||
import DocumentUpload from '../components/knowledge/DocumentUpload';
|
||||
import DocumentList from '../components/knowledge/DocumentList';
|
||||
import type { KnowledgeBase, Document } from '../api/knowledgeBaseApi';
|
||||
|
||||
const KnowledgePage = () => {
|
||||
// 模拟知识库数据
|
||||
const mockKnowledgeBases = [
|
||||
{
|
||||
id: '1',
|
||||
name: '心血管疾病研究文献库',
|
||||
fileCount: 15,
|
||||
totalSize: '45MB',
|
||||
createdAt: '2025-10-05',
|
||||
status: 'ready',
|
||||
},
|
||||
]
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
// 模拟文档数据
|
||||
const mockDocuments = [
|
||||
{
|
||||
id: '1',
|
||||
name: '高血压治疗指南2024.pdf',
|
||||
size: '3.2MB',
|
||||
uploadedAt: '2025-10-05 14:30',
|
||||
status: 'processed',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '心血管疾病流行病学研究.docx',
|
||||
size: '1.8MB',
|
||||
uploadedAt: '2025-10-05 15:20',
|
||||
status: 'processing',
|
||||
},
|
||||
]
|
||||
const KnowledgePage: React.FC = () => {
|
||||
const {
|
||||
knowledgeBases,
|
||||
currentKb,
|
||||
documents,
|
||||
loading,
|
||||
error,
|
||||
fetchKnowledgeBases,
|
||||
fetchKnowledgeBaseById,
|
||||
createKnowledgeBase,
|
||||
updateKnowledgeBase,
|
||||
deleteKnowledgeBase,
|
||||
fetchDocuments,
|
||||
deleteDocument,
|
||||
reprocessDocument,
|
||||
setCurrentKb,
|
||||
clearError,
|
||||
} = useKnowledgeBaseStore();
|
||||
|
||||
const kbColumns = [
|
||||
{
|
||||
title: '知识库名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text: string) => (
|
||||
<Space>
|
||||
<FolderOutlined style={{ color: '#1890ff' }} />
|
||||
<span>{text}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '文档数量',
|
||||
dataIndex: 'fileCount',
|
||||
key: 'fileCount',
|
||||
render: (count: number) => `${count} / 50`,
|
||||
},
|
||||
{
|
||||
title: '总大小',
|
||||
dataIndex: 'totalSize',
|
||||
key: 'totalSize',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'ready' ? 'green' : 'orange'}>
|
||||
{status === 'ready' ? '就绪' : '处理中'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: () => (
|
||||
<Space>
|
||||
<Button size="small" icon={<UploadOutlined />} disabled>
|
||||
上传
|
||||
</Button>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} disabled>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editingKb, setEditingKb] = useState<KnowledgeBase | null>(null);
|
||||
|
||||
const docColumns = [
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text: string) => (
|
||||
<Space>
|
||||
<FileTextOutlined />
|
||||
<span>{text}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '大小',
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
},
|
||||
{
|
||||
title: '上传时间',
|
||||
dataIndex: 'uploadedAt',
|
||||
key: 'uploadedAt',
|
||||
},
|
||||
{
|
||||
title: '处理状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => {
|
||||
if (status === 'processed') {
|
||||
return <Tag color="green">已完成</Tag>
|
||||
// 初始加载知识库列表
|
||||
useEffect(() => {
|
||||
fetchKnowledgeBases();
|
||||
}, []);
|
||||
|
||||
// 显示错误提示
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
message.error(error);
|
||||
clearError();
|
||||
}
|
||||
return (
|
||||
<Space>
|
||||
<Tag color="processing" icon={<SyncOutlined spin />}>
|
||||
处理中
|
||||
</Tag>
|
||||
<Progress percent={65} size="small" style={{ width: 100 }} />
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: () => (
|
||||
<Button size="small" danger icon={<DeleteOutlined />} disabled>
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [error]);
|
||||
|
||||
// 创建知识库
|
||||
const handleCreate = async (name: string, description?: string) => {
|
||||
await createKnowledgeBase(name, description);
|
||||
};
|
||||
|
||||
// 编辑知识库
|
||||
const handleEdit = (kb: KnowledgeBase) => {
|
||||
setEditingKb(kb);
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
// 更新知识库
|
||||
const handleUpdate = async (id: string, name: string, description?: string) => {
|
||||
await updateKnowledgeBase(id, name, description);
|
||||
};
|
||||
|
||||
// 删除知识库
|
||||
const handleDelete = async (kb: KnowledgeBase) => {
|
||||
try {
|
||||
await deleteKnowledgeBase(kb.id);
|
||||
message.success('知识库删除成功');
|
||||
|
||||
// 如果删除的是当前打开的知识库,返回列表
|
||||
if (currentKb?.id === kb.id) {
|
||||
setCurrentKb(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 选择知识库,查看详情
|
||||
const handleSelectKb = async (kb: KnowledgeBase) => {
|
||||
setCurrentKb(kb);
|
||||
await fetchKnowledgeBaseById(kb.id);
|
||||
};
|
||||
|
||||
// 返回知识库列表
|
||||
const handleBackToList = () => {
|
||||
setCurrentKb(null);
|
||||
};
|
||||
|
||||
// 上传成功后刷新文档列表
|
||||
const handleUploadSuccess = async () => {
|
||||
if (currentKb) {
|
||||
await fetchDocuments(currentKb.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除文档
|
||||
const handleDeleteDocument = async (doc: Document) => {
|
||||
try {
|
||||
await deleteDocument(doc.id);
|
||||
message.success('文档删除成功');
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 重新处理文档
|
||||
const handleReprocessDocument = async (doc: Document) => {
|
||||
try {
|
||||
await reprocessDocument(doc.id);
|
||||
message.success('已开始重新处理');
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 轮询文档状态(每5秒)
|
||||
useEffect(() => {
|
||||
if (!currentKb) return;
|
||||
|
||||
const hasProcessing = documents.some(doc =>
|
||||
['uploading', 'parsing', 'indexing'].includes(doc.status)
|
||||
);
|
||||
|
||||
if (!hasProcessing) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchDocuments(currentKb.id);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentKb, documents]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
{/* 提示信息 */}
|
||||
<Alert
|
||||
message="知识库功能说明"
|
||||
description="每个用户最多可创建3个知识库,每个知识库最多上传50个文件(支持PDF、DOCX格式)。在对话时可以@知识库,让AI基于您的文献进行回答。"
|
||||
type="info"
|
||||
showIcon
|
||||
closable
|
||||
<div style={{ padding: 24, height: '100%', overflow: 'auto' }}>
|
||||
{!currentKb ? (
|
||||
// 知识库列表视图
|
||||
<>
|
||||
<KnowledgeBaseList
|
||||
knowledgeBases={knowledgeBases}
|
||||
loading={loading}
|
||||
onCreateClick={() => setCreateDialogOpen(true)}
|
||||
onEditClick={handleEdit}
|
||||
onDeleteClick={handleDelete}
|
||||
onSelectClick={handleSelectKb}
|
||||
/>
|
||||
|
||||
<CreateKBDialog
|
||||
open={createDialogOpen}
|
||||
onCancel={() => setCreateDialogOpen(false)}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
|
||||
<EditKBDialog
|
||||
open={editDialogOpen}
|
||||
knowledgeBase={editingKb}
|
||||
onCancel={() => {
|
||||
setEditDialogOpen(false);
|
||||
setEditingKb(null);
|
||||
}}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// 知识库详情视图
|
||||
<div>
|
||||
{/* 返回按钮和标题 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={handleBackToList}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
message="功能开发中"
|
||||
description="知识库管理功能正在开发中,敬请期待..."
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<SyncOutlined spin />}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
{/* 知识库列表 */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FolderOutlined />
|
||||
<span>我的知识库 (1/3)</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled>
|
||||
创建知识库
|
||||
</Button>
|
||||
}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Table
|
||||
columns={kbColumns}
|
||||
dataSource={mockKnowledgeBases}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 文档列表 */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FileTextOutlined />
|
||||
<span>文档列表:心血管疾病研究文献库</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button icon={<UploadOutlined />} disabled>
|
||||
上传文件
|
||||
返回知识库列表
|
||||
</Button>
|
||||
}
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, marginBottom: 8 }}>{currentKb.name}</h2>
|
||||
<p style={{ margin: 0, color: '#8c8c8c' }}>
|
||||
{currentKb.description || '暂无描述'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleEdit(currentKb)}
|
||||
>
|
||||
<Table
|
||||
columns={docColumns}
|
||||
dataSource={mockDocuments}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
/>
|
||||
编辑知识库
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default KnowledgePage
|
||||
{/* 文档管理标签页 */}
|
||||
<Tabs defaultActiveKey="documents">
|
||||
<TabPane tab={`文档管理 (${documents.length})`} key="documents">
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<DocumentUpload
|
||||
kbId={currentKb.id}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
disabled={loading}
|
||||
maxDocuments={50}
|
||||
currentDocumentCount={documents.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card title="文档列表">
|
||||
<DocumentList
|
||||
documents={documents}
|
||||
loading={loading}
|
||||
onDelete={handleDeleteDocument}
|
||||
onReprocess={handleReprocessDocument}
|
||||
/>
|
||||
</Card>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="统计信息" key="stats">
|
||||
<Card>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
|
||||
<div style={{ textAlign: 'center', padding: 16, background: '#f0f5ff', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#1890ff' }}>
|
||||
{documents.length}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: '#595959' }}>总文档数</div>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', padding: 16, background: '#f6ffed', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#52c41a' }}>
|
||||
{documents.filter(d => d.status === 'completed').length}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: '#595959' }}>已就绪</div>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', padding: 16, background: '#fffbe6', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#faad14' }}>
|
||||
{documents.filter(d => ['uploading', 'parsing', 'indexing'].includes(d.status)).length}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: '#595959' }}>处理中</div>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', padding: 16, background: '#fff1f0', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#ff4d4f' }}>
|
||||
{documents.filter(d => d.status === 'error').length}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: '#595959' }}>失败</div>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', padding: 16, background: '#f0f0f0', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 24, fontWeight: 'bold', color: '#262626' }}>
|
||||
{documents.reduce((sum, d) => sum + (d.tokensCount || 0), 0).toLocaleString()}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: '#595959' }}>总Token数</div>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', padding: 16, background: '#f0f0f0', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 24, fontWeight: 'bold', color: '#262626' }}>
|
||||
{documents.reduce((sum, d) => sum + (d.segmentsCount || 0), 0).toLocaleString()}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: '#595959' }}>总段落数</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
|
||||
<EditKBDialog
|
||||
open={editDialogOpen}
|
||||
knowledgeBase={currentKb}
|
||||
onCancel={() => setEditDialogOpen(false)}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KnowledgePage;
|
||||
|
||||
217
frontend/src/stores/useKnowledgeBaseStore.ts
Normal file
217
frontend/src/stores/useKnowledgeBaseStore.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { create } from 'zustand';
|
||||
import type { KnowledgeBase, Document } from '../api/knowledgeBaseApi';
|
||||
import { knowledgeBaseApi, documentApi } from '../api/knowledgeBaseApi';
|
||||
|
||||
interface KnowledgeBaseState {
|
||||
// 知识库列表
|
||||
knowledgeBases: KnowledgeBase[];
|
||||
currentKb: KnowledgeBase | null;
|
||||
|
||||
// 文档列表
|
||||
documents: Document[];
|
||||
|
||||
// 加载状态
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// 操作方法
|
||||
fetchKnowledgeBases: () => Promise<void>;
|
||||
fetchKnowledgeBaseById: (id: string) => Promise<void>;
|
||||
createKnowledgeBase: (name: string, description?: string) => Promise<KnowledgeBase>;
|
||||
updateKnowledgeBase: (id: string, name?: string, description?: string) => Promise<void>;
|
||||
deleteKnowledgeBase: (id: string) => Promise<void>;
|
||||
|
||||
// 文档操作
|
||||
fetchDocuments: (kbId: string) => Promise<void>;
|
||||
uploadDocument: (kbId: string, file: File, onProgress?: (progress: number) => void) => Promise<Document>;
|
||||
deleteDocument: (id: string) => Promise<void>;
|
||||
reprocessDocument: (id: string) => Promise<void>;
|
||||
|
||||
// 辅助方法
|
||||
setCurrentKb: (kb: KnowledgeBase | null) => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useKnowledgeBaseStore = create<KnowledgeBaseState>((set, get) => ({
|
||||
knowledgeBases: [],
|
||||
currentKb: null,
|
||||
documents: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
// 获取知识库列表
|
||||
fetchKnowledgeBases: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const knowledgeBases = await knowledgeBaseApi.getList();
|
||||
set({ knowledgeBases, loading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.response?.data?.message || '获取知识库列表失败',
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 获取知识库详情
|
||||
fetchKnowledgeBaseById: async (id: string) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const kb = await knowledgeBaseApi.getById(id);
|
||||
set({ currentKb: kb, loading: false });
|
||||
|
||||
// 同时获取文档列表
|
||||
if (kb.id) {
|
||||
await get().fetchDocuments(kb.id);
|
||||
}
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.response?.data?.message || '获取知识库详情失败',
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 创建知识库
|
||||
createKnowledgeBase: async (name: string, description?: string) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const kb = await knowledgeBaseApi.create({ name, description });
|
||||
|
||||
// 更新列表
|
||||
const knowledgeBases = [...get().knowledgeBases, kb];
|
||||
set({ knowledgeBases, loading: false });
|
||||
|
||||
return kb;
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.message || '创建知识库失败';
|
||||
set({ error: errorMsg, loading: false });
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
},
|
||||
|
||||
// 更新知识库
|
||||
updateKnowledgeBase: async (id: string, name?: string, description?: string) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const updatedKb = await knowledgeBaseApi.update(id, { name, description });
|
||||
|
||||
// 更新列表
|
||||
const knowledgeBases = get().knowledgeBases.map(kb =>
|
||||
kb.id === id ? updatedKb : kb
|
||||
);
|
||||
|
||||
// 更新当前知识库
|
||||
const currentKb = get().currentKb?.id === id ? updatedKb : get().currentKb;
|
||||
|
||||
set({ knowledgeBases, currentKb, loading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.response?.data?.message || '更新知识库失败',
|
||||
loading: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 删除知识库
|
||||
deleteKnowledgeBase: async (id: string) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await knowledgeBaseApi.delete(id);
|
||||
|
||||
// 更新列表
|
||||
const knowledgeBases = get().knowledgeBases.filter(kb => kb.id !== id);
|
||||
|
||||
// 清除当前知识库(如果是被删除的)
|
||||
const currentKb = get().currentKb?.id === id ? null : get().currentKb;
|
||||
|
||||
set({ knowledgeBases, currentKb, loading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.response?.data?.message || '删除知识库失败',
|
||||
loading: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 获取文档列表
|
||||
fetchDocuments: async (kbId: string) => {
|
||||
try {
|
||||
const documents = await documentApi.getList(kbId);
|
||||
set({ documents });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.response?.data?.message || '获取文档列表失败'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 上传文档
|
||||
uploadDocument: async (kbId: string, file: File, onProgress?: (progress: number) => void) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const document = await documentApi.upload(kbId, file, onProgress);
|
||||
|
||||
// 更新文档列表
|
||||
const documents = [document, ...get().documents];
|
||||
set({ documents, loading: false });
|
||||
|
||||
return document;
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.message || '上传文档失败';
|
||||
set({ error: errorMsg, loading: false });
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
},
|
||||
|
||||
// 删除文档
|
||||
deleteDocument: async (id: string) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await documentApi.delete(id);
|
||||
|
||||
// 更新文档列表
|
||||
const documents = get().documents.filter(doc => doc.id !== id);
|
||||
set({ documents, loading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.response?.data?.message || '删除文档失败',
|
||||
loading: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 重新处理文档
|
||||
reprocessDocument: async (id: string) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await documentApi.reprocess(id);
|
||||
|
||||
// 更新文档状态
|
||||
const documents = get().documents.map(doc =>
|
||||
doc.id === id ? { ...doc, status: 'parsing' as const, progress: 0 } : doc
|
||||
);
|
||||
set({ documents, loading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.response?.data?.message || '重新处理文档失败',
|
||||
loading: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 设置当前知识库
|
||||
setCurrentKb: (kb: KnowledgeBase | null) => {
|
||||
set({ currentKb: kb });
|
||||
},
|
||||
|
||||
// 清除错误
|
||||
clearError: () => {
|
||||
set({ error: null });
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user