Files
qihuaiflow/frontend/src/pages/front/KnowledgePage.tsx
T

1211 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { knowledgeAPI } from '@/api';
import './KnowledgePage.css';
// ===== SVG Icons =====
const UploadIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
);
const ChevronDownIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
);
const FileIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
);
const FolderIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
);
const GraphIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="6" cy="6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/><circle cx="18" cy="8" r="1.5" fill="currentColor"/>
<line x1="6" y1="7.5" x2="12" y2="10.5"/><line x1="12" y1="13.5" x2="18" y2="9.5"/>
<circle cx="9" cy="18" r="1.5" fill="currentColor"/>
<line x1="18" y1="9.5" x2="9" y2="16.5"/><line x1="6" y1="7.5" x2="9" y2="16.5"/>
</svg>
);
const ProcessingIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="spin-icon">
<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/>
<line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>
</svg>
);
const CheckIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const CloseIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
);
const AddIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
);
const TrashIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>
</svg>
);
const AlertTriangleIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
);
// ===== Helpers =====
function formatFileSize(bytes: number): string {
if (!bytes || bytes === 0) return '-';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function formatDate(dateStr: string): string {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
function getTypeLabel(fileType: string): string {
const types: Record<string, string> = {
pdf: 'PDF', docx: 'Word', doc: 'Word', xlsx: 'Excel', xls: 'Excel',
csv: 'CSV', md: 'Markdown', txt: '文本', pptx: 'PPT', ppt: 'PPT',
};
return types[fileType] || fileType.toUpperCase();
}
// ===== Types =====
interface GraphNode {
id: string;
label: string;
type: string;
size: number;
group: string;
}
interface GraphEdge {
source: string;
target: string;
label?: string;
weight: number;
}
interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
stats: {
total_nodes: number;
total_edges: number;
document_count: number;
entity_types?: Record<string, number>;
};
}
interface KnowledgeBase {
id: string;
name: string;
description: string;
document_count: number;
knowledge_graph_count?: number;
created_at: string;
updated_at: string;
}
interface KnowledgeDoc {
id: string;
base_id?: string;
knowledge_base_id?: string;
filename: string;
file_type: string;
file_size: number;
status: string;
chunk_count: number;
created_at: string;
updated_at: string;
}
// ===== Color helpers for graph nodes =====
const GROUP_COLORS: Record<string, string> = {
document: '#4A90D9',
concept: '#7B68EE',
entity: '#50C878',
attribute: '#FF6347',
relation: '#FFD700',
};
// ===== Knowledge Graph Visualization =====
const KnowledgeGraphView: React.FC<{ graphData: GraphData; onClose: () => void }> = ({ graphData, onClose }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
interface LayoutNode extends GraphNode {
x: number;
y: number;
vx: number;
vy: number;
}
const layoutNodesRef = useRef<LayoutNode[]>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null);
const dragStartRef = useRef<{ x: number; y: number; nodeX: number; nodeY: number } | null>(null);
const hoveredNodeRef = useRef<string | null>(null);
const [tooltip, setTooltip] = useState<{ x: number; y: number; label: string; type: string; group: string } | null>(null);
const initLayout = useCallback(() => {
const { nodes, edges } = graphData;
const centerX = 400;
const centerY = 300;
const layoutNodes: LayoutNode[] = nodes.map((node, i) => {
if (node.type === 'document') {
const angle = (2 * Math.PI * i) / Math.max(nodes.filter(n => n.type === 'document').length, 1);
const radius = 120;
return { ...node, x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle), vx: 0, vy: 0 };
}
return { ...node, x: centerX + (Math.random() - 0.5) * 400, y: centerY + (Math.random() - 0.5) * 400, vx: 0, vy: 0 };
});
// Force simulation
const nodeMap = new Map<string, LayoutNode>();
layoutNodes.forEach(n => nodeMap.set(n.id, n));
const simulationSteps = 100;
for (let step = 0; step < simulationSteps; step++) {
const alpha = 1 - step / simulationSteps;
const repulsionStrength = 500 * alpha;
const attractionStrength = 0.01 * alpha;
const centeringStrength = 0.005 * alpha;
for (let i = 0; i < layoutNodes.length; i++) {
for (let j = i + 1; j < layoutNodes.length; j++) {
const dx = layoutNodes[j].x - layoutNodes[i].x;
const dy = layoutNodes[j].y - layoutNodes[i].y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = repulsionStrength / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
layoutNodes[i].vx -= fx;
layoutNodes[i].vy -= fy;
layoutNodes[j].vx += fx;
layoutNodes[j].vy += fy;
}
}
edges.forEach(edge => {
const src = nodeMap.get(edge.source);
const tgt = nodeMap.get(edge.target);
if (src && tgt) {
const dx = tgt.x - src.x;
const dy = tgt.y - src.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = dist * attractionStrength;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
src.vx += fx;
src.vy += fy;
tgt.vx -= fx;
tgt.vy -= fy;
}
});
layoutNodes.forEach(n => {
n.vx += (centerX - n.x) * centeringStrength;
n.vy += (centerY - n.y) * centeringStrength;
});
layoutNodes.forEach(n => {
n.vx *= 0.6;
n.vy *= 0.6;
n.x += n.vx;
n.y += n.vy;
n.vx = 0;
n.vy = 0;
});
}
layoutNodesRef.current = layoutNodes;
}, [graphData]);
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, rect.width, rect.height);
const { edges } = graphData;
const nodes = layoutNodesRef.current;
const nodeMap = new Map<string, LayoutNode>();
nodes.forEach(n => nodeMap.set(n.id, n));
edges.forEach(edge => {
const src = nodeMap.get(edge.source);
const tgt = nodeMap.get(edge.target);
if (!src || !tgt) return;
ctx.beginPath();
ctx.moveTo(src.x, src.y);
ctx.lineTo(tgt.x, tgt.y);
ctx.strokeStyle = 'rgba(148, 163, 184, 0.5)';
ctx.lineWidth = Math.max(0.5, edge.weight * 2);
ctx.stroke();
if (edge.label && edge.label.length < 20) {
const mx = (src.x + tgt.x) / 2;
const my = (src.y + tgt.y) / 2;
ctx.fillStyle = '#94a3b8';
ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(edge.label, mx, my - 4);
}
});
// 预计算选中节点的邻居集合
const highlightedNeighbors = new Set<string>();
if (selectedNodeId) {
highlightedNeighbors.add(selectedNodeId);
edges.forEach(edge => {
if (edge.source === selectedNodeId) highlightedNeighbors.add(edge.target);
if (edge.target === selectedNodeId) highlightedNeighbors.add(edge.source);
});
}
nodes.forEach(node => {
const r = Math.max(6, node.size * 1.2);
const color = GROUP_COLORS[node.group] || '#A9A9A9';
const isSelected = node.id === selectedNodeId;
const isNeighbor = selectedNodeId && highlightedNeighbors.has(node.id) && !isSelected;
const isDimmed = selectedNodeId && !highlightedNeighbors.has(node.id);
const isHovered = hoveredNodeRef.current === node.id;
// 高亮光晕
if (isSelected || isHovered) {
ctx.beginPath();
ctx.arc(node.x, node.y, r + 10, 0, 2 * Math.PI);
ctx.fillStyle = isSelected ? color + '50' : color + '25';
ctx.fill();
}
if (isNeighbor) {
ctx.beginPath();
ctx.arc(node.x, node.y, r + 5, 0, 2 * Math.PI);
ctx.fillStyle = color + '20';
ctx.fill();
}
// 节点本体
const alpha = isDimmed ? '40' : '';
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, 2 * Math.PI);
ctx.fillStyle = isSelected ? color : (isDimmed ? color + '30' : color);
ctx.fill();
if (isSelected) {
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 3;
} else if (isHovered) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3;
} else {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
}
ctx.stroke();
const label = node.label.length > 8 ? node.label.slice(0, 8) + '..' : node.label;
ctx.fillStyle = isDimmed ? 'rgba(148,163,184,0.3)' : '#e2e8f0';
ctx.font = `${isSelected ? 'bold ' : ''}${Math.max(10, node.size * 0.8)}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText(label, node.x, node.y + r + 12);
});
const legendX = 16;
let legendY = 16;
ctx.fillStyle = 'rgba(15, 23, 42, 0.85)';
ctx.fillRect(legendX - 8, legendY - 8, 110, 160);
ctx.font = 'bold 12px sans-serif';
ctx.fillStyle = '#e2e8f0';
ctx.fillText('节点类型', legendX + 4, legendY + 8);
legendY += 24;
Object.entries(GROUP_COLORS).forEach(([group, color]) => {
ctx.beginPath();
ctx.arc(legendX + 4, legendY, 6, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
ctx.fillStyle = '#cbd5e1';
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(group === 'document' ? '文档' : group === 'concept' ? '概念' : group === 'entity' ? '实体' : group === 'attribute' ? '属性' : '关系', legendX + 16, legendY + 4);
legendY += 22;
});
}, [graphData, selectedNodeId]);
// 鼠标事件:拖拽节点 + 点击高亮
const getCanvasPos = useCallback((e: React.MouseEvent<HTMLCanvasElement>): { x: number; y: number } => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}, []);
const findNodeAt = useCallback((canvasX: number, canvasY: number): LayoutNode | null => {
const nodes = layoutNodesRef.current;
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i];
const r = Math.max(6, node.size * 1.2) + 6;
const dx = canvasX - node.x;
const dy = canvasY - node.y;
if (dx * dx + dy * dy <= r * r) return node;
}
return null;
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const pos = getCanvasPos(e);
const node = findNodeAt(pos.x, pos.y);
if (node) {
setDraggedNodeId(node.id);
dragStartRef.current = { x: pos.x, y: pos.y, nodeX: node.x, nodeY: node.y };
}
}, [getCanvasPos, findNodeAt]);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const pos = getCanvasPos(e);
if (draggedNodeId && dragStartRef.current) {
const dx = pos.x - dragStartRef.current.x;
const dy = pos.y - dragStartRef.current.y;
const nodes = layoutNodesRef.current;
const node = nodes.find(n => n.id === draggedNodeId);
if (node) {
node.x = dragStartRef.current.nodeX + dx;
node.y = dragStartRef.current.nodeY + dy;
}
setTooltip(null);
return;
}
const hoveredNode = findNodeAt(pos.x, pos.y);
hoveredNodeRef.current = hoveredNode?.id || null;
if (hoveredNode) {
setTooltip({ x: pos.x, y: pos.y, label: hoveredNode.label, type: hoveredNode.type, group: hoveredNode.group });
} else {
setTooltip(null);
}
}, [getCanvasPos, draggedNodeId, findNodeAt]);
const handleMouseUp = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (draggedNodeId && dragStartRef.current) {
const pos = getCanvasPos(e);
const dx = pos.x - dragStartRef.current.x;
const dy = pos.y - dragStartRef.current.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 4) {
// 几乎没移动 = 点击
setSelectedNodeId(prev => prev === draggedNodeId ? null : draggedNodeId);
}
}
setDraggedNodeId(null);
dragStartRef.current = null;
}, [draggedNodeId, getCanvasPos]);
const handleCanvasClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const pos = getCanvasPos(e);
const node = findNodeAt(pos.x, pos.y);
if (!node) {
setSelectedNodeId(null);
}
}, [getCanvasPos, findNodeAt]);
useEffect(() => {
if (layoutNodesRef.current.length === 0) {
initLayout();
}
const animate = () => {
draw();
animationRef.current = requestAnimationFrame(animate);
};
animate();
return () => cancelAnimationFrame(animationRef.current);
}, [initLayout, draw]);
return (
<div className="graph-overlay" onClick={onClose}>
<div className="graph-modal" onClick={e => e.stopPropagation()}>
<div className="graph-modal-header">
<h2>知识图谱可视化</h2>
<div className="graph-stats">
<span>{graphData.stats.total_nodes} 节点</span>
<span>{graphData.stats.total_edges} 边</span>
<span>{graphData.stats.document_count} 文档</span>
</div>
<button className="graph-close-btn" onClick={onClose}><CloseIcon /></button>
</div>
<div className="graph-canvas-container" style={{position:'relative'}}>
<canvas
ref={canvasRef}
className="graph-canvas"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onClick={handleCanvasClick}
style={{ cursor: draggedNodeId ? 'grabbing' : hoveredNodeRef.current ? 'pointer' : 'grab' }}
/>
{tooltip && !draggedNodeId && (
<div style={{
position:'absolute',
left: tooltip.x + 16,
top: tooltip.y - 10,
background:'rgba(15,23,42,0.95)',
color:'#e2e8f0',
padding:'6px 12px',
borderRadius:'6px',
fontSize:'12px',
pointerEvents:'none',
whiteSpace:'nowrap',
zIndex: 100,
border:'1px solid rgba(148,163,184,0.3)',
}}>
<div style={{fontWeight:'bold',marginBottom:'2px'}}>{tooltip.label}</div>
<div style={{fontSize:'10px',color:'#94a3b8'}}>
类型: {tooltip.group === 'document' ? '文档' : tooltip.group === 'concept' ? '概念' : tooltip.group === 'entity' ? '实体' : tooltip.group === 'attribute' ? '属性' : tooltip.group === 'relation' ? '关系' : tooltip.group}
</div>
</div>
)}
{selectedNodeId && (() => {
const selNode = layoutNodesRef.current.find(n => n.id === selectedNodeId);
if (!selNode) return null;
const neighbors = graphData.edges
.filter(e => e.source === selectedNodeId || e.target === selectedNodeId)
.map(e => {
const neighborId = e.source === selectedNodeId ? e.target : e.source;
const node = layoutNodesRef.current.find(n => n.id === neighborId);
return { label: e.label || '', nodeName: node?.label || neighborId };
});
return (
<div style={{
position:'absolute',
right: 12,
top: 12,
background:'rgba(15,23,42,0.95)',
color:'#e2e8f0',
padding:'16px',
borderRadius:'8px',
fontSize:'13px',
minWidth:'220px',
maxWidth:'300px',
zIndex: 100,
border:'1px solid rgba(148,163,184,0.3)',
}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:'12px'}}>
<span style={{fontWeight:'bold',fontSize:'15px'}}>{selNode.label}</span>
<button
onClick={() => setSelectedNodeId(null)}
style={{background:'none',border:'none',color:'#94a3b8',cursor:'pointer',fontSize:'16px',padding:'0 4px'}}
>×</button>
</div>
<div style={{marginBottom:'8px',fontSize:'12px'}}>
<span style={{display:'inline-block',width:'8px',height:'8px',borderRadius:'50%',background:GROUP_COLORS[selNode.group]||'#A9A9A9',marginRight:'6px'}}></span>
类型: {selNode.group === 'document' ? '文档' : selNode.group === 'concept' ? '概念' : selNode.group === 'entity' ? '实体' : selNode.group === 'attribute' ? '属性' : selNode.group === 'relation' ? '关系' : selNode.group}
</div>
{neighbors.length > 0 && (
<div style={{marginTop:'12px',borderTop:'1px solid rgba(148,163,184,0.2)',paddingTop:'8px'}}>
<div style={{fontSize:'12px',color:'#94a3b8',marginBottom:'6px'}}>
关联节点 ({neighbors.length})
</div>
{neighbors.map((n, i) => (
<div key={i} style={{fontSize:'11px',padding:'2px 0',color:'#cbd5e1'}}>
<span style={{color:'#64748b',marginRight:'4px'}}>{n.label ? `[${n.label}]` : ''}</span>
{n.nodeName}
</div>
))}
</div>
)}
</div>
);
})()}
</div>
</div>
</div>
);
};
// ===== Modal for creating knowledge base =====
const CreateKBModal: React.FC<{ onClose: () => void; onCreate: (name: string, desc: string) => Promise<void> }> = ({ onClose, onCreate }) => {
const [name, setName] = useState('');
const [desc, setDesc] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!name.trim()) return;
setLoading(true);
try {
await onCreate(name.trim(), desc.trim());
onClose();
} catch (e) {
alert('创建失败:' + (e as Error).message);
} finally {
setLoading(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>创建知识库</h3>
<button className="modal-close" onClick={onClose}><CloseIcon /></button>
</div>
<div className="modal-body">
<label>知识库名称</label>
<input type="text" placeholder="请输入知识库名称" value={name} onChange={e => setName(e.target.value)} autoFocus />
<label>描述(可选)</label>
<textarea placeholder="请输入知识库描述" value={desc} onChange={e => setDesc(e.target.value)} rows={3} />
</div>
<div className="modal-footer">
<button className="btn-cancel" onClick={onClose}>取消</button>
<button className="btn-primary" onClick={handleSubmit} disabled={loading || !name.trim()}>
{loading ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
);
};
// ===== Modal for uploading document with progress =====
const UploadDocModal: React.FC<{
kbId: string;
kbName: string;
onClose: () => void;
onUpload: (baseId: string, filename: string, fileType: string, fileSize: number) => Promise<void>;
}> = ({ kbId, kbName, onClose, onUpload }) => {
const [filename, setFilename] = useState('');
const [fileType, setFileType] = useState('pdf');
const [fileSize, setFileSize] = useState(102400);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
setFilename(file.name);
const ext = file.name.split('.').pop()?.toLowerCase() || 'txt';
setFileType(ext);
setFileSize(file.size);
}
};
const handleSubmit = async () => {
if (!filename.trim() || uploading) return;
setUploading(true);
setProgress(0);
const interval = setInterval(() => {
setProgress(prev => {
if (prev >= 90) return prev;
return prev + Math.random() * 15 + 5;
});
}, 150);
try {
const formData = new FormData();
if (selectedFile) {
formData.append('file', selectedFile);
}
formData.append('filename', filename.trim());
formData.append('file_type', fileType);
formData.append('file_size', String(fileSize));
const token = localStorage.getItem('aiflow_token');
const res = await fetch(`/app/api/knowledge/bases/${kbId}/documents`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
});
const data = await res.json();
if (!res.ok || data.code !== 0) {
throw new Error(data.message || data.detail || '上传失败');
}
clearInterval(interval);
setProgress(100);
// 上传成功后刷新该知识库的文档列表,无需用户手动刷新页面
try {
await onUpload(kbId, filename.trim(), fileType, fileSize);
} catch (e) {
console.error('刷新文档列表失败:', e);
}
setTimeout(() => {
onClose();
}, 800);
} catch (e) {
clearInterval(interval);
setProgress(0);
alert('上传失败:' + (e as Error).message);
setUploading(false);
}
};
const fileTypes = ['pdf', 'docx', 'doc', 'xlsx', 'xls', 'csv', 'md', 'txt', 'pptx'];
return (
<div className="modal-overlay" onClick={uploading ? undefined : onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>上传文档到「{kbName}」</h3>
<button className="modal-close" onClick={onClose} disabled={uploading}><CloseIcon /></button>
</div>
<div className="modal-body">
<label>选择文件</label>
<input type="file" onChange={handleFileSelect} disabled={uploading} />
<label>文件名</label>
<input type="text" placeholder="文件名" value={filename} onChange={e => setFilename(e.target.value)} disabled={uploading} />
<label>文件类型</label>
<select value={fileType} onChange={e => setFileType(e.target.value)} disabled={uploading}>
{fileTypes.map(ft => <option key={ft} value={ft}>{ft.toUpperCase()}</option>)}
</select>
{/* Upload Progress Bar */}
{uploading && (
<div className="upload-progress-section">
<div className="upload-progress-bar-track">
<div
className="upload-progress-bar-fill"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
<span className="upload-progress-text">{Math.min(Math.round(progress), 100)}%</span>
</div>
)}
</div>
<div className="modal-footer">
<button className="btn-cancel" onClick={onClose} disabled={uploading}>取消</button>
<button className="btn-primary" onClick={handleSubmit} disabled={uploading || !filename.trim()}>
{uploading ? `上传中 ${Math.min(Math.round(progress), 100)}%` : '上传'}
</button>
</div>
</div>
</div>
);
};
// ===== Modal for confirming knowledge base deletion =====
const DeleteKBModal: React.FC<{
kbName: string;
docCount: number;
loading: boolean;
onConfirm: () => void;
onClose: () => void;
}> = ({ kbName, docCount, loading, onConfirm, onClose }) => {
return (
<div className="modal-overlay" onClick={loading ? undefined : onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>删除知识库</h3>
<button className="modal-close" onClick={onClose} disabled={loading}><CloseIcon /></button>
</div>
<div className="modal-body">
<div className="delete-warning">
<AlertTriangleIcon />
<div className="delete-warning-text">
<p className="delete-warning-title">确定要删除知识库「{kbName}」吗?</p>
{docCount > 0 && (
<p className="delete-warning-detail">
该知识库包含 {docCount} 个文档,删除后不可恢复。
</p>
)}
{docCount === 0 && (
<p className="delete-warning-detail">此操作不可撤销。</p>
)}
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn-cancel" onClick={onClose} disabled={loading}>取消</button>
<button className="btn-danger" onClick={onConfirm} disabled={loading}>
{loading ? '删除中...' : '确认删除'}
</button>
</div>
</div>
</div>
);
};
// ===== Document Row - backend-driven status display =====
const DocRow: React.FC<{
doc: KnowledgeDoc;
onDelete: () => void;
}> = ({ doc, onDelete }) => {
const isCompleted = doc.status === 'completed';
const isProcessing = doc.status === 'processing';
const isFailed = doc.status === 'failed';
return (
<tr className="doc-table-row">
<td className="doc-table-cell doc-filename-cell">
<FileIcon />
<span className="doc-filename-text">{doc.filename}</span>
</td>
<td className="doc-table-cell">
<span className={`doc-type-badge ${doc.file_type}`}>{getTypeLabel(doc.file_type)}</span>
</td>
<td className="doc-table-cell">{formatFileSize(doc.file_size)}</td>
<td className="doc-table-cell">
<div className="doc-status-cell">
{isProcessing ? (
<span className="doc-status-text processing">
<ProcessingIcon />
处理中...
</span>
) : isCompleted ? (
<span className="doc-status-badge completed">
<CheckIcon />
已完成
</span>
) : isFailed ? (
<span className="doc-status-badge failed">
失败
</span>
) : (
<span className={`doc-status-badge ${doc.status}`}>
{doc.status || '未知'}
</span>
)}
</div>
</td>
<td className="doc-table-cell doc-actions-cell">
<button
className="doc-delete-btn"
onClick={onDelete}
title="删除文档"
>
<TrashIcon />
</button>
</td>
</tr>
);
};
// ===== Main KnowledgePage =====
const KnowledgePage: React.FC = () => {
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [documents, setDocuments] = useState<Record<string, KnowledgeDoc[]>>({});
const [expandedKb, setExpandedKb] = useState<string | null>(null);
const [buildingKb, setBuildingKb] = useState<string | null>(null);
const buildingKbRef = useRef<string | null>(null);
const [buildMessage, setBuildMessage] = useState<string | null>(null);
const [graphData, setGraphData] = useState<GraphData | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [uploadTargetKb, setUploadTargetKb] = useState<KnowledgeBase | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [storageUsed, setStorageUsed] = useState(0);
const [storageLimit, setStorageLimit] = useState(0);
const [membershipLevel, setMembershipLevel] = useState(0);
// Delete KB modal state
const [deleteTargetKb, setDeleteTargetKb] = useState<KnowledgeBase | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
// Load knowledge bases
const loadKnowledgeBases = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await knowledgeAPI.getBases();
if (res.code === 0) {
const data: any = res.data;
if (data && Array.isArray(data.bases)) {
setKnowledgeBases(data.bases);
setStorageUsed(data.storage_used || 0);
setStorageLimit(data.storage_limit || 1073741824);
setMembershipLevel(data.membership_level || 0);
} else if (Array.isArray(data)) {
// Legacy format fallback
setKnowledgeBases(data);
} else {
setKnowledgeBases([]);
}
}
} catch (e) {
setError('加载知识库失败:' + (e as Error).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadKnowledgeBases();
}, [loadKnowledgeBases]);
// Load documents for a specific knowledge base
const loadDocuments = useCallback(async (baseId: string) => {
try {
const res = await knowledgeAPI.getDocuments(baseId);
if (res.code === 0) {
setDocuments(prev => ({ ...prev, [baseId]: res.data || [] }));
}
} catch (e) {
console.error('加载文档失败:', e);
}
}, []);
// Toggle expand
const toggleExpand = async (kbId: string) => {
if (expandedKb === kbId) {
setExpandedKb(null);
return;
}
setExpandedKb(kbId);
if (!documents[kbId]) {
await loadDocuments(kbId);
}
};
// Create knowledge base
const handleCreateKB = async (name: string, desc: string) => {
const res = await knowledgeAPI.createBase({ name, description: desc || undefined });
if (res.code === 0) {
await loadKnowledgeBases();
}
};
// Upload document - 实际文件上传由 UploadDocModal 内的 fetch 完成,
// 此回调仅用于在上传成功后刷新文档列表与知识库统计,避免重复上传产生重复文档。
const handleUploadDocument = async (baseId: string, _filename?: string, _fileType?: string, _fileSize?: number) => {
await loadDocuments(baseId);
await loadKnowledgeBases();
// 不在这里清理 uploadTargetKb,由 UploadDocModal 内部控制关闭时机
// (显示100%进度条800ms后再关闭)
};
// Delete document
const handleDeleteDocument = async (baseId: string, docId: string) => {
try {
const res = await knowledgeAPI.deleteDocument(baseId, docId);
if (res.code === 0) {
await loadDocuments(baseId);
await loadKnowledgeBases();
}
} catch (e) {
alert('删除失败:' + (e as Error).message);
}
};
// Delete knowledge base - opens confirmation modal
const handleDeleteKBClick = (kb: KnowledgeBase) => {
setDeleteTargetKb(kb);
};
const handleDeleteKBConfirm = async () => {
if (!deleteTargetKb) return;
setDeleteLoading(true);
try {
const res = await knowledgeAPI.deleteBase(deleteTargetKb.id);
if (res.code === 0) {
if (expandedKb === deleteTargetKb.id) setExpandedKb(null);
setDocuments(prev => { const next = { ...prev }; delete next[deleteTargetKb.id]; return next; });
await loadKnowledgeBases();
}
} catch (e) {
alert('删除失败:' + (e as Error).message);
} finally {
setDeleteLoading(false);
setDeleteTargetKb(null);
}
};
// Build knowledge graph
const handleBuildGraph = async (kbId: string) => {
// Prevent re-entry if already building
if (buildingKbRef.current) return;
buildingKbRef.current = kbId;
setBuildingKb(kbId);
setBuildMessage(null);
try {
const res = await knowledgeAPI.buildGraph(kbId);
if (res.code === 0) {
setGraphData(res.data);
await loadKnowledgeBases();
if (documents[kbId]) {
await loadDocuments(kbId);
}
} else {
setBuildMessage('图谱构建失败:' + (res.message || '未知错误,code=' + res.code));
}
} catch (e: any) {
let errMsg = '图谱构建失败';
if (e.message) {
errMsg += ':' + e.message;
} else if (e.detail) {
errMsg += ':' + e.detail;
} else {
errMsg += ':' + String(e);
}
setBuildMessage(errMsg);
} finally {
// Delay clearing building state to make the "构建中..." state visible
setTimeout(() => {
setBuildingKb(null);
buildingKbRef.current = null;
}, 600);
}
};
// Get graph for knowledge base
const handleViewGraph = async (kbId: string) => {
try {
const res = await knowledgeAPI.getGraph(kbId);
if (res.code === 0) {
setGraphData(res.data);
}
} catch (e) {
alert('获取图谱失败:' + (e as Error).message);
}
};
return (
<div className="knowledge-page">
<div className="knowledge-header">
<div className="knowledge-header-left">
<h1 className="knowledge-title">我的知识库</h1>
<span className="knowledge-count">{knowledgeBases.length} 个知识库</span>
</div>
<div className="knowledge-header-right">
{/* Storage Space Info */}
<div className="storage-info">
<span className="storage-label">存储空间</span>
<div className="storage-bar-wrapper">
<div className="storage-bar-track">
<div
className="storage-bar-fill"
style={{ width: `${storageLimit > 0 ? Math.min((storageUsed / storageLimit) * 100, 100) : 0}%` }}
/>
</div>
<span className="storage-text">
{formatFileSize(storageUsed)} / {formatFileSize(storageLimit)}
</span>
</div>
<span className="storage-tier">
{membershipLevel === 0 ? 'Free' : membershipLevel === 2 ? 'Pro' : membershipLevel === 3 ? 'Enterprise' : 'Free'}
</span>
</div>
<button className="knowledge-upload-btn" onClick={() => setShowCreateModal(true)}>
<AddIcon />
<span>创建知识库</span>
</button>
</div>
</div>
{error && (
<div className="knowledge-error">
<span>{error}</span>
<button onClick={loadKnowledgeBases}>重试</button>
</div>
)}
{loading && knowledgeBases.length === 0 && (
<div className="knowledge-loading">加载中...</div>
)}
{!loading && knowledgeBases.length === 0 && !error && (
<div className="knowledge-empty">
<FolderIcon />
<h3>暂无知识库</h3>
<p>点击右上角"创建知识库"按钮开始使用</p>
<button className="btn-primary" onClick={() => setShowCreateModal(true)}>创建知识库</button>
</div>
)}
<div className="knowledge-list">
{knowledgeBases.map(kb => {
const docs = documents[kb.id] || [];
const isExpanded = expandedKb === kb.id;
const isBuilding = buildingKb === kb.id;
const hasGraph = (kb.knowledge_graph_count || 0) > 0;
return (
<div key={kb.id} className={`knowledge-card ${isExpanded ? 'expanded' : ''}`}>
<div className="knowledge-card-main" onClick={() => toggleExpand(kb.id)}>
<div className="knowledge-card-icon">
<FolderIcon />
</div>
<div className="knowledge-card-info">
<h3 className="knowledge-card-name">{kb.name}</h3>
<p className="knowledge-card-desc">{kb.description || '暂无描述'}</p>
</div>
<div className="knowledge-card-meta">
<span className="knowledge-meta-item">
<FileIcon />
{kb.document_count || 0} 份文档
</span>
{hasGraph && (
<span className="knowledge-meta-item graph-indicator">
<GraphIcon />
{kb.knowledge_graph_count} 节点
</span>
)}
</div>
<div className={`knowledge-expand-indicator ${isExpanded ? 'open' : ''}`}>
<ChevronDownIcon />
</div>
</div>
{isExpanded && (
<div className="knowledge-card-detail">
<div className="knowledge-docs">
<div className="knowledge-docs-header">
<span className="knowledge-docs-title">
文档列表 ({docs.length})
</span>
<div className="knowledge-docs-actions">
<button
className={`knowledge-graph-btn ${isBuilding ? 'building' : ''}`}
onClick={(e) => { e.stopPropagation(); handleBuildGraph(kb.id); }}
disabled={isBuilding}
>
<GraphIcon />
{isBuilding ? '构建中...' : '构建知识图谱'}
</button>
{hasGraph && !isBuilding && (
<button
className="knowledge-graph-btn view-graph"
onClick={(e) => { e.stopPropagation(); handleViewGraph(kb.id); }}
>
<GraphIcon />
查看图谱
</button>
)}
<button
className="knowledge-doc-upload-btn"
onClick={(e) => { e.stopPropagation(); setUploadTargetKb(kb); }}
>
<UploadIcon />
上传文档
</button>
</div>
</div>
{/* Build graph inline message */}
{buildMessage && (
<div className="build-message">{buildMessage}</div>
)}
{/* Table-form document list */}
<div className="knowledge-docs-table-wrapper">
{docs.length > 0 ? (
<table className="knowledge-docs-table">
<thead>
<tr className="doc-table-header-row">
<th className="doc-table-th">文件名</th>
<th className="doc-table-th">类型</th>
<th className="doc-table-th">大小</th>
<th className="doc-table-th">状态</th>
<th className="doc-table-th">操作</th>
</tr>
</thead>
<tbody>
{docs.map(doc => (
<DocRow
key={doc.id}
doc={doc}
onDelete={() => handleDeleteDocument(kb.id, doc.id)}
/>
))}
</tbody>
</table>
) : (
<div className="knowledge-empty-docs">
暂未上传文档,请点击"上传文档"按钮添加
</div>
)}
</div>
</div>
{/* Expandable detail sidebar removed - footer spans full width */}
<div className="knowledge-card-footer">
<span className="knowledge-update-time">更新于 {formatDate(kb.updated_at)}</span>
<button
className="knowledge-delete-btn"
onClick={(e) => { e.stopPropagation(); handleDeleteKBClick(kb); }}
>
删除知识库
</button>
</div>
</div>
)}
</div>
);
})}
</div>
{/* Create Knowledge Base Modal */}
{showCreateModal && (
<CreateKBModal onClose={() => setShowCreateModal(false)} onCreate={handleCreateKB} />
)}
{/* Upload Document Modal */}
{uploadTargetKb && (
<UploadDocModal
kbId={uploadTargetKb.id}
kbName={uploadTargetKb.name}
onClose={() => setUploadTargetKb(null)}
onUpload={handleUploadDocument}
/>
)}
{/* Delete Knowledge Base Confirmation Modal */}
{deleteTargetKb && (
<DeleteKBModal
kbName={deleteTargetKb.name}
docCount={deleteTargetKb.document_count || 0}
loading={deleteLoading}
onConfirm={handleDeleteKBConfirm}
onClose={() => { if (!deleteLoading) setDeleteTargetKb(null); }}
/>
)}
{/* Knowledge Graph Visualization */}
{graphData && (
<KnowledgeGraphView graphData={graphData} onClose={() => setGraphData(null)} />
)}
</div>
);
};
export default KnowledgePage;