imetting/frontend/src/pages/KnowledgeBasePage.jsx

877 lines
31 KiB
React
Raw Normal View History

import React, { useState, useEffect } from 'react';
import { Database, ChevronLeft, ChevronRight, Plus, Calendar, Trash2, Edit, FileText, Image, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import ContentViewer from '../components/ContentViewer';
import TagDisplay from '../components/TagDisplay';
import Toast from '../components/Toast';
import ConfirmDialog from '../components/ConfirmDialog';
import FormModal from '../components/FormModal';
import StepIndicator from '../components/StepIndicator';
import SimpleSearchInput from '../components/SimpleSearchInput';
import Breadcrumb from '../components/Breadcrumb';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import exportService from '../services/exportService';
import tools from '../utils/tools';
import PageLoading from '../components/PageLoading';
import meetingCacheService from '../services/meetingCacheService';
import './KnowledgeBasePage.css';
const KnowledgeBasePage = ({ user }) => {
const navigate = useNavigate();
const [kbs, setKbs] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedKb, setSelectedKb] = useState(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [meetings, setMeetings] = useState([]);
const [selectedMeetings, setSelectedMeetings] = useState([]);
const [userPrompt, setUserPrompt] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [selectedTags, setSelectedTags] = useState([]);
const [availableTags, setAvailableTags] = useState([]);
const [generating, setGenerating] = useState(false);
const [taskId, setTaskId] = useState(null);
const [progress, setProgress] = useState(0);
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
const [toasts, setToasts] = useState([]);
const [createStep, setCreateStep] = useState(1); // 1: 选择会议, 2: 输入提示词
const [meetingsPagination, setMeetingsPagination] = useState({ page: 1, total: 0, has_more: false });
const [loadingMeetings, setLoadingMeetings] = useState(false);
const [availablePrompts, setAvailablePrompts] = useState([]); // 可用的提示词模版列表
const [selectedPromptId, setSelectedPromptId] = useState(null); // 选中的提示词模版ID
// Toast helper functions
const showToast = (message, type = 'info') => {
const id = Date.now();
setToasts(prev => [...prev, { id, message, type }]);
};
const removeToast = (id) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
useEffect(() => {
fetchAllKbs();
fetchAllTagsForFilter(); // 获取标签云数据
}, []);
// 当搜索或标签过滤变化时,重新加载第一页
useEffect(() => {
if (showCreateForm) {
fetchMeetings(1);
}
}, [searchQuery, selectedTags, showCreateForm]);
useEffect(() => {
if (taskId) {
const interval = setInterval(() => {
apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.TASK_STATUS(taskId)))
.then(response => {
const { status, progress } = response.data;
setProgress(progress || 0);
if (status === 'completed') {
clearInterval(interval);
setTaskId(null);
setGenerating(false);
setProgress(0);
setUserPrompt('');
setSelectedMeetings([]);
setShowCreateForm(false);
setCreateStep(1); // 重置步骤
setSearchQuery('');
setSelectedTags([]);
fetchAllKbs();
} else if (status === 'failed') {
clearInterval(interval);
setTaskId(null);
setGenerating(false);
setProgress(0);
showToast('知识库生成失败,请稍后重试', 'error');
}
})
.catch(error => {
console.error("Error fetching task status:", error);
clearInterval(interval);
setTaskId(null);
setGenerating(false);
setProgress(0);
});
}, 2000);
return () => clearInterval(interval);
}
}, [taskId]);
const fetchAllKbs = () => {
setLoading(true);
// 获取所有知识库(个人和共享)
apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.LIST))
.then(response => {
// 按创建时间倒序排序
const sortedKbs = response.data.kbs.sort((a, b) =>
new Date(b.created_at) - new Date(a.created_at)
);
setKbs(sortedKbs);
// 如果有知识库且没有选中,默认选中第一个
if (sortedKbs.length > 0 && !selectedKb) {
loadKbDetail(sortedKbs[0].kb_id);
}
setLoading(false);
})
.catch(error => {
console.error("Error fetching knowledge bases:", error);
setLoading(false);
});
};
const fetchMeetings = async (page = 1) => {
try {
// 生成当前过滤器的键不包含filterType因为知识库这里不需要
const filterKey = meetingCacheService.generateFilterKey('all', searchQuery, selectedTags);
// 先检查缓存
const cachedPage = meetingCacheService.getPage(filterKey, page);
if (cachedPage) {
console.log('Using cached page:', page, 'for filter:', filterKey);
setMeetings(cachedPage.meetings);
setMeetingsPagination(cachedPage.pagination);
return;
}
// 没有缓存,从服务器获取
setLoadingMeetings(true);
const params = {
user_id: user.user_id,
page: page,
search: searchQuery || undefined,
tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined
};
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params });
const newMeetings = response.data.meetings || [];
const newPagination = {
page: response.data.page,
total: response.data.total,
has_more: response.data.has_more
};
// 缓存当前页数据
meetingCacheService.setPage(filterKey, page, newMeetings, newPagination);
setMeetings(newMeetings);
setMeetingsPagination(newPagination);
} catch (error) {
console.error("Error fetching meetings:", error);
setMeetings([]);
} finally {
setLoadingMeetings(false);
}
};
// 获取所有标签用于过滤器
const fetchAllTagsForFilter = async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST));
const allTags = response.data || [];
// 取前6个热门标签
const topSixTags = allTags.slice(0, 6).map(tag => tag.name);
setAvailableTags(topSixTags);
} catch (error) {
console.error("Error fetching tags:", error);
}
};
// 获取知识库任务的启用提示词模版列表
const fetchAvailablePrompts = async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('KNOWLEDGE_TASK')));
const promptsList = response.data.prompts || [];
setAvailablePrompts(promptsList);
// 自动选中默认模版
const defaultPrompt = promptsList.find(p => p.is_default);
if (defaultPrompt) {
setSelectedPromptId(defaultPrompt.id);
}
} catch (error) {
console.error("Error fetching available prompts:", error);
setAvailablePrompts([]);
}
};
const loadKbDetail = async (kbId) => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kbId)));
setSelectedKb(response.data);
} catch (error) {
console.error("Error loading knowledge base detail:", error);
}
};
const handleKbSelect = (kb) => {
loadKbDetail(kb.kb_id);
};
const handleGenerate = async () => {
if (!selectedMeetings || selectedMeetings.length === 0) {
showToast('请至少选择一个会议', 'warning');
return;
}
setGenerating(true);
try {
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.CREATE), {
user_prompt: userPrompt,
source_meeting_ids: selectedMeetings.join(','),
is_shared: false,
prompt_id: selectedPromptId // 传递选中的模版ID
});
setTaskId(response.data.task_id);
} catch (error) {
console.error("Error creating knowledge base:", error);
setGenerating(false);
}
};
const toggleMeetingSelection = (meetingId) => {
setSelectedMeetings(prev =>
prev.includes(meetingId)
? prev.filter(id => id !== meetingId)
: [...prev, meetingId]
);
};
const handlePageChange = (newPage) => {
fetchMeetings(newPage);
};
const handleTagToggle = (tag) => {
setSelectedTags(prev =>
prev.includes(tag)
? prev.filter(t => t !== tag)
: [...prev, tag]
);
};
const clearFilters = () => {
setSearchQuery('');
setSelectedTags([]);
};
const handleOpenCreateModal = () => {
setCreateStep(1);
setSelectedMeetings([]);
setUserPrompt('');
setSearchQuery('');
setSelectedTags([]);
setSelectedPromptId(null);
setShowCreateForm(true);
// 获取可用的提示词模版
fetchAvailablePrompts();
};
const handleCloseCreateModal = () => {
setShowCreateForm(false);
setCreateStep(1);
setSelectedMeetings([]);
setUserPrompt('');
setSearchQuery('');
setSelectedTags([]);
};
const handleNextStep = () => {
if (selectedMeetings.length === 0) {
showToast('请至少选择一个会议', 'warning');
return;
}
setCreateStep(2);
};
const handlePrevStep = () => {
setCreateStep(1);
};
const handleDelete = async (kb) => {
setDeleteConfirmInfo({ kb_id: kb.kb_id, title: kb.title });
};
const confirmDelete = async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(deleteConfirmInfo.kb_id)));
// 如果删除的是当前选中的,清除选中
if (selectedKb && selectedKb.kb_id === deleteConfirmInfo.kb_id) {
setSelectedKb(null);
}
setDeleteConfirmInfo(null);
fetchAllKbs();
} catch (error) {
console.error("Error deleting knowledge base:", error);
showToast('删除失败,请稍后重试', 'error');
setDeleteConfirmInfo(null);
}
};
const groupKbsByDate = (kbList) => {
const todayKbs = [];
const pastKbs = [];
kbList.forEach(kb => {
if (tools.isToday(kb.created_at)) {
todayKbs.push(kb);
} else {
pastKbs.push(kb);
}
});
return { todayKbs, pastKbs };
};
// 导出知识库内容为图片
const exportSummaryToImage = async () => {
try {
if (!selectedKb?.content) {
showToast('暂无知识库内容,请稍后再试。', 'warning');
return;
}
const createdAt = tools.formatDate(selectedKb.created_at);
const tags = selectedKb.tags?.join('、') || '';
await exportService.exportKnowledgeBaseToImage({
title: selectedKb.title || '知识库',
content: selectedKb.content,
metadata: {
creator: selectedKb.created_by_name || '未知',
createdTime: createdAt,
tags: tags,
sourceMeetings: selectedKb.source_meetings?.length || 0
}
});
showToast('内容已成功导出为图片', 'success');
} catch (error) {
console.error('图片导出失败:', error);
showToast('图片导出失败,请重试。', 'error');
}
};
// 导出思维导图为图片
const exportMindMapToImage = async () => {
try {
if (!selectedKb?.content) {
showToast('暂无内容,无法导出思维导图。', 'warning');
return;
}
await exportService.exportMindMapToImage({
title: selectedKb.title || '知识库'
});
showToast('思维导图已成功导出为图片', 'success');
} catch (error) {
console.error('思维导图导出失败:', error);
showToast(error.message || '思维导图导出失败,请重试。', 'error');
}
};
const isCreator = selectedKb && user && String(selectedKb.creator_id) === String(user.user_id);
if (loading) {
return <PageLoading message="加载中..." />;
}
return (
<div className="kb-management-page">
<Breadcrumb currentPage="知识库管理" icon={Database} />
<div className="kb-layout">
{/* 左侧知识库列表 */}
<div className={`kb-sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-header">
{!sidebarCollapsed && <h2>知识库列表</h2>}
<div className="sidebar-actions">
{!sidebarCollapsed && (
<button
className="btn-new-kb"
onClick={handleOpenCreateModal}
title="新增知识条目"
>
<Plus size={18} />
</button>
)}
<button
className="btn-toggle-sidebar"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
title={sidebarCollapsed ? "展开" : "收起"}
>
{sidebarCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
</div>
{!sidebarCollapsed && (
<div className="kb-list-sidebar">
{kbs.length === 0 ? (
<div className="empty-state">
<p>暂无知识库条目</p>
</div>
) : (
(() => {
const { todayKbs, pastKbs } = groupKbsByDate(kbs);
return (
<>
{/* 今天的知识库 */}
{todayKbs.length > 0 && (
<div className="kb-date-group">
<div className="date-group-header">今天</div>
{todayKbs.map(kb => (
<div
key={kb.kb_id}
className={`kb-list-item ${selectedKb && selectedKb.kb_id === kb.kb_id ? 'active' : ''}`}
onClick={() => handleKbSelect(kb)}
>
<div className="kb-list-item-header">
<h3>{kb.title}</h3>
{String(kb.creator_id) === String(user.user_id) && (
<div className="kb-item-actions">
<button
className="btn-edit-kb"
onClick={(e) => {
e.stopPropagation();
navigate(`/knowledge-base/edit/${kb.kb_id}`);
}}
title="编辑"
>
<Edit size={14} />
</button>
<button
className="btn-delete-kb"
onClick={(e) => {
e.stopPropagation();
handleDelete(kb);
}}
title="删除"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
<div className="kb-list-item-meta">
<span className="meta-time">{tools.formatTime(kb.created_at)}</span>
<span className="meta-item">
<Database size={12} />
{kb.source_meeting_count || 0} 个数据源
</span>
</div>
</div>
))}
</div>
)}
{/* 之前的知识库 */}
{pastKbs.length > 0 && (
<div className="kb-date-group">
<div className="date-group-header">之前</div>
{pastKbs.map(kb => (
<div
key={kb.kb_id}
className={`kb-list-item ${selectedKb && selectedKb.kb_id === kb.kb_id ? 'active' : ''}`}
onClick={() => handleKbSelect(kb)}
>
<div className="kb-list-item-header">
<h3>{kb.title}</h3>
{String(kb.creator_id) === String(user.user_id) && (
<div className="kb-item-actions">
<button
className="btn-edit-kb"
onClick={(e) => {
e.stopPropagation();
navigate(`/knowledge-base/edit/${kb.kb_id}`);
}}
title="编辑"
>
<Edit size={14} />
</button>
<button
className="btn-delete-kb"
onClick={(e) => {
e.stopPropagation();
handleDelete(kb);
}}
title="删除"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
<div className="kb-list-item-meta">
<span className="meta-date">{tools.formatShortDate(kb.created_at)}</span>
<span className="meta-item">
<Database size={12} />
{kb.source_meeting_count || 0} 个数据源
</span>
</div>
</div>
))}
</div>
)}
</>
);
})()
)}
</div>
)}
</div>
{/* 右侧详情区 */}
<div className="kb-detail-area">
{selectedKb ? (
<>
<div className="kb-detail-header">
<div className="kb-header-title">
<h1>
{selectedKb.title}
{selectedKb.tags && selectedKb.tags.length > 0 && (
<TagDisplay
tags={selectedKb.tags.map(tag => tag.name)}
size="medium"
showIcon={true}
className="inline-title-tags"
/>
)}
</h1>
</div>
<div className="kb-detail-meta">
{selectedKb.created_by_name && (
<span className="meta-item">
创建者: {selectedKb.created_by_name}
</span>
)}
<span className="meta-item">
<Calendar size={14} />
{tools.formatDate(selectedKb.created_at)}
</span>
{selectedKb.source_meetings && selectedKb.source_meetings.length > 0 && (
<span className="meta-item">
<Database size={14} />
{selectedKb.source_meetings.length} 个数据源
</span>
)}
</div>
</div>
{/* 用户提示词 */}
{selectedKb.user_prompt && (
<div className="kb-prompt-section">
<strong>用户提示词:</strong> {selectedKb.user_prompt}
</div>
)}
{/* 数据源列表 */}
{selectedKb.source_meetings && selectedKb.source_meetings.length > 0 && (
<div className="kb-sources-section">
<h3>数据源列表</h3>
<div className="source-meetings-list">
{selectedKb.source_meetings.map(meeting => (
<a
key={meeting.meeting_id}
href={`/meetings/${meeting.meeting_id}`}
target="_blank"
rel="noopener noreferrer"
className="source-meeting-link"
>
{meeting.title}
</a>
))}
</div>
</div>
)}
{/* 内容区域 - Tabs */}
<div className="kb-content-tabs">
<ContentViewer
content={selectedKb.content}
title={selectedKb.title}
emptyMessage="内容生成中..."
summaryActions={
selectedKb.content && (
<button
className="action-btn export-btn"
onClick={exportSummaryToImage}
title="导出图片"
>
<Image size={16} />
<span>导出图片</span>
</button>
)
}
mindmapActions={
selectedKb.content && (
<button
className="action-btn export-btn"
onClick={exportMindMapToImage}
title="导出图片"
>
<Image size={16} />
<span>导出图片</span>
</button>
)
}
/>
</div>
</>
) : (
<div className="kb-empty-placeholder">
<FileText size={64} />
<p>请从左侧选择一个知识库查看详情</p>
</div>
)}
</div>
</div>
{/* 新增知识库表单弹窗 */}
<FormModal
isOpen={showCreateForm}
onClose={handleCloseCreateModal}
title="新增知识库"
size="large"
headerExtra={
<StepIndicator
steps={['选择会议', '自定义提示词']}
currentStep={createStep}
/>
}
actions={
<>
{createStep === 1 ? (
<>
<button type="button" className="btn btn-secondary" onClick={handleCloseCreateModal}>
取消
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleNextStep}
disabled={selectedMeetings.length === 0}
>
下一步
</button>
</>
) : (
<>
<button type="button" className="btn btn-secondary" onClick={handlePrevStep}>
上一步
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleGenerate}
disabled={generating}
>
{generating ? `生成中... ${progress}%` : '生成知识库'}
</button>
</>
)}
</>
}
>
{/* 步骤 1: 选择会议 */}
{createStep === 1 && (
<div className="form-step">
<div className="form-group">
{/* 紧凑的搜索和过滤区 */}
<div className="search-filter-area">
<SimpleSearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="搜索会议名称或创建人..."
realTimeSearch={true}
debounceDelay={500}
/>
{availableTags.length > 0 && (
<div className="tag-filter-section">
<div className="tag-filter-chips">
{availableTags.map(tag => (
<button
key={tag}
type="button"
className={`tag-chip ${selectedTags.includes(tag) ? 'selected' : ''}`}
onClick={() => handleTagToggle(tag)}
>
{tag}
</button>
))}
</div>
</div>
)}
{(searchQuery || selectedTags.length > 0) && (
<button
type="button"
className="clear-filters-btn"
onClick={clearFilters}
>
<X size={14} />
清除筛选
</button>
)}
</div>
<div className="meeting-list">
{loadingMeetings ? (
<div className="loading-state">
<p>加载中...</p>
</div>
) : meetings.length === 0 ? (
<div className="empty-state">
<p>未找到匹配的会议</p>
</div>
) : (
meetings.map(meeting => (
<div
key={meeting.meeting_id}
className={`meeting-item ${selectedMeetings.includes(meeting.meeting_id) ? 'selected' : ''}`}
onClick={() => toggleMeetingSelection(meeting.meeting_id)}
>
<input
type="checkbox"
checked={selectedMeetings.includes(meeting.meeting_id)}
onChange={(e) => {
e.stopPropagation();
toggleMeetingSelection(meeting.meeting_id);
}}
onClick={(e) => e.stopPropagation()}
/>
<div className="meeting-item-content">
<div className="meeting-item-title">{meeting.title}</div>
<div className="meeting-item-meta">
{meeting.creator_username && (
<span className="meeting-item-creator">创建人: {meeting.creator_username}</span>
)}
{meeting.created_at && (
<span className="meeting-item-date">创建时间: {tools.formatMeetingDate(meeting.created_at)}</span>
)}
</div>
</div>
</div>
))
)}
</div>
{/* 分页按钮 */}
{!loadingMeetings && meetings.length > 0 && (
<div className="pagination-controls">
<button
className="pagination-btn"
onClick={() => handlePageChange(meetingsPagination.page - 1)}
disabled={meetingsPagination.page === 1}
>
<ChevronLeft size={16} />
上一页
</button>
<span className="pagination-info">
{meetingsPagination.page} · {meetingsPagination.total}
</span>
<button
className="pagination-btn"
onClick={() => handlePageChange(meetingsPagination.page + 1)}
disabled={!meetingsPagination.has_more}
>
下一页
<ChevronRight size={16} />
</button>
</div>
)}
</div>
</div>
)}
{/* 步骤 2: 输入提示词 */}
{createStep === 2 && (
<div className="form-step">
<div className="step-summary">
<div className="summary-item">
<span className="summary-label">已选择会议</span>
<span className="summary-value">{selectedMeetings.length} </span>
</div>
</div>
{/* 模版选择 */}
{availablePrompts.length > 0 && (
<div className="form-group">
<label>* 选择生成模版可选</label>
<select
value={selectedPromptId || ''}
onChange={(e) => setSelectedPromptId(e.target.value ? parseInt(e.target.value) : null)}
className="template-select"
>
{availablePrompts.map(prompt => (
<option key={prompt.id} value={prompt.id}>
{prompt.name} {prompt.is_default ? '(默认)' : ''}
</option>
))}
</select>
</div>
)}
<div className="form-group">
<label>* 用户提示词可选</label>
<p className="field-hint">您可以添加额外的要求来定制知识库生成内容例如重点关注某个主题提取特定信息等如不填写系统将使用默认提示词</p>
<textarea
placeholder="例如:请重点关注会议中的决策事项和待办任务..."
value={userPrompt}
onChange={(e) => setUserPrompt(e.target.value)}
className="kb-prompt-input"
rows={8}
autoFocus
/>
</div>
</div>
)}
</FormModal>
{/* 删除确认对话框 */}
<ConfirmDialog
isOpen={!!deleteConfirmInfo}
onClose={() => setDeleteConfirmInfo(null)}
onConfirm={confirmDelete}
title="删除知识库"
message={`确定要删除知识库条目"${deleteConfirmInfo?.title}"吗?此操作无法撤销。`}
confirmText="确定删除"
cancelText="取消"
type="danger"
/>
{/* Toast notifications */}
{toasts.map(toast => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
);
};
export default KnowledgeBasePage;