Files
yawan-workbench/web/src/pages/KnowledgePage.tsx
T

666 lines
46 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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>
}