"""个人知识库路由(纯文件 .kb/,照记忆机制范式,DESIGN §3.8)。 状态全在 FS(core/kb.py),端点只是薄壳;入库后台任务照定时执行器范式 (create_task + to_thread,per-(user,库) 锁在 core/kb_ingest.py 内去重)。 不设 HTTP 检索端点 —— agent 侧走 fs 工具 read/grep(kb_block 注入契约)。 """ from __future__ import annotations import asyncio from uuid import UUID from fastapi import Depends, File, HTTPException, UploadFile from core.paths import ROOT from ..schemas import KbCreateRequest _kb_ingest_tasks: set = set() # 持引用防 GC(fire-and-forget task 的惯例) def _spawn_kb_ingest(user_id: UUID, name: str) -> None: from core.agent_builder import load_config, resolve_workspace from core.kb_ingest import run_ingest cfg = load_config() ws = resolve_workspace(None) t = asyncio.create_task( asyncio.to_thread(run_ingest, ws, user_id, name, ROOT / cfg["models_dir"]) ) _kb_ingest_tasks.add(t) t.add_done_callback(_kb_ingest_tasks.discard) def register_kb_routes(app, *, require_user) -> None: @app.get("/v1/kb", tags=["kb"]) def kb_list(user_id: UUID = Depends(require_user)): """所有库概览:[{name, doc_count, pending_count}]。前端入口一次拉满。""" from core.agent_builder import resolve_workspace from core.kb import list_kbs return {"results": list_kbs(resolve_workspace(None), user_id)} @app.post("/v1/kb", tags=["kb"]) def kb_create(body: KbCreateRequest, user_id: UUID = Depends(require_user)): """建库(幂等)。名字非法 → 400。""" from core.agent_builder import resolve_workspace from core.kb import create_kb from core.kb_lock import KbBusyError name = (body.name or "").strip() try: if create_kb(resolve_workspace(None), user_id, name) is None: raise HTTPException(400, f"invalid kb name: {body.name!r}") except KbBusyError: raise HTTPException(409, "该库正在被其他操作修改") return {"name": name} @app.get("/v1/kb/{name}", tags=["kb"]) def kb_detail_ep(name: str, user_id: UUID = Depends(require_user)): """单库全貌:INDEX 条目 + 待入库列表 + 入库进度(前端轮询这里)。""" from core.agent_builder import resolve_workspace from core.kb import kb_detail from core.kb_ingest import ingest_status ws = resolve_workspace(None) detail = kb_detail(ws, user_id, name) if detail is None: raise HTTPException(404, f"kb not found: {name!r}") detail["ingest"] = ingest_status(user_id, name, ws) return detail @app.delete("/v1/kb/{name}", tags=["kb"]) def kb_delete(name: str, user_id: UUID = Depends(require_user)): """整库删除(原件 + docs + INDEX)。入库进行中 → 409(避免半截写盘)。""" from core.agent_builder import resolve_workspace from core.kb import delete_kb from core.kb_lock import KbBusyError try: if not delete_kb(resolve_workspace(None), user_id, name): raise HTTPException(404, f"kb not found: {name!r}") except KbBusyError: raise HTTPException(409, "该库正在入库或被其他操作修改") return {"deleted": name} @app.post("/v1/kb/{name}/upload", tags=["kb"]) async def kb_upload( name: str, files: list[UploadFile] = File(...), user_id: UUID = Depends(require_user), ): """上传原件到 sources/ 并立即触发入库(上传即入库)。 同名覆盖 = 重新入库(core/kb.py::save_source 会摘掉旧 INDEX 条目重排队)。 磁盘配额 gate 与 /v1/files/upload 同款。 """ from core.agent_builder import load_config as _load_cfg, resolve_workspace from core.kb import kb_dir, save_sources from core.kb_lock import KbBusyError from core.storage.disk_quota import check_disk_quota, parse_bytes _quotas_cfg = (_load_cfg().get("quotas") or {}) _limit = parse_bytes(_quotas_cfg.get("disk_bytes_per_user")) if _limit is not None and _limit > 0: _err = check_disk_quota(user_id, _limit) if _err is not None: raise HTTPException(413, _err) ws = resolve_workspace(None) d = kb_dir(ws, user_id, name) if d is None or not d.is_dir(): raise HTTPException(404, f"kb not found: {name!r}") uploads: list[tuple[str, bytes]] = [] for up in files or []: raw_name = up.filename or "" data = await up.read() uploads.append((raw_name, data)) if not uploads: raise HTTPException(400, "no files uploaded") try: saved_names = save_sources(ws, user_id, name, uploads) except KbBusyError: raise HTTPException(409, "该库正在入库或被其他操作修改") if saved_names is None: raise HTTPException(400, "存在非法文件名") saved = [ {"name": saved_name, "size": len(data)} for saved_name, (_raw_name, data) in zip(saved_names, uploads) ] _spawn_kb_ingest(user_id, name) return {"count": len(saved), "saved": saved} @app.post("/v1/kb/{name}/ingest", tags=["kb"]) async def kb_ingest_trigger(name: str, user_id: UUID = Depends(require_user)): """手动触发入库(兜待入库缺口:上传时崩溃 / 单件失败重试)。幂等,已在跑则空转。""" from core.agent_builder import resolve_workspace from core.kb import kb_dir if kb_dir(resolve_workspace(None), user_id, name) is None: raise HTTPException(404, f"kb not found: {name!r}") _spawn_kb_ingest(user_id, name) return {"started": True} @app.get("/v1/kb/{name}/docs/{filename}", tags=["kb"]) def kb_doc_read(name: str, filename: str, user_id: UUID = Depends(require_user)): """读单篇转换后 markdown(点开列表项时拉)。穿越校验收口 core/kb.py::read_doc。""" from core.agent_builder import resolve_workspace from core.kb import read_doc content = read_doc(resolve_workspace(None), user_id, name, filename) if content is None: raise HTTPException(404, f"doc not found: {filename!r}") return {"filename": filename, "content": content} @app.delete("/v1/kb/{name}/docs/{filename}", tags=["kb"]) def kb_doc_delete(name: str, filename: str, user_id: UUID = Depends(require_user)): """删单篇(docs 文件 + INDEX 行 + source 原件,防原件被重新入库)。""" from core.agent_builder import resolve_workspace from core.kb import delete_doc from core.kb_lock import KbBusyError try: if not delete_doc(resolve_workspace(None), user_id, name, filename): raise HTTPException(404, f"doc not found: {filename!r}") except KbBusyError: raise HTTPException(409, "该库正在入库或被其他操作修改") return {"deleted": filename}