feat(web): add React workbench administration UI

This commit is contained in:
yi
2026-09-16 19:06:21 +08:00
parent d4b5b3f43a
commit 85f6e3be86
68 changed files with 14263 additions and 0 deletions
+665
View File
@@ -0,0 +1,665 @@
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<number> {
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<Blob> {
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<any[]>([])
const [me, setMe] = useState<any>(null)
const [forkBusy, setForkBusy] = useState<number | null>(null)
const [sel, setSel] = useState<number | null>(id ? Number(id) : null)
const selRef = useRef<number | null>(id ? Number(id) : null)
const [detail, setDetail] = useState<any>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [usages, setUsages] = useState<any[]>([])
const [allUsages, setAllUsages] = useState<any[]>([])
const [allUsageLoading, setAllUsageLoading] = useState(false)
const [allUsageError, setAllUsageError] = useState('')
const [usageSummary, setUsageSummary] = useState<any>(null)
const [selectedUsageIds, setSelectedUsageIds] = useState<Set<number>>(new Set())
const [openCategoryIds, setOpenCategoryIds] = useState<Set<number>>(new Set())
const [tab, setTab] = useState<TabKey>('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<number | ''>('')
const [chunkMode, setChunkMode] = useState<'auto' | 'heading'>('auto')
const [uploadPct, setUploadPct] = useState<number | null>(null)
const [folderJob, setFolderJob] = useState<any>(null)
const [importMessages, setImportMessages] = useState<string[]>([])
const uploadAlive = useRef(false)
useEffect(() => { uploadAlive.current = true; return () => { uploadAlive.current = false } }, [])
const [fileInputRef, setFileInputRef] = useState<HTMLInputElement | null>(null)
const [folderInputRef, setFolderInputRef] = useState<HTMLInputElement | null>(null)
// 检索
const [q, setQ] = useState('')
const [hits, setHits] = useState<any[]>([])
// 分类
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<any>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewTarget, setPreviewTarget] = useState('')
// 路由切换或开发模式 StrictMode 可能让详情请求重叠;仅允许最后一次请求更新视图。
const selectRequestRef = useRef(0)
const refresh = () => api.get<any[]>('/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<any[]>('/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<KbDetailResponse>(`/kbs/${id}`)
if (requestId !== selectRequestRef.current) return
setDetail(kbDetail)
setSelectedUsageIds(new Set())
void Promise.all([
api.get<KbUsageResponse[]>(`/kbs/${id}/usage`),
api.get<any>(`/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<any>('/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<ImportResult | null> => {
while (uploadAlive.current) {
const job = await api.get<any>(`/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<any> => {
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<any>(`/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 <div key={category.id} className="category-drawer-row" style={{ marginLeft: depth * 16 }}>
<div className="category-drawer-head">
{children.length ? <button className="mini category-chevron" onClick={() => toggleCategory(category.id)} aria-label={open ? '收起分类' : '展开分类'}>{open ? '▾' : '▸'}</button> : <span className="category-chevron-spacer" />}
<span className="category-drawer-name">{category.name}</span><span className="dim">{String(category.file_count ?? 0)} 个文件{children.length > 0 ? '(含子分类)' : ''}</span>
<button className="mini" onClick={() => editCategory(category)}>重命名</button><button className="mini danger-text" onClick={() => deleteCategory(category)}>删除</button>
</div>
{open && children.length ? <div className="category-drawer-children">{renderCategoryTree(children, depth + 1)}</div> : null}
</div>
})
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<any[]>('/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<any>(`/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 <TagsPage />
if (moduleTab === 'usage') return <div className="all-usage-page">
<PageHeader title="知识库消费记录" description="查看所有知识库的引用和消费情况,按状态抽屉分类管理。" meta={<span className="scope-chip">知识资产</span>} />
<div className="detail-tabs" role="tablist">
<button role="tab" className="detail-tab" onClick={() => nav('/admin/knowledge')}>知识库</button>
<button role="tab" className="detail-tab active" aria-selected="true">消费记录{allUsages.length ? <span className="tab-badge">{allUsages.length}</span> : null}</button>
<button role="tab" className="detail-tab" onClick={() => nav('/admin/knowledge?tab=tags')}>标签管理</button>
</div>
{error && <div className="err">{error}</div>}
{allUsageError && <div className="err">消费记录加载失败:{allUsageError} <button className="mini" onClick={() => { setAllUsageError(''); void refreshAllUsages() }}>重试</button></div>}
{allUsageLoading ? <div className="all-usage-loading">正在读取所有知识库消费记录…</div> : !allUsageError && <KnowledgeUsageList usages={allUsages} selected={selectedUsageIds} busy={busy} onSelect={setSelectedUsageIds} onReleaseAll={() => void releaseAllUsages(true)} onReleaseSelected={() => void releaseAllUsages(false)} onRelease={usage => void releaseAnyUsage(usage)} />}
{!allUsageLoading && !allUsageError && !allUsages.length && <div className="empty-state">暂无消费记录——生成内容任务后,这里会显示所有知识库被引用的片段。</div>}
</div>
return <div className="knowledge-page">
<PageHeader title="知识库" description="从素材入库到引用追踪,集中维护内容生产的知识来源。" meta={<span className="scope-chip">知识资产</span>} />
<div className="detail-tabs" role="tablist">
<button role="tab" aria-selected={moduleTab === 'kbs'} className={`detail-tab ${moduleTab === 'kbs' ? 'active' : ''}`} onClick={() => nav('/admin/knowledge')}>知识库</button>
<button role="tab" aria-selected={false} className="detail-tab" onClick={() => nav('/admin/knowledge?tab=usage')}>消费记录{allUsages.length ? <span className="tab-badge">{allUsages.length}</span> : null}</button>
<button role="tab" aria-selected={false} className="detail-tab" onClick={() => nav('/admin/knowledge?tab=tags')}>标签管理</button>
</div>
<div className="knowledge-split">
<aside className="kb-list-panel">
<div className="collection-heading"><h2>知识库目录</h2><span>{kbs.length} 个</span></div>
<div className="page-toolbar">
<div className="toolbar-search"><Search size={14} /><input placeholder="搜索知识库…" onChange={e => setQuery(e.target.value)} /></div>
<button className="primary-action" onClick={() => setShowCreate(v => !v)}><Plus size={14} /> 新建</button>
</div>
{showCreate && <div className="card kb-create-card">
<input placeholder="知识库名称" value={name} onChange={e => setName(e.target.value)} />
<input placeholder="描述(可选)" value={desc} onChange={e => setDesc(e.target.value)} />
<div className="two-select-row">
<label className="duration-field">分段策略
<select value={chunkMode} onChange={e => setChunkMode(e.target.value as 'auto' | 'heading')}>
<option value="auto">按长度聚合(自动)</option>
<option value="heading">按标题切分</option>
</select>
</label>
<label className="duration-field">分段目标长度
<select value={chunkTarget} onChange={e => setChunkTarget(e.target.value ? Number(e.target.value) : '')}>
<option value="">自动(小段 500 字)</option>
<option value={1200}>1,200 字</option>
<option value={2200}>2,200 字(口播长文)</option>
<option value={4000}>4,000 字</option>
</select>
</label>
</div>
<div className="btns"><button className="ok" onClick={add} disabled={busy || !name.trim()}>创建</button><button className="mini" onClick={() => setShowCreate(false)}>取消</button></div>
</div>}
{kbs.map(kb => (
<div key={kb.id} className={`agent-row ${sel === kb.id ? 'active' : ''}`} role="button" tabIndex={0}
onClick={() => nav(`/admin/knowledge/${kb.id}`)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); nav(`/admin/knowledge/${kb.id}`) } }}>
<div style={{ minWidth: 0, flex: 1 }}><b>{kb.name}</b><span className="dim">{kb.file_count ?? 0} 文件 · {kb.chunk_count ?? 0} 块</span><OwnerLabel resource={kb} /></div>
{kb.index_status === 'ready' && <span className="tag g">就绪</span>}
{canManageResource(kb, me) ? <button className="icon-button danger-text" aria-label={`删除知识库 ${kb.name}`} title="删除知识库" disabled={busy}
onClick={e => { e.stopPropagation(); void remove(kb) }}><Trash2 size={14} /></button> : isGlobalResource(kb) && canWriteKnowledge ? <><button className="mini" onClick={e => { e.stopPropagation(); void fork(kb) }} disabled={busy || forkBusy === kb.id}>{forkBusy === kb.id ? '创建中…' : '创建我的副本'}</button><button className="mini danger-text" onClick={e => { e.stopPropagation(); void remove(kb) }} disabled={busy}>从我的列表移除</button></> : <span className="field-help">只读</span>}
</div>
))}
{!kbs.length && <div className="empty-state">还没有知识库</div>}
</aside>
<section className="kb-detail-panel">
{error && <div className="err">{error}</div>}
{!detail && detailLoading && <div className="empty-workspace compact"><strong>正在打开知识库…</strong><span>正在读取文件和分类信息,请稍候。</span></div>}
{!detail && !detailLoading && <EmptyWorkspace title="让内容有据可查" description="选择知识库管理文件、分类和引用;新建空库不会调用 AI。" steps={["收集:上传文档与素材文件夹", "整理:维护分类和分段策略", "追踪:查看检索片段与消费记录"]} />}
{detail && <>
<div className="detail-header">
<div className="detail-title">
<h1>{detail.name}</h1>
<OwnerLabel resource={detail} />
<div className="detail-meta">
<span className="tag g">{detail.index_status === 'ready' ? '索引就绪' : detail.index_status}</span>
<span className="dim">{files.length} 文件 · {detail.chunk_count ?? 0} 块</span>
</div>
</div>
<div className="detail-actions">
{detailEditable ? <><button className="primary-action" onClick={() => folderInputRef?.click()} disabled={busy}>📁 上传文件夹</button>
<button onClick={() => fileInputRef?.click()} disabled={busy}>上传文件</button></> : null}
<input ref={setFolderInputRef} type="file" multiple style={{ display: 'none' }} onChange={e => e.target.files && uploadFolder(e.target.files)} />
<input ref={setFileInputRef} type="file" multiple accept=".md,.txt,.docx,.doc,.pdf,.srt,.json,.jsonl,.mp4,.mov,.flv,.mkv,.avi,.webm,.mp3,.wav,.m4a,.aac,.ogg,.flac" style={{ display: 'none' }} onChange={e => e.target.files && uploadFiles(e.target.files)} />
{canManageResource(detail, me) ? <button className="danger-button" onClick={() => remove(detail)} disabled={busy}><Trash2 size={14} /> 删除知识库</button> : isGlobalResource(detail) && canWriteKnowledge ? <><button className="primary-action" onClick={() => void fork(detail)} disabled={busy || forkBusy === detail.id}>{forkBusy === detail.id ? '正在创建副本…' : '创建我的副本并编辑'}</button><button className="mini danger-text" onClick={() => remove(detail)} disabled={busy}>从我的列表移除</button></> : <span className="field-help">当前知识库为只读资源</span>}
</div>
</div>
{/* @ts-ignore webkitdirectory */}
<input ref={el => { if (el) { (el as any).webkitdirectory = true; setFolderInputRef(el) } }} type="file" multiple style={{ display: 'none' }} onChange={e => e.target.files && uploadFolder(e.target.files)} />
<div className="dim" style={{ margin: '6px 0 10px', fontSize: 12 }}>单个文件超过 80MB 会自动分片上传,公网单文件支持至 20GB;文件夹上传请先压缩并受公网通道限制。同一知识库的重复文件会自动跳过,文件内完全重复的同标题片段只索引一次。</div>
{importMessages.length > 0 && <div className="card" role="status" aria-live="polite" style={{ padding: '10px 14px', marginBottom: 10 }}><strong>上传处理结果</strong><ul>{importMessages.map((message, index) => <li key={index}>{message}</li>)}</ul></div>}
{uploadPct !== null && <div className="task-progress"><div className="task-progress-head"><span>上传中 {uploadPct}%</span></div><div className="task-progress-track"><div className="task-progress-fill" style={{ width: `${uploadPct}%` }} /></div></div>}
{folderJob && <div className="card" style={{ padding: '10px 14px', marginBottom: 10 }}>
{folderJob.status === 'running' && <span className="folder-job-line">⏳ 正在处理:{folderJob.current || '准备中'}{folderJob.total ? `(${folderJob.files_done}/${folderJob.total})` : ''}</span>}
{folderJob.status === 'done' && <span className="folder-job-line ok">✓ 导入完成</span>}
{folderJob.status === 'error' && <span className="folder-job-line err">失败:{folderJob.error}</span>}
</div>}
<div className="detail-tabs" role="tablist">
{tabs.map(t => <button key={t.key} role="tab" aria-selected={tab === t.key} className={`detail-tab ${tab === t.key ? 'active' : ''}`} onClick={() => setTab(t.key)}>{t.label}{t.badge ? <span className="tab-badge">{t.badge}</span> : null}</button>)}
</div>
{tab === 'files' && <div>
<div className="page-toolbar kb-classify-toolbar">
<div className="toolbar-search"><Search size={14} /><input placeholder="搜索文件名…" value={query} onChange={e => setQuery(e.target.value)} /></div>
<div className="kb-classify-actions">
{detailEditable && <><button className="mini" onClick={() => void classifyFiles()} disabled={busy || classifyBusy || !files.length}>{classifyBusy ? 'AI 推荐处理中…' : '✦ AI 推荐分类'}</button>
<button className="mini" onClick={() => void confirmAllAiCategories()} disabled={busy || classifyBusy || !aiSuggestedFiles.length}>采用全部建议 <em>{aiSuggestedFiles.length || ''}</em></button></>}
</div>
</div>
{classifyBusy && <div className="inline-ai-progress" role="status" aria-live="polite"><div><strong>正在处理分类建议</strong><span>请等待本次操作完成</span></div><div className="ai-progress-track"><div className="ai-progress-indeterminate" /></div><small>AI 推荐会读取文件并匹配分类;采用建议后会更新文件归属。</small></div>}
{(classifyNotice || classifyError) && <div className={`kb-classify-notice ${classifyError ? 'error' : ''}`} role="status" aria-live="polite"><span>{classifyError || classifyNotice}</span>{classifyError && <button className="mini" onClick={() => setClassifyError('')}>关闭</button>}</div>}
{!!aiSuggestedFiles.length && <div className="kb-recommendation-summary"><span className="kb-recommendation-icon">✦</span><div><b>待确认的 AI 分类建议({aiSuggestedFiles.length})</b><small>AI 只生成建议,不会自动改变文件归属;请逐条采用或一次采用全部建议。</small></div><button className="mini" onClick={() => void confirmAllAiCategories()} disabled={busy || classifyBusy}>一键采用推荐</button></div>}
{filteredFiles.map((f: any) => <div key={f.name} className="file-row card" style={{ padding: '8px 12px', marginBottom: 6 }}>
<button className="link-like" onClick={() => previewFile(f.name)}><b>{f.name}</b></button>
<span className="dim">{f.chunk_count ?? 0} 块</span>
{f.classification_status === 'ai_suggested' && <span className="ai-category-hint" title={f.ai_reason || 'AI 根据文件内容推荐'}>✦ AI 推荐:{f.category_path || '待确认'}{f.ai_confidence ? `(${Math.round(f.ai_confidence * 100)}%)` : ''}{f.ai_reason ? ` · ${f.ai_reason}` : ''}</span>}
<select value={f.category_id || ''} onChange={e => moveFile(f, e.target.value)} style={{ maxWidth: 160 }} disabled={!detailEditable}>
<option value="">未分类</option>
{categories.map(c => <option key={c.id} value={c.id}>{' '.repeat(c.depth)}{c.name}</option>)}
</select>
<button className="mini kb-view-button" onClick={() => void previewFile(f.name)} disabled={previewLoading || busy} aria-label={`查看知识文件 ${f.name}`}>{previewLoading && previewTarget === f.name ? '读取中…' : '查看'}</button>
{f.classification_status === 'ai_suggested' && <button className="mini recommend-accept" onClick={() => void confirmAiCategory(f)} disabled={!detailEditable || busy || classifyBusy}>采用推荐</button>}
{detailEditable && <button className="mini danger-text" onClick={() => deleteFile(f)}>删除</button>}
</div>)}
{!filteredFiles.length && <div className="empty-state">没有文件。用顶部「上传文件夹」导入。</div>}
</div>}
{tab === 'categories' && <div className="card agent-form">
<div className="btns">
<input placeholder="新分类名" value={categoryName} onChange={e => setCategoryName(e.target.value)} style={{ maxWidth: 200 }} />
<select value={categoryParentId} onChange={e => setCategoryParentId(e.target.value)} style={{ maxWidth: 160 }}>
<option value="">作为顶级分类</option>
{categories.map(c => <option key={c.id} value={c.id}>{' '.repeat(c.depth)}{c.name}</option>)}
</select>
{detailEditable && <button className="ok" onClick={addCategory} disabled={busy || !categoryName.trim()}>添加分类</button>}
</div>
<div className="category-drawer">{renderCategoryTree(detail.categories || [])}</div>
{!categories.length && <div className="empty-state">暂无分类。</div>}
</div>}
{tab === 'search' && <div className="card agent-form">
<div className="btns">
<input placeholder="输入查询,验证检索效果…" value={q} onChange={e => setQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && search()} style={{ flex: 1 }} />
<button className="ok" onClick={search} disabled={busy || !q.trim()}>检索</button>
</div>
{hits.map((h: any, i: number) => <div key={i} className="kv-list"><div><span>{`#${i + 1} · ${(h.meta && h.meta.file) || h.file || '—'}`}</span><p>{String(h.text || h).slice(0, 220)}</p></div></div>)}
</div>}
{tab === 'usage' && <div>
{usageSummary && (() => {
const total = usageSummary.total_chunks || 0
const consumed = usageSummary.consumed || 0
const pct = total ? Math.round(consumed / total * 100) : 0
return <>
<div className="card" style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', gap: 18, alignItems: 'baseline', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13 }}><b>{total}</b> <span className="dim">总块</span></span>
<span style={{ fontSize: 13 }}><b style={{ color: '#b04a4a' }}>{consumed}</b> <span className="dim">已被消费</span></span>
<span style={{ fontSize: 13 }}><b>{usageSummary.released || 0}</b> <span className="dim">已重新开放</span></span>
<span style={{ fontSize: 13 }}><b style={{ color: 'var(--accent)' }}>{usageSummary.remaining ?? 0}</b> <span className="dim">剩余可用</span></span>
</div>
<div style={{ height: 6, background: 'var(--border)', borderRadius: 3, marginTop: 8, overflow: 'hidden' }}>
<div style={{ height: '100%', width: pct + '%', background: '#b04a4a' }} />
</div>
<p className="field-help" style={{ marginTop: 6 }}>同一片段只对消费它的 Agent 锁定;其他 Agent 仍可用。「重新开放」后片段会回到可用池。</p>
</div>
{!!usageSummary.by_agent?.length && <div className="card" style={{ marginBottom: 10 }}>
<b style={{ fontSize: 13 }}>按 Agent 统计</b>
{usageSummary.by_agent.map((a: any) => (
<div key={a.agent_id} className="row" style={{ marginTop: 6 }}>
<b>{a.agent_name}</b>
<span className="dim">已消费 {a.used} 段{a.released ? ` · 已重新开放 ${a.released}` : ''}</span>
</div>
))}
</div>}
</>
})()}
<KnowledgeUsageList key={sel} usages={usages} selected={selectedUsageIds} busy={busy} onSelect={setSelectedUsageIds} onReleaseAll={() => void releaseSelectedUsages(true)} onReleaseSelected={() => void releaseSelectedUsages(false)} onRelease={usage => void releaseUsage(usage as KbUsageResponse)} />
{!usages.length && <div className="empty-state">暂无消费记录——生成任务后这里会显示每个 Agent 用了哪些知识。</div>}
</div>}
{tab === 'settings' && <div className="card agent-form">
<div className="kv-list">
<div><span>块数</span><p>{String(detail.chunk_count ?? 0)}</p></div>
<div><span>索引状态</span><p>{detail.index_status}</p></div>
<div><span>描述</span><p>{detail.desc || '—'}</p></div>
</div>
<div className="btns"><button className="danger-button" onClick={() => remove(detail)}>删除此知识库</button></div>
</div>}
</>}
</section>
</div>
{preview && <div className="kb-preview-mask" role="presentation" onClick={() => setPreview(null)}>
<aside className="kb-preview-drawer" role="dialog" aria-modal="true" aria-label={`知识文件预览:${preview.file || preview.name || '文件'}`} onClick={e => e.stopPropagation()}>
<header className="kb-preview-head">
<div style={{ minWidth: 0 }}>
<strong>知识文件预览</strong>
<span title={preview.file || preview.name || ''}>{preview.file || preview.name || '未命名文件'}</span>
</div>
<button className="drawer-close" onClick={() => setPreview(null)} aria-label="关闭预览">×</button>
</header>
<div className="kb-preview-meta">
{detail?.name && <span>知识库:{detail.name}</span>}
{preview.characters != null && <span>{Number(preview.characters).toLocaleString('zh-CN')} 字</span>}
{preview.truncated && <span className="tag">仅显示前 50,000 字</span>}
{preview.category_path && <span>分类:{preview.category_path}</span>}
</div>
<div className="kb-preview-content">
<pre className="art">{typeof preview === 'string' ? preview : (preview.text ?? preview.content ?? '该文件没有可解析的文字内容。')}</pre>
</div>
</aside>
</div>}
</div>
}