feat(space): 共享工作区增量3b —— KB 知识库按 Space 共享(存储 re-key)

把 3a 的 Space 作用域推到 KB 层:KB/Doc/DocLink owner→space_id,作用域键
owner/name→space_id/name,同空间成员共享知识库(检索/入库/文库/双链/图谱)。

后端:
- store: KB/Doc/DocLink 加 space_id,唯一索引 (owner,*)→(space_id,*),owner 降级创建人;
  查询全改 space 作用域;SaveDoc/ListVault/GetDocByID/DeleteDocByID/ReplaceDocLinks/
  ResolveInboundLinks/ListLinks 改 space;tenantIDForSpace 补异步入库租户
- MigrateKBSpaces 启动迁移(space_id 回填 + 唯一索引换新,同 Agent 顺序坑规避)
- scopedKB owner/name→space_id/name;IngestJob 契约加 SpaceID;enqueueIngest/runIngest
  穿 space;MinIO 对象键改 space/kb/doc(避免跨空间同名撞键,老键不透明不迁)
- KB 写路由(create/ingest/ingest_file/note/delete)挂 RequireSpaceRole(member):viewer 只读
- 存储层重灌迁移端点 POST /admin/migrate-kb-storage(异步):为存量文档入队新 space
  作用域的重灌作业(复用 JetStream 入库 worker 池),先删旧键;避免同步重嵌撑爆 HTTP 超时

桌面端:
- KbView 收 spaceId(变则重拉库)+spaceReadOnly(viewer 禁建库/入库/文件/笔记);VaultPanel 同

验证(gateway+mcp-go+Milvus/Neo4j/embedding 全栈):
- PG 迁移: 20/21 KB + 50/54 doc 回填 space_id(4 未迁=pre-多租户 owner='wt' 空租户遗留,
  正确跳过),唯一索引 idx_kb_sn/idx_doc_skn 换新、旧索引删除
- KB 共享: member 见共享库 / viewer 建库·入库 403 / 切回个人空间隔离(看不到)
- 全向量链路: RagA 入库(真 dashscope embedding)→ RagB(空间member)检索命中 RagA 内容
- 存储重灌: 端点异步入队 49 作业(worker 池背压处理),重灌后老文档在新 space 键可检索

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-13 13:00:34 +08:00
parent addaa1b34f
commit a8c0bb42a7
9 changed files with 218 additions and 97 deletions
+1 -1
View File
@@ -298,7 +298,7 @@ export default function App() {
) : view === "studio" ? (
<StudioView onRun={onRun} phase={run.phase} identity={identity} readOnly={tenant?.role === "viewer"} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
) : view === "kb" ? (
<KbView identity={identity} />
<KbView identity={identity} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
) : view === "report" ? (
<ReportView identity={identity} />
) : view === "runs" ? (
@@ -114,7 +114,7 @@ function dedupTriples(ts: Triple[]): Triple[] {
}
// 知识库:owner 隔离 + 项目/案件/文件夹组织;Tab 分(入库 / 文库(Obsidian 式) / 检索 / 图谱)。
export function KbView({ identity }: { identity: Identity }) {
export function KbView({ identity, spaceId = "", spaceReadOnly = false }: { identity: Identity; spaceId?: string; spaceReadOnly?: boolean }) {
const toast = useToast();
const [kbs, setKbs] = useState<KbInfo[]>([]);
const [kb, setKb] = useState("default");
@@ -150,11 +150,13 @@ export function KbView({ identity }: { identity: Identity }) {
/* 降级用默认库 */
}
}, [identity]);
// 切换工作区(spaceId 变)后重拉该空间的知识库(共享工作区隔离)。
useEffect(() => {
void refreshKbs();
}, [refreshKbs]);
}, [refreshKbs, spaceId]);
const onCreate = async () => {
if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权新建知识库");
const name = newName.trim();
if (!name) return;
try {
@@ -212,6 +214,7 @@ export function KbView({ identity }: { identity: Identity }) {
};
const onIngest = async () => {
if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权入库");
if (!text.trim()) return;
try {
const job = await ingestKb(identity, kb, text);
@@ -223,6 +226,7 @@ export function KbView({ identity }: { identity: Identity }) {
};
const ingestFiles = (list: FileList | File[] | null | undefined) => {
if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权入库");
const arr = Array.from(list ?? []);
arr.forEach((file, idx) => {
const fid = `${file.name}-${idx}-${stamp()}-${Math.round(file.size)}`;
@@ -387,7 +391,7 @@ export function KbView({ identity }: { identity: Identity }) {
</div>
)}
{tab === "vault" && <VaultPanel identity={identity} kb={kb} />}
{tab === "vault" && <VaultPanel identity={identity} kb={kb} spaceReadOnly={spaceReadOnly} />}
{tab === "search" && (
<div className="h-full overflow-y-auto p-4">
@@ -436,7 +440,7 @@ export function KbView({ identity }: { identity: Identity }) {
// VaultPanelObsidian 式文库 —— 列表(仅元数据) / 正文按需加载 / [[双链]]可点 / 反链 / 笔记关系图。
// 列表与正文分离 + 链接走服务端索引,不再一次拉回整库正文,可扛十几万字大文件。
function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) {
function VaultPanel({ identity, kb, spaceReadOnly = false }: { identity: Identity; kb: string; spaceReadOnly?: boolean }) {
const toast = useToast();
const [docs, setDocs] = useState<VaultDoc[]>([]);
const [links, setLinks] = useState<DocLink[]>([]);
@@ -528,6 +532,7 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) {
setDraft(content);
};
const onSave = async () => {
if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权编辑笔记");
const name = (creatingNew ? draftName : current?.name ?? "").trim();
if (!name || !draft.trim()) {
toast.push("error", "笔记名与内容不能为空");