Files
Mutual_Fund/frontend/app/tools/knowledge/page.tsx
T

413 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import {
ArrowLeft,
CheckCircle2,
FileText,
Loader2,
ScanEye,
Trash2,
Upload,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
Badge,
Button,
Card,
EmptyState,
ErrorState,
TextInput,
LoadingBlock,
Select,
} from "@/components/admin/ui";
import { adminApi, errMessage } from "@/lib/admin-api";
import {
buildKnowledgeForm,
collectionLabel,
DEFAULT_KNOWLEDGE_COLLECTION,
KNOWLEDGE_COLLECTIONS,
knowledgeApi,
knowledgeErrorHint,
type KnowledgeDocument,
type UploadPreview,
} from "@/lib/knowledge-api";
import { isAdmin } from "@/lib/roles";
/** 切分策略,与后端 KnowledgeUploadService 支持的策略保持一致。 */
const STRATEGIES = [
{ value: "default", label: "默认(按段落递归切分)" },
{ value: "fixed", label: "固定长度切分" },
{ value: "sentence", label: "按句子切分" },
];
export default function KnowledgePage() {
const [state, setState] = useState<"loading" | "ready" | "denied" | "error">("loading");
const [items, setItems] = useState<KnowledgeDocument[]>([]);
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [collection, setCollection] = useState<string>(DEFAULT_KNOWLEDGE_COLLECTION);
const [strategy, setStrategy] = useState("default");
const [chunkSize, setChunkSize] = useState("");
const [chunkOverlap, setChunkOverlap] = useState("");
const [preview, setPreview] = useState<UploadPreview | null>(null);
const [uploading, setUploading] = useState(false);
const [removing, setRemoving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setItems(await knowledgeApi.list());
} catch (err) {
setError(errMessage(err, "知识库加载失败"));
}
}, []);
useEffect(() => {
let alive = true;
(async () => {
try {
const user = await adminApi.me();
if (!alive) return;
if (!isAdmin(user)) {
setState("denied");
return;
}
setState("ready");
await load();
} catch (err) {
if (!alive) return;
// 401 时 apiFetch 已跳转登录页,这里只兜底其余失败
setError(errMessage(err, "请先登录管理员账号"));
setState("error");
}
})();
return () => {
alive = false;
};
}, [load]);
const resetUpload = () => {
setFile(null);
setTitle("");
setChunkSize("");
setChunkOverlap("");
setPreview(null);
};
const runPreview = async () => {
if (!file || uploading) return;
setUploading(true);
setError(null);
setNotice(null);
try {
const result = await knowledgeApi.preview(
buildKnowledgeForm(file, {
collection_name: collection,
strategy,
chunk_size: chunkSize ? Number(chunkSize) : null,
chunk_overlap: chunkOverlap ? Number(chunkOverlap) : null,
})
);
setPreview(result);
if (!title) setTitle(file.name);
} catch (err) {
setError(knowledgeErrorHint(errMessage(err, "预览失败,请检查文件格式")));
} finally {
setUploading(false);
}
};
const runConfirm = async () => {
if (!file || uploading) return;
// 集合是后端白名单校验的必填项,缺了会直接 400「知识库集合不在允许范围内」
if (!collection) {
setError("请先选择知识库集合");
return;
}
setUploading(true);
setError(null);
setNotice(null);
try {
await knowledgeApi.confirm(
buildKnowledgeForm(file, {
title: title || file.name,
doc_id: preview?.doc_id ? String(preview.doc_id) : undefined,
collection_name: collection,
strategy,
chunk_size: chunkSize ? Number(chunkSize) : null,
chunk_overlap: chunkOverlap ? Number(chunkOverlap) : null,
})
);
setNotice(`《${title || file.name}》已入库到「${collectionLabel(collection)}」`);
resetUpload();
await load();
} catch (err) {
setError(knowledgeErrorHint(errMessage(err, "入库失败,请稍后重试")));
} finally {
setUploading(false);
}
};
const remove = async (doc: KnowledgeDocument) => {
const docId = doc.doc_id ?? doc.id ?? "";
if (!docId) return;
if (!window.confirm(`确认删除《${doc.title ?? doc.filename ?? docId}》?该操作不可撤销。`)) {
return;
}
setRemoving(docId);
setError(null);
try {
await knowledgeApi.remove(docId);
setNotice("文档已删除");
await load();
} catch (err) {
setError(errMessage(err, "删除失败,请稍后重试"));
} finally {
setRemoving(null);
}
};
return (
<main className="min-h-screen bg-slate-50 px-6 py-8">
<div className="mx-auto max-w-5xl space-y-5">
<Link
href="/admin"
className="inline-flex items-center gap-2 text-sm text-slate-500 transition-colors hover:text-slate-800"
>
<ArrowLeft className="h-4 w-4" />
返回统一工作台
</Link>
<div>
<h1 className="text-2xl font-semibold text-slate-900">知识库管理</h1>
<p className="mt-1 text-sm text-slate-500">
上传投顾 / 客服 Agent 可检索的文档,入库前可先预览切分效果
</p>
</div>
{state === "loading" && <LoadingBlock text="正在校验权限..." />}
{state === "denied" && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-800">
知识库管理仅对管理员开放。当前账号无操作权限,如有需要请联系系统管理员。
</div>
)}
{state === "error" && <ErrorState message={error ?? "加载失败"} onRetry={() => location.reload()} />}
{state === "ready" && (
<>
<Card
title="上传文档"
description="先预览切分效果,确认无误后再入库"
>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<label className="cursor-pointer">
<input
type="file"
className="hidden"
onChange={(event) => {
const picked = event.target.files?.[0] ?? null;
setFile(picked);
setPreview(null);
if (picked) setTitle(picked.name);
}}
/>
<span className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-3.5 py-2 text-sm text-slate-600 transition-colors hover:border-indigo-300 hover:text-indigo-600">
<Upload className="h-4 w-4" />
选择文件
</span>
</label>
<span className="text-sm text-slate-500">
{file ? file.name : "未选择文件"}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<label className="space-y-1.5">
<span className="text-xs text-slate-500">标题</span>
<TextInput
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="用于检索展示的标题"
/>
</label>
<label className="space-y-1.5">
<span className="text-xs text-slate-500">
知识库集合 <span className="text-rose-500">*</span>
</span>
<Select
value={collection}
onChange={(event) => setCollection(event.target.value)}
>
{KNOWLEDGE_COLLECTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}({item.value})
</option>
))}
</Select>
<span className="block text-[11px] leading-4 text-slate-400">
{KNOWLEDGE_COLLECTIONS.find((item) => item.value === collection)
?.description ?? "决定文档写入哪个向量集合,入库后不可修改"}
</span>
</label>
<label className="space-y-1.5">
<span className="text-xs text-slate-500">切分策略</span>
<Select
value={strategy}
onChange={(event) => setStrategy(event.target.value)}
>
{STRATEGIES.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
</label>
<div className="grid grid-cols-2 gap-2">
<label className="space-y-1.5">
<span className="text-xs text-slate-500">分片长度</span>
<TextInput
value={chunkSize}
onChange={(event) => setChunkSize(event.target.value.replace(/\D/g, ""))}
placeholder="留空用默认"
inputMode="numeric"
/>
</label>
<label className="space-y-1.5">
<span className="text-xs text-slate-500">重叠长度</span>
<TextInput
value={chunkOverlap}
onChange={(event) => setChunkOverlap(event.target.value.replace(/\D/g, ""))}
placeholder="留空用默认"
inputMode="numeric"
/>
</label>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
disabled={!file}
loading={uploading && !preview}
onClick={() => void runPreview()}
>
<ScanEye className="h-4 w-4" />
预览切分
</Button>
<Button
variant="primary"
disabled={!file}
loading={uploading && !!preview}
onClick={() => void runConfirm()}
>
<CheckCircle2 className="h-4 w-4" />
确认入库
</Button>
{file && (
<Button variant="ghost" onClick={resetUpload}>
取消
</Button>
)}
</div>
{preview && (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<Badge tone="info">{collectionLabel(collection)}</Badge>
<Badge tone="info">{preview.strategy || strategy}</Badge>
{preview.chunk_size != null && <span>分片 {preview.chunk_size}</span>}
{preview.chunk_overlap != null && <span>重叠 {preview.chunk_overlap}</span>}
<span className="font-medium text-slate-700">
共切出 {preview.chunks?.length ?? 0} 段
</span>
</div>
<div className="max-h-48 space-y-1.5 overflow-y-auto">
{(preview.chunks ?? []).slice(0, 20).map((chunk, index) => (
<p
key={index}
className="rounded bg-white px-3 py-2 text-xs leading-6 text-slate-600"
>
{typeof chunk === "string" ? chunk : JSON.stringify(chunk)}
</p>
))}
{(preview.chunks?.length ?? 0) > 20 && (
<p className="px-3 text-xs text-slate-400">
仅展示前 20 段,共 {preview.chunks?.length} 段
</p>
)}
</div>
</div>
)}
</div>
</Card>
{error && <ErrorState message={error} />}
{notice && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{notice}
</div>
)}
<Card title="已入库文档" description={`共 ${items.length} 篇`}>
{items.length === 0 ? (
<EmptyState text="暂无知识库文档,上传后可被投顾与客服 Agent 检索" />
) : (
<div className="divide-y divide-slate-100">
{items.map((item) => {
const docId = item.doc_id ?? item.id ?? "";
const busy = removing === docId;
return (
<div
key={docId}
className="flex items-center justify-between gap-4 py-3.5"
>
<div className="flex min-w-0 items-start gap-3">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-800">
{item.title ?? item.filename ?? docId}
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-400">
{item.collection_name && (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-500">
{collectionLabel(item.collection_name)}
</span>
)}
{item.chunk_count != null && <span>{item.chunk_count} 段</span>}
{item.create_time && <span>{item.create_time}</span>}
<span className="font-mono">{docId}</span>
</p>
</div>
</div>
<button
onClick={() => void remove(item)}
disabled={busy}
aria-label="删除文档"
className="shrink-0 rounded p-1.5 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-500 disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
</div>
);
})}
</div>
)}
</Card>
</>
)}
</div>
</main>
);
}