temp:临时备份,完成一半知识库管理模块
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Form, Input, Button, Card, message, Spin } from 'antd';
|
||||
import { SaveOutlined } from '@ant-design/icons';
|
||||
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
||||
import { updateDatasetName } from '~/api/dify-dataset/api/datasetApi';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface DatasetSettingsProps {
|
||||
dataset: Dataset | null;
|
||||
onDatasetUpdated: (dataset: Dataset) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识库设置组件
|
||||
* 用于修改知识库名称和描述
|
||||
*/
|
||||
export default function DatasetSettings({
|
||||
dataset,
|
||||
onDatasetUpdated,
|
||||
}: DatasetSettingsProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// 初始化表单数据
|
||||
useEffect(() => {
|
||||
if (dataset) {
|
||||
form.setFieldsValue({
|
||||
name: dataset.name,
|
||||
description: dataset.description || '',
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [dataset, form]);
|
||||
|
||||
/**
|
||||
* 处理表单值变化
|
||||
*/
|
||||
const handleValuesChange = () => {
|
||||
const values = form.getFieldsValue();
|
||||
const changed =
|
||||
values.name !== dataset?.name ||
|
||||
values.description !== (dataset?.description || '');
|
||||
setHasChanges(changed);
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存设置
|
||||
*/
|
||||
const handleSave = async () => {
|
||||
if (!dataset) {
|
||||
message.error('知识库不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
// 目前只支持修改名称
|
||||
const updatedDataset = await updateDatasetName(dataset.id, values.name);
|
||||
|
||||
message.success('保存成功');
|
||||
onDatasetUpdated(updatedDataset);
|
||||
setHasChanges(false);
|
||||
} catch (err: any) {
|
||||
console.error('保存设置失败:', err);
|
||||
message.error(err.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置表单
|
||||
*/
|
||||
const handleReset = () => {
|
||||
if (dataset) {
|
||||
form.setFieldsValue({
|
||||
name: dataset.name,
|
||||
description: dataset.description || '',
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!dataset) {
|
||||
return (
|
||||
<div className="settings-loading">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dataset-settings-page">
|
||||
{/* 页面标题 */}
|
||||
<div className="page-header">
|
||||
<h1>设置</h1>
|
||||
<p className="page-description">
|
||||
管理知识库的基本信息
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 设置表单 */}
|
||||
<Card className="settings-card">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={handleValuesChange}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="知识库名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入知识库名称' },
|
||||
{ max: 100, message: '名称不能超过100个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入知识库名称" maxLength={100} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="知识库描述"
|
||||
extra="描述知识库的用途和内容(仅展示,暂不支持修改)"
|
||||
>
|
||||
<TextArea
|
||||
placeholder="请输入知识库描述"
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
showCount
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 只读信息 */}
|
||||
<div className="readonly-info">
|
||||
<div className="info-item">
|
||||
<span className="info-label">索引方式:</span>
|
||||
<span className="info-value">
|
||||
{dataset.indexing_technique === 'high_quality' ? '高质量' : '经济'}
|
||||
</span>
|
||||
</div>
|
||||
{/* <div className="info-item">
|
||||
<span className="info-label">Embedding 模型:</span>
|
||||
<span className="info-value">
|
||||
{dataset.embedding_model || '默认模型'}
|
||||
</span>
|
||||
</div> */}
|
||||
<div className="info-item">
|
||||
<span className="info-label">文档数量:</span>
|
||||
<span className="info-value">{dataset.document_count}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">总字符数:</span>
|
||||
<span className="info-value">{dataset.word_count?.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">创建时间:</span>
|
||||
<span className="info-value">
|
||||
{new Date(dataset.created_at * 1000).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="form-actions">
|
||||
<Button onClick={handleReset} disabled={!hasChanges}>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
InputNumber,
|
||||
Checkbox,
|
||||
Select,
|
||||
Card,
|
||||
Empty,
|
||||
Spin,
|
||||
message,
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { Document } from '~/api/dify-dataset/type/documentTypes';
|
||||
import type { Segment } from '~/api/dify-dataset/type';
|
||||
import { fetchSegments } from '~/api/dify-dataset/api/segmentApi';
|
||||
import { updateDocumentWithSettings } from '~/api/dify-dataset/api/documentApi';
|
||||
|
||||
interface DocumentDetailProps {
|
||||
datasetId: string;
|
||||
document: Document | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分段设置配置
|
||||
* 注意:Dify API 支持的参数有限
|
||||
* - separator: ✅ 支持
|
||||
* - maxTokens: ✅ 支持
|
||||
* - removeExtraSpaces: ✅ 支持
|
||||
* - removeUrlsEmails: ✅ 支持
|
||||
* - useQASegment: ⚠️ 需要 doc_form: "qa_model"
|
||||
*/
|
||||
interface SegmentationSettings {
|
||||
separator: string;
|
||||
maxTokens: number;
|
||||
removeExtraSpaces: boolean;
|
||||
removeUrlsEmails: boolean;
|
||||
useQASegment: boolean;
|
||||
qaLanguage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认分段设置
|
||||
*/
|
||||
const DEFAULT_SETTINGS: SegmentationSettings = {
|
||||
separator: '\\n\\n',
|
||||
maxTokens: 500,
|
||||
removeExtraSpaces: true,
|
||||
removeUrlsEmails: false,
|
||||
useQASegment: false,
|
||||
qaLanguage: 'Chinese',
|
||||
};
|
||||
|
||||
/**
|
||||
* 文档详情组件
|
||||
* 显示文档的分段设置,支持修改并重新处理
|
||||
*/
|
||||
export default function DocumentDetail({
|
||||
datasetId,
|
||||
document,
|
||||
}: DocumentDetailProps) {
|
||||
// 分段设置状态
|
||||
const [settings, setSettings] = useState<SegmentationSettings>(DEFAULT_SETTINGS);
|
||||
|
||||
// 预览状态
|
||||
const [previewSegments, setPreviewSegments] = useState<Segment[]>([]);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
// 保存状态
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 当文档变化时重置设置
|
||||
useEffect(() => {
|
||||
if (document) {
|
||||
// 可以从文档中读取已有的设置,这里使用默认值
|
||||
setSettings(DEFAULT_SETTINGS);
|
||||
setPreviewSegments([]);
|
||||
setShowPreview(false);
|
||||
}
|
||||
}, [document?.id]);
|
||||
|
||||
/**
|
||||
* 更新设置
|
||||
*/
|
||||
const updateSettings = (key: keyof SegmentationSettings, value: any) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置设置
|
||||
*/
|
||||
const handleReset = () => {
|
||||
setSettings(DEFAULT_SETTINGS);
|
||||
setPreviewSegments([]);
|
||||
setShowPreview(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 预览分段
|
||||
*/
|
||||
const handlePreview = async () => {
|
||||
if (!document) return;
|
||||
|
||||
setPreviewLoading(true);
|
||||
setShowPreview(true);
|
||||
try {
|
||||
// 获取当前文档的分段作为预览
|
||||
const response = await fetchSegments(datasetId, document.id, 1, 50);
|
||||
setPreviewSegments(response.data || []);
|
||||
if (response.data?.length === 0) {
|
||||
message.info('该文档暂无分段数据');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('预览分段失败:', err);
|
||||
message.error(err.message || '预览失败');
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存并处理
|
||||
*/
|
||||
const handleSaveAndProcess = async () => {
|
||||
if (!document) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateDocumentWithSettings(datasetId, document.id, {
|
||||
indexing_technique: 'high_quality',
|
||||
process_rule: {
|
||||
mode: 'custom',
|
||||
rules: {
|
||||
pre_processing_rules: [
|
||||
{ id: 'remove_extra_spaces', enabled: settings.removeExtraSpaces },
|
||||
{ id: 'remove_urls_emails', enabled: settings.removeUrlsEmails },
|
||||
],
|
||||
segmentation: {
|
||||
separator: settings.separator.replace(/\\n/g, '\n'),
|
||||
max_tokens: settings.maxTokens,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
message.success('设置已保存,文档正在重新处理...');
|
||||
} catch (err: any) {
|
||||
console.error('保存设置失败:', err);
|
||||
message.error(err.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!document) {
|
||||
return (
|
||||
<div className="document-detail-empty">
|
||||
<Empty description="请选择一个文档" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="document-detail-page">
|
||||
<div className="document-detail-content">
|
||||
{/* 左侧设置区域 */}
|
||||
<div className="settings-panel">
|
||||
{/* 分段设置 */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">分段设置</h3>
|
||||
|
||||
{/* 分块模式 */}
|
||||
<div className="setting-item mode-selector">
|
||||
<div className="mode-option active">
|
||||
<div className="mode-icon">
|
||||
<i className="ri-text-spacing"></i>
|
||||
</div>
|
||||
<div className="mode-info">
|
||||
<span className="mode-name">通用</span>
|
||||
<span className="mode-desc">通用文本分块模式,检索和召回的块是相同的</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分段标识符 */}
|
||||
<div className="setting-item">
|
||||
<label className="setting-label">
|
||||
分段标识符
|
||||
<Tooltip title="系统会在遇到指定分隔符时自动分段,默认值为 \n\n(按段落分段)">
|
||||
<QuestionCircleOutlined className="help-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Input
|
||||
value={settings.separator}
|
||||
onChange={(e) => updateSettings('separator', e.target.value)}
|
||||
placeholder="\n\n"
|
||||
className="setting-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分段最大长度 */}
|
||||
<div className="setting-item">
|
||||
<label className="setting-label">
|
||||
分段最大长度
|
||||
<Tooltip title="指定每个分段允许的最大字符数,超过此限制系统会强制分段">
|
||||
<QuestionCircleOutlined className="help-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<div className="setting-input-with-suffix">
|
||||
<InputNumber
|
||||
value={settings.maxTokens}
|
||||
onChange={(value) => updateSettings('maxTokens', value || 500)}
|
||||
min={100}
|
||||
max={4000}
|
||||
className="setting-input-number"
|
||||
/>
|
||||
<span className="input-suffix">characters</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 文本预处理规则 */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">文本预处理规则</h3>
|
||||
|
||||
<div className="checkbox-group">
|
||||
<Checkbox
|
||||
checked={settings.removeExtraSpaces}
|
||||
onChange={(e) => updateSettings('removeExtraSpaces', e.target.checked)}
|
||||
>
|
||||
替换掉连续的空格、换行符和制表符
|
||||
</Checkbox>
|
||||
|
||||
<Checkbox
|
||||
checked={settings.removeUrlsEmails}
|
||||
onChange={(e) => updateSettings('removeUrlsEmails', e.target.checked)}
|
||||
>
|
||||
删除所有 URL 和电子邮件地址
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Q&A 分段 */}
|
||||
<div className="settings-section">
|
||||
<div className="qa-segment-row">
|
||||
<Checkbox
|
||||
checked={settings.useQASegment}
|
||||
onChange={(e) => updateSettings('useQASegment', e.target.checked)}
|
||||
>
|
||||
使用 Q&A 分段,语言
|
||||
</Checkbox>
|
||||
<Select
|
||||
value={settings.qaLanguage}
|
||||
onChange={(value) => updateSettings('qaLanguage', value)}
|
||||
disabled={!settings.useQASegment}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'Chinese', label: 'Chinese' },
|
||||
{ value: 'English', label: 'English' },
|
||||
{ value: 'Japanese', label: 'Japanese' },
|
||||
{ value: 'Korean', label: 'Korean' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="settings-actions">
|
||||
<Button
|
||||
icon={<EyeOutlined />}
|
||||
onClick={handlePreview}
|
||||
loading={previewLoading}
|
||||
>
|
||||
预览块
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 保存并处理按钮 */}
|
||||
<div className="save-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSaveAndProcess}
|
||||
loading={saving}
|
||||
block
|
||||
>
|
||||
保存并处理
|
||||
</Button>
|
||||
<Button block style={{ marginTop: 8 }}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧预览区域 */}
|
||||
<div className="preview-panel">
|
||||
<Card
|
||||
title={
|
||||
<div className="preview-header">
|
||||
<span>预览</span>
|
||||
<Select
|
||||
value={document.name}
|
||||
style={{ width: 200 }}
|
||||
disabled
|
||||
options={[{ value: document.name, label: document.name }]}
|
||||
/>
|
||||
<span className="segment-count">
|
||||
{showPreview ? `${previewSegments.length} 段块` : '0 段块'}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
className="preview-card"
|
||||
>
|
||||
{previewLoading ? (
|
||||
<div className="preview-loading">
|
||||
<Spin size="large" />
|
||||
<div className="loading-text">加载中...</div>
|
||||
</div>
|
||||
) : !showPreview ? (
|
||||
<div className="preview-empty">
|
||||
<div className="empty-icon">
|
||||
<EyeOutlined />
|
||||
</div>
|
||||
<p>点击左侧的"预览块"按钮来预览</p>
|
||||
</div>
|
||||
) : previewSegments.length === 0 ? (
|
||||
<Empty description="暂无分段数据" />
|
||||
) : (
|
||||
<div className="preview-segments">
|
||||
{previewSegments.map((segment, index) => (
|
||||
<div key={segment.id} className="segment-item">
|
||||
<div className="segment-header">
|
||||
<span className="segment-index">#{index + 1}</span>
|
||||
<span className="segment-chars">
|
||||
{segment.word_count} 字符
|
||||
</span>
|
||||
</div>
|
||||
<div className="segment-content">
|
||||
{segment.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
PauseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Document, IndexingStatus } from '~/api/dify-dataset';
|
||||
import { deleteDocument, toggleDocumentStatus, uploadDocument } from '~/api/dify-dataset';
|
||||
import type { Document, IndexingStatus } from '~/api/dify-dataset/type/documentTypes';
|
||||
import { deleteDocument, toggleDocumentStatus, uploadDocument } from '~/api/dify-dataset/api/documentApi';
|
||||
import '../../styles/components/dify-dataset-manager/index.css';
|
||||
|
||||
interface DocumentListProps {
|
||||
@@ -43,6 +43,7 @@ interface DocumentListProps {
|
||||
onDocumentDeleted: (documentId: string) => void;
|
||||
onDocumentStatusChanged: (documentId: string, enabled: boolean) => void;
|
||||
onRefresh: () => void;
|
||||
onViewDocument?: (document: Document) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +51,6 @@ interface DocumentListProps {
|
||||
*/
|
||||
export default function DocumentList({
|
||||
datasetId,
|
||||
datasetName,
|
||||
documents,
|
||||
loading,
|
||||
total,
|
||||
@@ -60,6 +60,7 @@ export default function DocumentList({
|
||||
onDocumentDeleted,
|
||||
onDocumentStatusChanged,
|
||||
onRefresh,
|
||||
onViewDocument,
|
||||
}: DocumentListProps) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -238,15 +239,12 @@ export default function DocumentList({
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="查看详情">
|
||||
<Tooltip title="查看分段">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => {
|
||||
// TODO: 查看文档详情/分段
|
||||
message.info('功能开发中');
|
||||
}}
|
||||
onClick={() => onViewDocument?.(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
@@ -273,11 +271,16 @@ export default function DocumentList({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="dataset-content">
|
||||
{/* 头部区域 */}
|
||||
<div className="dataset-header">
|
||||
<h1>{datasetName || '知识库文档'}</h1>
|
||||
<div className="dataset-header-actions">
|
||||
<div className="document-list-page">
|
||||
{/* 页面头部 */}
|
||||
<div className="page-header">
|
||||
<div className="header-left">
|
||||
<h1>文档</h1>
|
||||
{/* <p className="page-description">
|
||||
知识库的所有文件都在这里显示,整个知识库都可以被接到 Dify 引用或通过 Chat 插件进行索引。
|
||||
</p> */}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<Tooltip title="刷新">
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
@@ -297,36 +300,34 @@ export default function DocumentList({
|
||||
loading={uploading}
|
||||
disabled={!datasetId}
|
||||
>
|
||||
上传文档
|
||||
添加文件
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="document-list-toolbar">
|
||||
{/* 搜索栏 */}
|
||||
<div className="document-search-bar">
|
||||
<Input
|
||||
className="document-list-search"
|
||||
placeholder="搜索文档..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文档表格 - 固定表头和分页 */}
|
||||
<div className="document-table-container">
|
||||
{/* 文档表格 */}
|
||||
<div className="document-table-wrapper">
|
||||
{loading && documents.length === 0 ? (
|
||||
<div className="dataset-loading">
|
||||
<div className="loading-state">
|
||||
<Spin size="large" />
|
||||
<span className="text-gray-500">加载中...</span>
|
||||
<div className="loading-text">加载中...</div>
|
||||
</div>
|
||||
) : filteredDocuments.length === 0 ? (
|
||||
<div className="dataset-empty">
|
||||
<Empty
|
||||
description={searchValue ? '未找到匹配的文档' : '暂无文档'}
|
||||
>
|
||||
<div className="empty-state">
|
||||
<Empty description={searchValue ? '未找到匹配的文档' : '暂无文档'}>
|
||||
{!searchValue && (
|
||||
<Upload
|
||||
beforeUpload={handleUpload}
|
||||
@@ -354,7 +355,7 @@ export default function DocumentList({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 固定底部分页器 */}
|
||||
{/* 底部分页器 */}
|
||||
{filteredDocuments.length > 0 && (
|
||||
<div className="document-pagination">
|
||||
<span className="pagination-total">共 {total} 条</span>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { message, Spin } from 'antd';
|
||||
import DatasetLayout, { type MenuTab } from './layout';
|
||||
import DocumentList from './document-list';
|
||||
import type { Dataset, Document } from '~/api/dify-dataset';
|
||||
import { fetchDatasets, fetchDocuments } from '~/api/dify-dataset';
|
||||
import DocumentDetail from './document-detail';
|
||||
import RetrieveTest from './retrieve-test';
|
||||
import DatasetSettings from './dataset-settings';
|
||||
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
||||
import type { Document } from '~/api/dify-dataset/type/documentTypes';
|
||||
import { fetchDatasets } from '~/api/dify-dataset/api/datasetApi';
|
||||
import { fetchDocuments } from '~/api/dify-dataset/api/documentApi';
|
||||
import '../../styles/components/dify-dataset-manager/index.css';
|
||||
|
||||
/**
|
||||
* 知识库管理主组件
|
||||
* 简化版 - 假设只有一个知识库,直接显示文档列表
|
||||
* 带左侧菜单栏的完整布局
|
||||
*/
|
||||
export default function DatasetManager() {
|
||||
// 知识库状态
|
||||
@@ -25,6 +31,12 @@ export default function DatasetManager() {
|
||||
const [inited, setInited] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 菜单状态
|
||||
const [activeTab, setActiveTab] = useState<MenuTab>('documents');
|
||||
|
||||
// 选中的文档(用于查看文档详情)
|
||||
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
|
||||
|
||||
/**
|
||||
* 加载知识库(获取第一个知识库)
|
||||
*/
|
||||
@@ -123,6 +135,39 @@ export default function DatasetManager() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 查看文档详情(分段管理)
|
||||
*/
|
||||
const handleViewDocument = (doc: Document) => {
|
||||
console.log('[DatasetManager] 查看文档详情:', doc);
|
||||
setSelectedDocument(doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* 返回文档列表
|
||||
*/
|
||||
const handleBackToDocuments = () => {
|
||||
setSelectedDocument(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理菜单切换
|
||||
*/
|
||||
const handleTabChange = (tab: MenuTab) => {
|
||||
setActiveTab(tab);
|
||||
// 切换菜单时清除选中的文档
|
||||
if (tab !== 'documents') {
|
||||
setSelectedDocument(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理知识库更新
|
||||
*/
|
||||
const handleDatasetUpdated = (updatedDataset: Dataset) => {
|
||||
setDataset(updatedDataset);
|
||||
};
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
loadDataset();
|
||||
@@ -132,11 +177,9 @@ export default function DatasetManager() {
|
||||
if (!inited || loadingDataset) {
|
||||
return (
|
||||
<div className="dataset-manager-wrapper">
|
||||
<div className="dataset-manager-card">
|
||||
<div className="dataset-loading-state">
|
||||
<Spin size="large" />
|
||||
<span className="loading-text">正在加载知识库...</span>
|
||||
</div>
|
||||
<div className="dataset-loading-state">
|
||||
<Spin size="large" />
|
||||
<span className="loading-text">正在加载知识库...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -146,20 +189,32 @@ export default function DatasetManager() {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="dataset-manager-wrapper">
|
||||
<div className="dataset-manager-card">
|
||||
<div className="dataset-error-state">
|
||||
<i className="ri-error-warning-line error-icon"></i>
|
||||
<h3>加载失败</h3>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="dataset-error-state">
|
||||
<i className="ri-error-warning-line error-icon"></i>
|
||||
<h3>加载失败</h3>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dataset-manager-wrapper">
|
||||
<div className="dataset-manager-card">
|
||||
/**
|
||||
* 渲染右侧内容区
|
||||
*/
|
||||
const renderContent = () => {
|
||||
// 文档菜单
|
||||
if (activeTab === 'documents') {
|
||||
// 如果选中了文档,显示文档详情
|
||||
if (selectedDocument) {
|
||||
return (
|
||||
<DocumentDetail
|
||||
datasetId={dataset?.id || ''}
|
||||
document={selectedDocument}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// 否则显示文档列表
|
||||
return (
|
||||
<DocumentList
|
||||
datasetId={dataset?.id || ''}
|
||||
datasetName={dataset?.name || ''}
|
||||
@@ -172,8 +227,40 @@ export default function DatasetManager() {
|
||||
onDocumentDeleted={handleDocumentDeleted}
|
||||
onDocumentStatusChanged={handleDocumentStatusChanged}
|
||||
onRefresh={handleRefresh}
|
||||
onViewDocument={handleViewDocument}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 召回测试菜单
|
||||
if (activeTab === 'retrieve') {
|
||||
return <RetrieveTest datasetId={dataset?.id || ''} />;
|
||||
}
|
||||
|
||||
// 设置菜单
|
||||
if (activeTab === 'settings') {
|
||||
return (
|
||||
<DatasetSettings
|
||||
dataset={dataset}
|
||||
onDatasetUpdated={handleDatasetUpdated}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dataset-manager-wrapper">
|
||||
<DatasetLayout
|
||||
dataset={dataset}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
showBackButton={activeTab === 'documents' && !!selectedDocument}
|
||||
onBack={handleBackToDocuments}
|
||||
>
|
||||
{renderContent()}
|
||||
</DatasetLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
ArrowLeftOutlined,
|
||||
DatabaseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
||||
|
||||
/**
|
||||
* 菜单项类型
|
||||
*/
|
||||
export type MenuTab = 'documents' | 'retrieve' | 'settings';
|
||||
|
||||
interface DatasetLayoutProps {
|
||||
/** 知识库信息 */
|
||||
dataset: Dataset | null;
|
||||
/** 当前激活的菜单 */
|
||||
activeTab: MenuTab;
|
||||
/** 菜单切换回调 */
|
||||
onTabChange: (tab: MenuTab) => void;
|
||||
/** 是否显示返回按钮(在文档详情页时显示) */
|
||||
showBackButton?: boolean;
|
||||
/** 返回按钮点击回调 */
|
||||
onBack?: () => void;
|
||||
/** 子组件 */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识库布局组件
|
||||
* 包含左侧菜单栏和右侧内容区
|
||||
*/
|
||||
export default function DatasetLayout({
|
||||
dataset,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
showBackButton = false,
|
||||
onBack,
|
||||
children,
|
||||
}: DatasetLayoutProps) {
|
||||
const menuItems: { key: MenuTab; icon: ReactNode; label: string }[] = [
|
||||
{ key: 'documents', icon: <FileTextOutlined />, label: '文档' },
|
||||
{ key: 'retrieve', icon: <SearchOutlined />, label: '召回测试' },
|
||||
{ key: 'settings', icon: <SettingOutlined />, label: '设置' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="dataset-layout">
|
||||
{/* 左侧侧边栏 */}
|
||||
<aside className="dataset-sidebar">
|
||||
{/* 返回按钮 */}
|
||||
{showBackButton && onBack && (
|
||||
<div className="sidebar-back">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onBack}
|
||||
className="back-btn"
|
||||
>
|
||||
返回文档列表
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 知识库信息 */}
|
||||
<div className="sidebar-header">
|
||||
<div className="dataset-icon">
|
||||
<DatabaseOutlined />
|
||||
</div>
|
||||
<div className="dataset-info">
|
||||
<Tooltip title={dataset?.name} placement="right">
|
||||
<h2 className="dataset-name">{dataset?.name || '知识库'}</h2>
|
||||
</Tooltip>
|
||||
<span className="dataset-type">本地文档</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="sidebar-stats">
|
||||
<span className="stat-item">
|
||||
{dataset?.document_count || 0} 个文档
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<nav className="sidebar-menu">
|
||||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={`menu-item ${activeTab === item.key ? 'active' : ''}`}
|
||||
onClick={() => onTabChange(item.key)}
|
||||
>
|
||||
<span className="menu-icon">{item.icon}</span>
|
||||
<span className="menu-label">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* 右侧内容区 */}
|
||||
<main className="dataset-main">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
Card,
|
||||
Select,
|
||||
Slider,
|
||||
Table,
|
||||
Tag,
|
||||
Empty,
|
||||
Spin,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { FileSearchOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { RetrieveRecord } from '~/api/dify-dataset/type';
|
||||
import { retrieveDataset } from '~/api/dify-dataset/api/segmentApi';
|
||||
|
||||
interface RetrieveTestProps {
|
||||
datasetId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 召回测试组件
|
||||
* 用于测试知识库的检索效果
|
||||
*/
|
||||
export default function RetrieveTest({ datasetId }: RetrieveTestProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [retrieveResults, setRetrieveResults] = useState<RetrieveRecord[]>([]);
|
||||
const [retrieving, setRetrieving] = useState(false);
|
||||
const [searchMethod, setSearchMethod] = useState<string>('hybrid_search');
|
||||
const [topK, setTopK] = useState<number>(5);
|
||||
|
||||
/**
|
||||
* 执行检索
|
||||
*/
|
||||
const handleRetrieve = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
message.warning('请输入检索关键词');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!datasetId) {
|
||||
message.warning('知识库ID不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
setRetrieving(true);
|
||||
try {
|
||||
const response = await retrieveDataset(datasetId, searchQuery, {
|
||||
search_method: searchMethod as any,
|
||||
top_k: topK,
|
||||
});
|
||||
setRetrieveResults(response.records || []);
|
||||
if (response.records?.length === 0) {
|
||||
message.info('未找到匹配的结果');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('检索失败:', err);
|
||||
message.error(err.message || '检索失败');
|
||||
} finally {
|
||||
setRetrieving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 检索结果列定义
|
||||
const columns: ColumnsType<RetrieveRecord> = [
|
||||
{
|
||||
title: '相关度',
|
||||
dataIndex: 'score',
|
||||
key: 'score',
|
||||
width: 100,
|
||||
render: (score: number) => (
|
||||
<Tag color={score > 0.8 ? 'green' : score > 0.5 ? 'orange' : 'default'}>
|
||||
{(score * 100).toFixed(1)}%
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
key: 'content',
|
||||
render: (_, record) => (
|
||||
<div className="retrieve-result-content">
|
||||
<div className="content-text">
|
||||
{record.segment.content.length > 300
|
||||
? record.segment.content.substring(0, 300) + '...'
|
||||
: record.segment.content}
|
||||
</div>
|
||||
{record.segment.answer && (
|
||||
<div className="answer-text">
|
||||
<strong>答案:</strong>
|
||||
{record.segment.answer.length > 150
|
||||
? record.segment.answer.substring(0, 150) + '...'
|
||||
: record.segment.answer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '字数',
|
||||
key: 'word_count',
|
||||
width: 80,
|
||||
render: (_, record) => record.segment.word_count,
|
||||
},
|
||||
{
|
||||
title: '命中次数',
|
||||
key: 'hit_count',
|
||||
width: 100,
|
||||
render: (_, record) => record.segment.hit_count,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="retrieve-test-page">
|
||||
{/* 页面标题 */}
|
||||
<div className="page-header">
|
||||
<h1>召回测试</h1>
|
||||
<p className="page-description">
|
||||
输入查询内容,测试知识库的检索效果
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 检索设置 */}
|
||||
<Card className="retrieve-settings" size="small">
|
||||
<div className="search-row">
|
||||
<Input
|
||||
placeholder="输入检索关键词..."
|
||||
prefix={<FileSearchOutlined />}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onPressEnter={handleRetrieve}
|
||||
className="search-input"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleRetrieve}
|
||||
loading={retrieving}
|
||||
>
|
||||
检索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="options-row">
|
||||
<div className="option-item">
|
||||
<span className="option-label">检索方式:</span>
|
||||
<Select
|
||||
value={searchMethod}
|
||||
onChange={setSearchMethod}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'keyword_search', label: '关键词搜索' },
|
||||
{ value: 'semantic_search', label: '语义搜索' },
|
||||
{ value: 'full_text_search', label: '全文搜索' },
|
||||
{ value: 'hybrid_search', label: '混合搜索' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="option-item">
|
||||
<span className="option-label">返回数量 (Top K):</span>
|
||||
<Slider
|
||||
value={topK}
|
||||
onChange={setTopK}
|
||||
min={1}
|
||||
max={20}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<span className="option-value">{topK}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 检索结果 */}
|
||||
<div className="retrieve-results">
|
||||
{retrieving ? (
|
||||
<div className="loading-state">
|
||||
<Spin size="large" />
|
||||
<div className="loading-text">检索中...</div>
|
||||
</div>
|
||||
) : retrieveResults.length === 0 ? (
|
||||
<Empty
|
||||
description="请输入关键词进行检索"
|
||||
className="empty-state"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="results-header">
|
||||
<span>找到 {retrieveResults.length} 条结果</span>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={retrieveResults}
|
||||
rowKey={(record) => record.segment.id}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user