diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue index 4ef1ad1..62120fe 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -37,6 +37,9 @@ const chatDetailOpen = ref(false); const recordTab = ref("chats"); const editingModelId = ref(null); const editingKnowledgeId = ref(null); +const batchSpaceId = ref(""); +const batchParentToken = ref(""); +const batchImporting = ref(false); const agentDebugging = ref(false); const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([ { role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" }, @@ -589,6 +592,27 @@ function resetKnowledgeForm() { Object.assign(knowledgeForm, { name: "", feishuSpaceId: "", feishuNodeId: "", status: 1, remark: "" }); } +async function batchImportKnowledge() { + const spaceId = batchSpaceId.value.trim(); + const parentToken = batchParentToken.value.trim(); + if (!spaceId || !parentToken) { + ElMessage.warning("请输入 SpaceID 和父节点 NodeToken"); + return; + } + batchImporting.value = true; + try { + const result = await api.importKnowledgeFromSpace(JSON.stringify({ spaceId, parent_node_token: parentToken })); + ElMessage.success(`导入完成:新增 ${result.imported} 个,跳过 ${result.skipped} 个`); + batchSpaceId.value = ""; + batchParentToken.value = ""; + await loadCurrentMenu(); + } catch (e: any) { + ElMessage.error(e.message || "导入失败"); + } finally { + batchImporting.value = false; + } +} + async function savePrompt() { const result = await api.savePrompt(promptContent.value); promptContent.value = result.promptContent; @@ -1101,6 +1125,13 @@ function buildChatQuery() { 清空 + + +
+ + + 递归导入子节点 +
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts index 5b71cd1..4c9cc16 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts @@ -106,6 +106,11 @@ export const api = { updateKnowledge: (id: number, payload: Record) => request(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }), deleteKnowledge: (id: number) => request(`/admin/knowledge/${id}`, { method: "DELETE" }), + importKnowledgeFromSpace: (body: Record) => + request<{ imported: number; skipped: number; nodes: { name: string; nodeToken: string; status: string; id?: number }[] }>( + "/admin/knowledge/import", + { method: "POST", body: JSON.stringify(body) }, + ), prompt: () => request<{ promptContent: string }>("/admin/prompt"), savePrompt: (promptContent: string) => request<{ promptContent: string }>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }), diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py index f8c1c06..57dc00e 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py @@ -11,6 +11,7 @@ from app.models.admin import Admin from app.models.knowledge import Knowledge, UserKnowledgePermission from app.schemas.admin import KnowledgeSaveRequest from app.services.admin_service import OperationLogService +from app.services.feishu_service import FeishuKnowledgeService router = APIRouter() @@ -21,6 +22,55 @@ def list_knowledge(db: Session = Depends(get_db), current_admin: Admin = Depends return api_success([_knowledge_dict(item) for item in items]) +@router.post("/knowledge/import") +def import_knowledge_from_space( + payload: dict, + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + space_id = payload.get("spaceId", "").strip() + parent_node_token = payload.get("parent_node_token", "").strip() + if not space_id or not parent_node_token: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="SpaceID 和父节点 Token 不能为空") + + nodes = FeishuKnowledgeService.import_nodes_from_parent(space_id, parent_node_token, db) + if not nodes: + return api_success({"imported": 0, "skipped": 0, "nodes": []}) + + # 获取已存在的 node_id,避免重复导入 + existing_nodes = { + row.feishu_node_id for row in db.scalars(select(Knowledge)).all() if row.feishu_node_id + } + + imported = 0 + skipped = 0 + results = [] + for node in nodes: + node_token = node["node_token"] + if node_token in existing_nodes: + skipped += 1 + results.append({"name": node["name"], "nodeToken": node_token, "status": "skipped"}) + continue + + knowledge = Knowledge( + name=node["name"], + feishu_space_id=space_id, + feishu_node_id=node_token, + status=1, + remark=f"obj_type={node.get('obj_type', '')}", + ) + db.add(knowledge) + db.flush() + imported += 1 + results.append({"name": node["name"], "nodeToken": node_token, "status": "imported", "id": knowledge.id}) + OperationLogService.write( + db, admin_id=current_admin.id, module="knowledge", action="import", target_id=knowledge.id + ) + + db.commit() + return api_success({"imported": imported, "skipped": skipped, "nodes": results}) + + @router.post("/knowledge") def create_knowledge( payload: KnowledgeSaveRequest, diff --git a/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py b/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py index 97bc509..620c281 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py @@ -330,6 +330,49 @@ class FeishuKnowledgeService: cls._content_cache.clear() return count + @classmethod + def import_nodes_from_parent( + cls, + space_id: str, + parent_node_token: str, + db: Session | None = None, + ) -> list[dict]: + """从指定节点递归获取所有子节点,用于批量导入知识库。 + 不需要空间级别权限,只需要对 parent_node_token 所在节点有查看权限。 + """ + config = _feishu_retrieval_config(db) + if not config.app_id or not config.app_secret: + raise ExternalServiceError( + "未配置飞书应用凭证,无法读取飞书知识库。", + provider="feishu", + ) + + all_nodes: list[dict] = [] + visited: set[str] = set() + + def _fetch_children(parent_token: str) -> None: + try: + children = _get_wiki_children(space_id, parent_token, config) + except ExternalServiceError: + return + for child in children: + node_token = child.get("node_token", "") + if not node_token or node_token in visited: + continue + visited.add(node_token) + all_nodes.append({ + "name": child.get("title", node_token), + "node_token": node_token, + "space_id": space_id, + "obj_type": child.get("obj_type", ""), + "parent_token": parent_token, + }) + if child.get("has_child", False): + _fetch_children(node_token) + + _fetch_children(parent_node_token) + return all_nodes + @classmethod def retrieve( cls,