import { KnowledgeUsageList } from './KnowledgeUsageList' // KnowledgePage(/knowledge):列表 + 选中 → 右侧五页签(文件/分类/检索测试/消费记录/设置) // 复用 KBsPage 的全部业务函数;detail 中已有 files/categories/usage 数据 import { useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import { Plus, Search, Trash2 } from 'lucide-react' import { api, chunkedUpload } from '../api' import { useDialog } from '../dialog' import { Tag } from '../ui' import { PageHeader, EmptyWorkspace, OwnerLabel } from '../workbench-ui' import { forkResource, canManageResource, canWriteResource, isGlobalResource } from '../resourceActions' import TagsPage from './TagsPage' import type { KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from '../types' // zip 打包(从 KBsPage 抽出的工具,保持一致) let _crcTable: number[] | null = null function crc32Table(): number[] { if (_crcTable) return _crcTable const t: number[] = [] for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0 } return _crcTable = t } async function crc32(buf: ArrayBuffer): Promise { const table = crc32Table() const bytes = new Uint8Array(buf) let crc = -1 for (let i = 0; i < bytes.length; i++) crc = (crc >>> 8) ^ table[(crc ^ bytes[i]) & 0xff] return (crc ^ -1) >>> 0 } async function buildZip(files: File[]): Promise { const enc = new TextEncoder() const chunks: BlobPart[] = [] const central: BlobPart[] = [] let offset = 0 for (const f of files) { const rel = ((f as any).webkitRelativePath || f.name) as string const nameBytes = enc.encode(rel) const data = await f.arrayBuffer() const crc = await crc32(data) const local = new DataView(new ArrayBuffer(30)) local.setUint32(0, 0x04034b50, true); local.setUint16(4, 20, true); local.setUint16(6, 0x0800, true) local.setUint16(8, 0, true); local.setUint16(10, 0, true); local.setUint16(12, 0, true) local.setUint32(14, crc, true); local.setUint32(18, data.byteLength, true); local.setUint32(22, data.byteLength, true) local.setUint16(26, nameBytes.length, true); local.setUint16(28, 0, true) chunks.push(local.buffer, nameBytes, data) const cv = new DataView(new ArrayBuffer(46)) cv.setUint32(0, 0x02014b50, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true) cv.setUint16(8, 0x0800, true); cv.setUint16(10, 0, true); cv.setUint16(12, 0, true) cv.setUint16(14, 0, true); cv.setUint32(16, crc, true) cv.setUint32(20, data.byteLength, true); cv.setUint32(24, data.byteLength, true) cv.setUint16(28, nameBytes.length, true) cv.setUint32(42, offset, true) central.push(cv.buffer, nameBytes) offset += 30 + nameBytes.length + data.byteLength } const centralSize = central.reduce((s: number, c: BlobPart) => s + (c as ArrayBuffer).byteLength, 0) const end = new DataView(new ArrayBuffer(22)) end.setUint32(0, 0x06054b50, true); end.setUint16(8, files.length, true); end.setUint16(10, files.length, true) end.setUint32(12, centralSize, true); end.setUint32(16, offset, true) return new Blob([...chunks, ...central, end.buffer], { type: 'application/zip' }) } type TabKey = 'files' | 'categories' | 'search' | 'usage' | 'settings' type ImportResult = { imported_files?: number; skipped_duplicates?: number; removed_duplicate_chunks?: number; duplicate_files?: { file_name: string; duplicate_of: string }[] } function describeImportResult(result: ImportResult, label: string): string { const imported = Number(result.imported_files || 0), skipped = Number(result.skipped_duplicates || 0) const removed = Number(result.removed_duplicate_chunks || 0) let text = `${label}:新增或更新 ${imported} 个文件,跳过 ${skipped} 个重复文件。` if (removed) text += ` ${removed.toLocaleString('zh-CN')} 个完全重复片段已合并索引,原文保留。` const duplicates = result.duplicate_files || [] if (duplicates.length) text += ' ' + duplicates.slice(0, 5).map(item => `「${item.file_name}」与「${item.duplicate_of}」内容相同`).join(';') + (duplicates.length > 5 ? `等 ${duplicates.length} 个文件。` : '。') return text } export default function KnowledgePage() { const { id } = useParams() const nav = useNavigate() const dialog = useDialog() const [searchParams] = useSearchParams() const moduleTab = searchParams.get('tab') === 'tags' ? 'tags' : searchParams.get('tab') === 'usage' ? 'usage' : 'kbs' const [kbs, setKbs] = useState([]) const [me, setMe] = useState(null) const [forkBusy, setForkBusy] = useState(null) const [sel, setSel] = useState(id ? Number(id) : null) const selRef = useRef(id ? Number(id) : null) const [detail, setDetail] = useState(null) const [detailLoading, setDetailLoading] = useState(false) const [usages, setUsages] = useState([]) const [allUsages, setAllUsages] = useState([]) const [allUsageLoading, setAllUsageLoading] = useState(false) const [allUsageError, setAllUsageError] = useState('') const [usageSummary, setUsageSummary] = useState(null) const [selectedUsageIds, setSelectedUsageIds] = useState>(new Set()) const [openCategoryIds, setOpenCategoryIds] = useState>(new Set()) const [tab, setTab] = useState('files') const [busy, setBusy] = useState(false) const [error, setError] = useState('') const [query, setQuery] = useState('') // 新建/上传 const [showCreate, setShowCreate] = useState(false) const [name, setName] = useState('') const [desc, setDesc] = useState('') const [chunkTarget, setChunkTarget] = useState('') const [chunkMode, setChunkMode] = useState<'auto' | 'heading'>('auto') const [uploadPct, setUploadPct] = useState(null) const [folderJob, setFolderJob] = useState(null) const [importMessages, setImportMessages] = useState([]) const uploadAlive = useRef(false) useEffect(() => { uploadAlive.current = true; return () => { uploadAlive.current = false } }, []) const [fileInputRef, setFileInputRef] = useState(null) const [folderInputRef, setFolderInputRef] = useState(null) // 检索 const [q, setQ] = useState('') const [hits, setHits] = useState([]) // 分类 const [categoryName, setCategoryName] = useState('') const [categoryParentId, setCategoryParentId] = useState('') const [classifyBusy, setClassifyBusy] = useState(false) const [classifyNotice, setClassifyNotice] = useState('') const [classifyError, setClassifyError] = useState('') // 文件预览 const [preview, setPreview] = useState(null) const [previewLoading, setPreviewLoading] = useState(false) const [previewTarget, setPreviewTarget] = useState('') // 路由切换或开发模式 StrictMode 可能让详情请求重叠;仅允许最后一次请求更新视图。 const selectRequestRef = useRef(0) const refresh = () => api.get('/kbs').then(setKbs).catch(e => setError(String(e.message || e))) useEffect(() => { refresh() }, []) useEffect(() => { api.get('/auth/me').then(setMe).catch(() => {}) }, []) useEffect(() => { if (moduleTab !== 'usage') return let alive = true setAllUsageLoading(true) setAllUsageError('') api.get('/kb-usages').then(rows => { if (alive) setAllUsages(Array.isArray(rows) ? rows : []) }) .catch((e: any) => { if (alive) setAllUsageError(String(e.message || e)) }) .finally(() => { if (alive) setAllUsageLoading(false) }) return () => { alive = false } }, [moduleTab]) useEffect(() => { if (id) { const nextId = Number(id); void selectKb(nextId) } else { // 离开详情路由时也要使进行中的请求失效,避免返回列表后旧响应又把详情写回来。 selectRequestRef.current += 1 selRef.current = null setSel(null); setDetail(null); setUsages([]); setUsageSummary(null); setDetailLoading(false) setSelectedUsageIds(new Set()) } }, [id]) const selectKb = async (id: number) => { const requestId = ++selectRequestRef.current // 切换到另一个知识库时先清空旧详情,避免新库请求完成前仍显示旧库文件。 // 同一个知识库刷新(上传、分类操作等)保留旧详情,避免无意义闪烁。 if (selRef.current !== id) { setImportMessages([]); setFolderJob(null) setDetail(null) setUsages([]) setUsageSummary(null) setTab('files') setQuery('') } selRef.current = id setSel(id); setPreview(null); setHits([]); setError(''); setDetailLoading(true) try { // 先展示知识库详情;消费统计不阻塞首次打开,后台补齐即可。 const kbDetail = await api.get(`/kbs/${id}`) if (requestId !== selectRequestRef.current) return setDetail(kbDetail) setSelectedUsageIds(new Set()) void Promise.all([ api.get(`/kbs/${id}/usage`), api.get(`/kbs/${id}/usage/summary`).catch(() => null), ]).then(([usageRows, usageSum]) => { if (requestId !== selectRequestRef.current) return setUsages(usageRows); setUsageSummary(usageSum) }).catch(() => {}) } catch (e: any) { if (requestId !== selectRequestRef.current) return setDetail(null); setError(String(e.message || e)) } finally { if (requestId === selectRequestRef.current) setDetailLoading(false) } } const add = async () => { if (!name.trim()) return setBusy(true); setError('') try { const r = await api.post('/kbs', { name, desc, chunk_target: chunkTarget ? Number(chunkTarget) : 0, chunk_mode: chunkMode }); setName(''); setDesc(''); setShowCreate(false); await refresh(); nav(`/admin/knowledge/${r.id}`) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } const remove = async (kb: any) => { const global = isGlobalResource(kb) && !canManageResource(kb, me) if (!await dialog.confirm({ title: `${global ? '从我的列表移除' : '删除'} 知识库「${kb.name}」?`, description: global ? '仅从当前账号隐藏,不影响其他用户和管理员使用。' : '系统会先自动解除所有 Agent 的知识库挂接,再删除源文件、分类和向量索引。此操作无法恢复。', confirmLabel: global ? '确认移除' : '确认删除', tone: 'danger' })) return setBusy(true); setError('') try { await api.delete(`/kbs/${kb.id}`); if (sel === kb.id) { setSel(null); setDetail(null) }; await refresh() } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } const fork = async (kb: any) => { if (forkBusy) return setForkBusy(kb.id); setError('') try { const result = await forkResource('kbs', Number(kb.id)) await refresh(); nav(`/admin/knowledge/${result.id}`) void dialog.notice({ title: '已创建我的知识库副本', description: '副本中的文件、分类和删除操作只影响你的账号;管理员可在版本审核中查看你的修改。', tone: 'success' }) } catch (e: any) { setError(String(e.message || e)) } finally { setForkBusy(null) } } const waitForImport = async (kbId: number, jobId: string): Promise => { while (uploadAlive.current) { const job = await api.get(`/kbs/${kbId}/import-progress/${jobId}`) if (!uploadAlive.current) return null if (selRef.current === kbId) setFolderJob(job) if (job.status === 'done') return job.result || {} if (job.status === 'error') throw new Error(job.error || '后台入库失败,请重试') await new Promise(resolve => window.setTimeout(resolve, 1500)) } return null } const uploadFolder = async (files: FileList) => { if (!sel || !files.length) return const kbId = sel setBusy(true); setError(''); setUploadPct(0); setImportMessages([]); setFolderJob(null) try { const zipBlob = await buildZip(Array.from(files)) const response = await api.uploadBlob(`/kbs/${kbId}/import-folder`, zipBlob, 'folder.zip', {}, pct => { if (uploadAlive.current) setUploadPct(pct) }) if (!uploadAlive.current) return setUploadPct(null) const result = await waitForImport(kbId, (response as any).job_id) if (result && uploadAlive.current && selRef.current === kbId) { setImportMessages([describeImportResult(result, '文件夹处理完成')]); await selectKb(kbId) } } catch (e: any) { if (uploadAlive.current) setError(String(e.message || e)) } finally { if (uploadAlive.current) { setUploadPct(null); setBusy(false) } } } const uploadOneWithRetry = async (kbId: number, file: File, attempt = 1): Promise => { const dt = new DataTransfer(); dt.items.add(file) try { return await api.upload(`/kbs/${kbId}/import`, dt.files) } catch (e: any) { const msg = String(e.message || e) if (/网络|network|failed to fetch|timeout|timed out|负载|524|502|503/i.test(msg) && attempt < 3 && uploadAlive.current) { await new Promise(resolve => window.setTimeout(resolve, 1500 * attempt)) if (uploadAlive.current) return uploadOneWithRetry(kbId, file, attempt + 1) } throw e } } const uploadFiles = async (files: FileList) => { if (!sel || !files.length) return setBusy(true); setError(''); setImportMessages([]); setFolderJob(null) const kbId = sel, failed: string[] = [], messages: string[] = [] try { const all = Array.from(files) for (let idx = 0; idx < all.length && uploadAlive.current; idx++) { const file = all[idx] try { setUploadPct(Math.round((idx / all.length) * 100)) let result: any if (file.size > 80 * 1024 * 1024) { result = await chunkedUpload(`/kbs/${kbId}/import`, file, { purpose: 'kb', fields: { kb_id: String(kbId) }, onProgress: pct => { if (uploadAlive.current) setUploadPct(Math.round(((idx + pct / 100) / all.length) * 100)) }, }) } else result = await uploadOneWithRetry(kbId, file) if (!uploadAlive.current) return if (result?.background && result.job_id) { setUploadPct(null) result = await waitForImport(kbId, result.job_id) } if (!uploadAlive.current) return if (result) messages.push(describeImportResult(result, file.name)) if (selRef.current === kbId) setImportMessages([...messages]) } catch (reason: any) { failed.push(`${file.name}:${String(reason.message || reason).slice(0, 160)}`) } } if (uploadAlive.current && selRef.current === kbId) { await selectKb(kbId) if (failed.length) setError('以下文件处理失败:\n' + failed.join('\n')) } } finally { if (uploadAlive.current) { setUploadPct(null); setBusy(false); if (fileInputRef) fileInputRef.value = '' } } } const search = async () => { if (!sel || !q.trim()) return setBusy(true); setError('') try { setHits(await api.get(`/kbs/${sel}/search?q=${encodeURIComponent(q)}`)) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } const classifyFiles = async () => { if (!sel || classifyBusy) return setClassifyBusy(true); setClassifyNotice(''); setClassifyError('') try { const result = await api.post(`/kbs/${sel}/classify`, {}) const suggestions = Array.isArray(result?.suggestions) ? result.suggestions.length : 0 const errors = Array.isArray(result?.errors) ? result.errors.length : 0 setClassifyNotice(result?.status === 'empty' ? '知识库暂无文件,上传文件后再生成推荐。' : `已完成分类推荐:${suggestions} 个文件有推荐${errors ? `,${errors} 个文件处理失败` : ''}。请逐条确认或采用全部建议。`) if (errors) setClassifyError(result.errors.slice(0, 3).join(';')) await selectKb(sel) } catch (e: any) { setClassifyError(String(e.message || e)) } finally { setClassifyBusy(false) } } const confirmAiCategory = async (file: any) => { if (!sel || !file.ai_category_id || classifyBusy) return setClassifyBusy(true); setClassifyError('') try { await api.post(`/kbs/${sel}/files/${encodeURIComponent(file.name)}/classification/confirm`, {}); setClassifyNotice(`已采用「${file.name}」的推荐分类:${file.category_path || '推荐分类'}`); await selectKb(sel) } catch (e: any) { setClassifyError(String(e.message || e)) } finally { setClassifyBusy(false) } } const confirmAllAiCategories = async () => { if (!sel || classifyBusy) return const pending = files.filter(file => file.classification_status === 'ai_suggested' && (file as any).ai_category_id) if (!pending.length) { setClassifyNotice('当前没有待确认的 AI 分类建议。'); return } setClassifyBusy(true); setClassifyError('') try { let done = 0 for (const file of pending) { await api.post(`/kbs/${sel}/files/${encodeURIComponent(file.name)}/classification/confirm`, {}); done++ } setClassifyNotice(`已采用全部推荐分类(${done} 个文件)。`); await selectKb(sel) } catch (e: any) { setClassifyError(String(e.message || e)) } finally { setClassifyBusy(false) } } const previewFile = async (file: string) => { const kbId = sel if (!kbId) return const requestId = selectRequestRef.current setBusy(true); setPreviewLoading(true); setPreviewTarget(file); setError('') try { const result = await api.get(`/kbs/${kbId}/files/${encodeURIComponent(file)}`) // 页面已切换知识库时,丢弃旧文件预览响应,避免首击后出现“文件不存在”误导。 if (requestId === selectRequestRef.current) setPreview(result) } catch (e: any) { if (requestId === selectRequestRef.current) setError(String(e.message || e)) } finally { setBusy(false); setPreviewLoading(false); setPreviewTarget('') } } const releaseUsage = async (usage: KbUsageResponse) => { if (!sel || !await dialog.confirm({ title: '重新开放知识片段', description: `这条片段将再次提供给「${usage.agent_name}」。`, confirmLabel: '重新开放' })) return setBusy(true); setError('') try { await api.post(`/kb-usages/${usage.id}/release`); await selectKb(sel) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } const releaseSelectedUsages = async (all = false) => { if (!sel) return const ids = all ? [] : Array.from(selectedUsageIds) if (!all && !ids.length) return if (!await dialog.confirm({ title: all ? '一键重新开放全部消费记录?' : `重新开放选中的 ${ids.length} 条记录?`, description: '开放后这些知识片段会重新回到可用池。', confirmLabel: '确认开放' })) return setBusy(true); setError('') try { await api.post(`/kbs/${sel}/usage/release-batch`, { usage_ids: ids }); setSelectedUsageIds(new Set()); await selectKb(sel) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } const flattenCategories = (nodes: KbCategoryRow[], depth = 0): KbCategoryRow[] => nodes.flatMap(node => [ { ...node, depth }, ...flattenCategories(node.children || [], depth + 1), ]) const categories = detail ? flattenCategories(detail.categories || []) : [] const toggleCategory = (id: number) => setOpenCategoryIds(current => { const next = new Set(current); next.has(id) ? next.delete(id) : next.add(id); return next }) const renderCategoryTree = (nodes: KbCategoryRow[], depth = 0): any => nodes.map((category: any) => { const children = category.children || [] const open = openCategoryIds.has(category.id) return
{children.length ? : } {category.name}{String(category.file_count ?? 0)} 个文件{children.length > 0 ? '(含子分类)' : ''}
{open && children.length ?
{renderCategoryTree(children, depth + 1)}
: null}
}) const addCategory = async () => { if (!sel || !categoryName.trim()) return setBusy(true); setError('') try { await api.post(`/kbs/${sel}/categories`, { name: categoryName, parent_id: categoryParentId || null }); setCategoryName(''); setCategoryParentId(''); await selectKb(sel) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } const editCategory = async (c: any) => { if (!sel) return const next = window.prompt('分类名称', c.name) if (!next || next.trim() === c.name) return setBusy(true); setError('') try { await api.put(`/kbs/${sel}/categories/${c.id}`, { name: next.trim() }); await selectKb(sel) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } const moveCategory = async (c: any, parent: string) => { if (!sel) return setBusy(true); setError('') try { await api.put(`/kbs/${sel}/categories/${c.id}`, { parent_id: parent ? Number(parent) : null }); await selectKb(sel) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } const deleteCategory = async (c: any) => { if (!sel || !await dialog.confirm({ title: `删除分类「${c.name}」?`, description: c.children?.length ? '请先删除或移动子分类。' : ((c.file_count || 0) ? '分类下有文件,删除后文件将变为未分类。' : '此操作不可恢复。'), confirmLabel: '确认删除', tone: 'danger' })) return setBusy(true); setError('') try { await api.delete(`/kbs/${sel}/categories/${c.id}`); await selectKb(sel) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } const moveFile = async (file: KbFileRow, nextCategoryId: string) => { if (!sel) return setBusy(true); setError('') try { await api.put(`/kbs/${sel}/files/${encodeURIComponent(file.name)}/category`, { category_id: nextCategoryId ? Number(nextCategoryId) : null }); await selectKb(sel) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } const deleteFile = async (file: KbFileRow) => { if (!sel || !await dialog.confirm({ title: `删除「${file.name}」?`, description: '向量索引同步清理。', confirmLabel: '确认删除', tone: 'danger' })) return setBusy(true); setError('') try { await api.delete(`/kbs/${sel}/files/${encodeURIComponent(file.name)}`); await selectKb(sel) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } const files: KbFileRow[] = (detail?.files || []) as KbFileRow[] const detailEditable = canManageResource(detail, me) const canWriteKnowledge = canWriteResource('knowledge_base', me) const filteredFiles = useMemo(() => files.filter(f => !query.trim() || f.name.includes(query.trim())), [files, query]) const aiSuggestedFiles = useMemo(() => files.filter(file => file.classification_status === 'ai_suggested' && (file as any).ai_category_id), [files]) const tabs: { key: TabKey; label: string; badge?: number }[] = [ { key: 'files', label: '文件', badge: files.length || undefined }, { key: 'categories', label: '分类', badge: categories.length || undefined }, { key: 'search', label: '检索测试' }, { key: 'usage', label: '消费记录', badge: usages.length || undefined }, { key: 'settings', label: '设置' }, ] const refreshAllUsages = async () => { setAllUsageError('') try { const rows = await api.get('/kb-usages'); setAllUsages(Array.isArray(rows) ? rows : []) } catch (e: any) { setAllUsageError(String(e.message || e)); throw e } } const releaseAnyUsage = async (usage: any) => { if (!await dialog.confirm({ title: '重新开放知识片段', description: `这条片段将再次提供给「${usage.agent_name || '当前 Agent'}」。`, confirmLabel: '重新开放' })) return setBusy(true); setError('') try { await api.post(`/kb-usages/${usage.id}/release`); await refreshAllUsages() } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } const releaseAllUsages = async (all = false) => { const actionable = allUsages.filter(row => row.status === 'consumed' && row.can_release !== false) const ids = all ? [] : Array.from(selectedUsageIds).filter(id => actionable.some(row => row.id === id)) if (!all && !ids.length) return if (!await dialog.confirm({ title: all ? '一键重新开放全部消费记录?' : `重新开放选中的 ${ids.length} 条记录?`, description: '开放后这些知识片段会重新回到可用池。', confirmLabel: '确认开放' })) return setBusy(true); setError('') let done = 0, failed: string[] = [] try { for (const kb of kbs) { const eligible = actionable.filter(row => row.kb_id === kb.id) const kbIds = all ? [] : eligible.filter(row => ids.includes(row.id)).map(row => row.id) if (!all && !kbIds.length) continue if (all && !eligible.length) continue try { const result = await api.post(`/kbs/${kb.id}/usage/release-batch`, { usage_ids: kbIds }); done += Number(result?.released || (all ? eligible.length : kbIds.length)) } catch (e: any) { failed.push(`${kb.name}:${String(e.message || e)}`) } } setSelectedUsageIds(new Set()); await refreshAllUsages() if (failed.length) setError(`已开放 ${done} 条,${failed.length} 个知识库失败:${failed.join(';')}`) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } if (moduleTab === 'tags') return if (moduleTab === 'usage') return
知识资产} />
{error &&
{error}
} {allUsageError &&
消费记录加载失败:{allUsageError}
} {allUsageLoading ?
正在读取所有知识库消费记录…
: !allUsageError && void releaseAllUsages(true)} onReleaseSelected={() => void releaseAllUsages(false)} onRelease={usage => void releaseAnyUsage(usage)} />} {!allUsageLoading && !allUsageError && !allUsages.length &&
暂无消费记录——生成内容任务后,这里会显示所有知识库被引用的片段。
}
return
知识资产} />
{error &&
{error}
} {!detail && detailLoading &&
正在打开知识库…正在读取文件和分类信息,请稍候。
} {!detail && !detailLoading && } {detail && <>

{detail.name}

{detail.index_status === 'ready' ? '索引就绪' : detail.index_status} {files.length} 文件 · {detail.chunk_count ?? 0} 块
{detailEditable ? <> : null} e.target.files && uploadFolder(e.target.files)} /> e.target.files && uploadFiles(e.target.files)} /> {canManageResource(detail, me) ? : isGlobalResource(detail) && canWriteKnowledge ? <> : 当前知识库为只读资源}
{/* @ts-ignore webkitdirectory */} { if (el) { (el as any).webkitdirectory = true; setFolderInputRef(el) } }} type="file" multiple style={{ display: 'none' }} onChange={e => e.target.files && uploadFolder(e.target.files)} />
单个文件超过 80MB 会自动分片上传,公网单文件支持至 20GB;文件夹上传请先压缩并受公网通道限制。同一知识库的重复文件会自动跳过,文件内完全重复的同标题片段只索引一次。
{importMessages.length > 0 &&
上传处理结果
    {importMessages.map((message, index) =>
  • {message}
  • )}
} {uploadPct !== null &&
上传中 {uploadPct}%
} {folderJob &&
{folderJob.status === 'running' && ⏳ 正在处理:{folderJob.current || '准备中'}{folderJob.total ? `(${folderJob.files_done}/${folderJob.total})` : ''}} {folderJob.status === 'done' && ✓ 导入完成} {folderJob.status === 'error' && 失败:{folderJob.error}}
}
{tabs.map(t => )}
{tab === 'files' &&
setQuery(e.target.value)} />
{detailEditable && <> }
{classifyBusy &&
正在处理分类建议请等待本次操作完成
AI 推荐会读取文件并匹配分类;采用建议后会更新文件归属。
} {(classifyNotice || classifyError) &&
{classifyError || classifyNotice}{classifyError && }
} {!!aiSuggestedFiles.length &&
✦
待确认的 AI 分类建议({aiSuggestedFiles.length})AI 只生成建议,不会自动改变文件归属;请逐条采用或一次采用全部建议。
} {filteredFiles.map((f: any) =>
{f.chunk_count ?? 0} 块 {f.classification_status === 'ai_suggested' && ✦ AI 推荐:{f.category_path || '待确认'}{f.ai_confidence ? `(${Math.round(f.ai_confidence * 100)}%)` : ''}{f.ai_reason ? ` · ${f.ai_reason}` : ''}} {f.classification_status === 'ai_suggested' && } {detailEditable && }
)} {!filteredFiles.length &&
没有文件。用顶部「上传文件夹」导入。
}
} {tab === 'categories' &&
setCategoryName(e.target.value)} style={{ maxWidth: 200 }} /> {detailEditable && }
{renderCategoryTree(detail.categories || [])}
{!categories.length &&
暂无分类。
}
} {tab === 'search' &&
setQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && search()} style={{ flex: 1 }} />
{hits.map((h: any, i: number) =>
{`#${i + 1} · ${(h.meta && h.meta.file) || h.file || '—'}`}

{String(h.text || h).slice(0, 220)}

)}
} {tab === 'usage' &&
{usageSummary && (() => { const total = usageSummary.total_chunks || 0 const consumed = usageSummary.consumed || 0 const pct = total ? Math.round(consumed / total * 100) : 0 return <>
{total} 总块 {consumed} 已被消费 {usageSummary.released || 0} 已重新开放 {usageSummary.remaining ?? 0} 剩余可用

同一片段只对消费它的 Agent 锁定;其他 Agent 仍可用。「重新开放」后片段会回到可用池。

{!!usageSummary.by_agent?.length &&
按 Agent 统计 {usageSummary.by_agent.map((a: any) => (
{a.agent_name} 已消费 {a.used} 段{a.released ? ` · 已重新开放 ${a.released}` : ''}
))}
} })()} void releaseSelectedUsages(true)} onReleaseSelected={() => void releaseSelectedUsages(false)} onRelease={usage => void releaseUsage(usage as KbUsageResponse)} /> {!usages.length &&
暂无消费记录——生成任务后这里会显示每个 Agent 用了哪些知识。
}
} {tab === 'settings' &&
块数

{String(detail.chunk_count ?? 0)}

索引状态

{detail.index_status}

描述

{detail.desc || '—'}

} }
{preview &&
setPreview(null)}>
}
}