Files
Cyrene_v2/frontend/web/src/admin/modules/Rag.tsx
T
AskaEth 2dee372fb2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink/admin): ③b — rag/delete 真实现 + D1 推送出口钩子(自建优先→个推兜底) + api-reference 补 D2/D3/D5 补丁
- 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 即可
2026-09-09 00:08:29 +08:00

214 lines
8.4 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.
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>
);
}