- B5: POST /admin/rag/delete 真做(DeleteDocument→DeleteProject 级联删章节+索引清理, owner+审计, 200/404/500, 500降级兜底); users/iot scenarios+rules 诚实501(无用户表/IoT场景规则模型, 消息写明缺什么, 不伪造), 锁住 501+not_implemented
- D1: backthink/push_outlet.go PushExporter{Export}; pushOutlet 自建HTTP(2xx=送达, token仅Authorization头+error脱敏)优先→Getui兜底(未接入, 诚实日志'getui not integrated'+(false,nil)); backthink.Deps.Exporter → pusher.withExporter, Push 在WS/IM后 exportAsync(detached+5s超时, 失败仅日志, 不破坏现有投递, 限流不上报); config PushOutletConfig(PUSH_EXPORTER_* + GETUI_*占位)
- 前端 Rag.tsx/api.ts 删除后 reload + 类型; Docs/api-client v0.31 + api-reference(#23 owner/200-404, §8.4, 端点表PATCH/DELETE messages + pinned + messages?q=)
- 测试(push_outlet/admin_rag_delete/admin_honest501/knowledge store), 后端+前端build全绿+3平台交叉编译
- 刻意: Getui SDK 未接入(无SDK不硬造), 主人自建服务填 PUSH_EXPORTER_URL/TOKEN 即可
214 lines
8.4 KiB
TypeScript
214 lines
8.4 KiB
TypeScript
import { useState } from "react";
|
||
import { useAsyncData } from "../../lib/useAsyncData";
|
||
import { adminDeleteRag, adminIndexRag, adminListRag, adminSearchRag } from "../../lib/api";
|
||
import type { AdminRagDoc, AdminRagHit } from "../../lib/types";
|
||
import { useAuditStore } from "../audit";
|
||
import { Notice, PageHeader } from "../components";
|
||
import { Button, Surface, Tag } from "../../components/primitives";
|
||
|
||
export function RagModule(): React.ReactNode {
|
||
const log = useAuditStore((s) => s.log);
|
||
const { data, loading, error, reload } = useAsyncData(() => adminListRag(), []);
|
||
const [query, setQuery] = useState("");
|
||
const [hits, setHits] = useState<AdminRagHit[]>([]);
|
||
const [searching, setSearching] = useState(false);
|
||
const [notice, setNotice] = useState<string | null>(null);
|
||
// Manual "导入文档" form fields.
|
||
const [docName, setDocName] = useState("");
|
||
const [docDesc, setDocDesc] = useState("");
|
||
const [docContent, setDocContent] = useState("");
|
||
const [importing, setImporting] = useState(false);
|
||
|
||
const docs: AdminRagDoc[] = data?.documents ?? [];
|
||
|
||
const search = async () => {
|
||
log("rag.search", "ok");
|
||
setSearching(true);
|
||
setNotice(null);
|
||
try {
|
||
const res = await adminSearchRag(query.trim());
|
||
setHits(res.hits);
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : "检索失败";
|
||
setNotice(msg);
|
||
setHits([]);
|
||
} finally {
|
||
setSearching(false);
|
||
}
|
||
};
|
||
|
||
const remove = async (d: AdminRagDoc) => {
|
||
setNotice(null);
|
||
try {
|
||
await adminDeleteRag(d.id);
|
||
log("rag.delete", "ok");
|
||
reload();
|
||
setNotice("文档已删除(含其章节)。");
|
||
} catch (err: unknown) {
|
||
log("rag.delete", "error");
|
||
setNotice(err instanceof Error ? err.message : "删除文档失败");
|
||
}
|
||
};
|
||
|
||
const importDoc = async () => {
|
||
if (!docName.trim() || !docContent.trim()) {
|
||
setNotice("文档名称与内容不能为空");
|
||
return;
|
||
}
|
||
setImporting(true);
|
||
setNotice(null);
|
||
try {
|
||
await adminIndexRag({
|
||
name: docName.trim(),
|
||
desc: docDesc.trim() || undefined,
|
||
content: docContent.trim(),
|
||
});
|
||
log("rag.index", "ok");
|
||
setDocName("");
|
||
setDocDesc("");
|
||
setDocContent("");
|
||
reload();
|
||
setNotice("文档已导入,可按知识库检索。");
|
||
} catch (err: unknown) {
|
||
log("rag.index", "error");
|
||
setNotice(err instanceof Error ? err.message : "导入文档失败");
|
||
} finally {
|
||
setImporting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<PageHeader
|
||
title="知识库 RAG"
|
||
subtitle="GET /api/v1/admin/rag · 文档列表(分块/嵌入状态) + POST /rag 导入 + 检索测试"
|
||
actions={
|
||
<Button variant="soft" onClick={reload} disabled={importing}>
|
||
刷新
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
{notice ? <Notice tone={notice.includes("失败") || notice.includes("不能为空") ? "err" : "warn"}>{notice}</Notice> : null}
|
||
|
||
<Surface className="space-y-3">
|
||
<h3 className="text-sm font-semibold">导入文档(写入项目索引,可分块、可检索)</h3>
|
||
<input
|
||
value={docName}
|
||
onChange={(e) => setDocName(e.target.value)}
|
||
placeholder="文档名称(如:内部知识库.md)…"
|
||
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||
/>
|
||
<input
|
||
value={docDesc}
|
||
onChange={(e) => setDocDesc(e.target.value)}
|
||
placeholder="描述(可选,作为项目描述)…"
|
||
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||
/>
|
||
<textarea
|
||
value={docContent}
|
||
onChange={(e) => setDocContent(e.target.value)}
|
||
placeholder="粘贴文档正文,空行分段,后端自动分块并落到项目索引(关键词/行上下文可检索)…"
|
||
rows={6}
|
||
className="w-full resize-y rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||
/>
|
||
<div className="flex justify-end">
|
||
<Button variant="primary" disabled={importing} onClick={() => void importDoc()}>
|
||
{importing ? "导入中…" : "导入文档"}
|
||
</Button>
|
||
</div>
|
||
</Surface>
|
||
|
||
<Surface className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-sm font-semibold">文档列表</h3>
|
||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||
{loading ? "加载中…" : `${docs.length} 篇`}
|
||
</span>
|
||
</div>
|
||
{loading ? (
|
||
<p className="text-xs text-[var(--mu-text-lo)]">正在从项目索引加载知识文档…</p>
|
||
) : error ? (
|
||
<p className="text-xs text-[var(--mu-text-lo)]">{error}</p>
|
||
) : docs.length === 0 ? (
|
||
<p className="text-xs text-[var(--mu-text-lo)]">当前无文档:点『导入文档』导入资料后可按知识库检索。</p>
|
||
) : (
|
||
docs.map((d) => (
|
||
<div
|
||
key={d.id}
|
||
className="flex items-center gap-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm"
|
||
>
|
||
<div className="min-w-0">
|
||
<span className="font-medium">{d.name}</span>
|
||
{d.desc ? (
|
||
<span className="ml-2 truncate text-xs text-[var(--mu-text-lo)]">{d.desc}</span>
|
||
) : null}
|
||
</div>
|
||
<span className="text-xs text-[var(--mu-text-lo)]">{d.chunks} 分块</span>
|
||
<span className="ml-auto">
|
||
<Tag tone={d.embedded ? "ok" : "warn"}>{d.embedded ? "已嵌入" : "待嵌入"}</Tag>
|
||
</span>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => {
|
||
if (!confirm(`删除 ${d.name}?`)) return;
|
||
void remove(d);
|
||
}}
|
||
>
|
||
删除
|
||
</Button>
|
||
</div>
|
||
))
|
||
)}
|
||
</Surface>
|
||
|
||
<Surface className="space-y-3">
|
||
<h3 className="text-sm font-semibold">检索测试</h3>
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && query.trim()) void search();
|
||
}}
|
||
placeholder="输入查询(命中项目索引或项目记忆)…"
|
||
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||
/>
|
||
<Button variant="primary" onClick={() => void search()} disabled={searching || !query.trim()}>
|
||
{searching ? "检索中…" : "检索"}
|
||
</Button>
|
||
</div>
|
||
{hits.length > 0 ? (
|
||
<div className="space-y-1.5">
|
||
{hits.map((h, i) => (
|
||
<div
|
||
key={`${h.docId}-${h.chapter ?? "mem"}-${i}`}
|
||
className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs"
|
||
>
|
||
<div className="mb-1 flex items-center gap-2">
|
||
<span className="font-medium text-[var(--mu-text)]">{h.docName}</span>
|
||
{h.chapter ? (
|
||
<span className="text-[var(--mu-text-lo)]">#{h.chapter}</span>
|
||
) : null}
|
||
<span className="ml-auto text-[var(--mu-text-lo)]">
|
||
{h.semantic ? "语义" : "关键词"} · {h.score.toFixed(2)}
|
||
</span>
|
||
</div>
|
||
<p className="whitespace-pre-wrap text-[var(--mu-text-mid)]">{h.text}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : hits.length === 0 && query.trim() && !searching ? (
|
||
<p className="text-xs text-[var(--mu-text-lo)]">无命中。</p>
|
||
) : null}
|
||
</Surface>
|
||
|
||
<Notice>
|
||
分块/嵌入状态与语义检索:配置了 pgvector / SQLite 向量增强后,后端经真实语义/向量路径返回(非空壳);
|
||
未配置时走项目索引关键词 + 行上下文(导入文档即时可查)。文档删除为真实删除(项目索引条目 + 级联章节)。
|
||
</Notice>
|
||
</div>
|
||
);
|
||
}
|