Files
Cyrene_v2/frontend/web/src/admin/modules/Rag.tsx
T
Aska 9c2a37c4ac
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: 管理面板补齐(RAG导入/记忆添加/会话接history/诚实表述) + design/16 §4.6 IM多会话隔离+第三方身份+黑白名单
agent-98 面板批次:
- 会话管理面板改接 history.ListSessions(主web+次级) + 新建次级会话按钮
- 记忆面板手动添加入口 POST /admin/memories(owner)
- 知识库真正实现 IndexDocument(分块+落projectindex) + POST /admin/rag 导入端点
- 三面板空态诚实表述(去除'后端M10+提供')
- RAG嵌入降级到keyword/line(无直属embedder,不伪造语义,符合红线)

agent-99 design/16 §4.6:
- model.Message.SenderName + 迁移0006 sender_name 列
- 第三方身份边界: session.ResolveActor判定(非LLM猜) + [第三方·昵称]前缀(不改源content)
- 群聊/陌生人落独立sess-xxx(persistIsolated),不写主会话,不广播
- 黑/白名单(allowGroups/denyGroups/allowUsers/denyUsers)最前拦截,不进LLM
- 多账号 CHANNELS_JSON 配置 + 每平台多实例
- 管理只读 GET /admin/channels/{platform}/messages(该平台非主人消息流)

实机验证(JWT): 会话管理返回主会话web / 记忆添加成功 / RAG导入documents非空+Cyrene命中; 后端build/vet/test全绿 + 前端build绿
2026-09-07 05:13:36 +08:00

211 lines
8.3 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) => {
log("rag.delete", "ok");
setNotice(null);
try {
await adminDeleteRag(d.id);
} catch (err: unknown) {
setNotice(err instanceof Error ? err.message : "文档删除为后端 M10 写操作");
}
};
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 向量增强后,后端经真实语义/向量路径返回(非空壳);
未配置时走项目索引关键词 + 行上下文(导入文档即时可查)。文档删除为后端 M10 写操作。
</Notice>
</div>
);
}