feat: 知识库递归导入(从指定父节点,不需要空间权限)
- feishu_service.py: import_space_nodes 改为 import_nodes_from_parent 从指定父节点递归获取子节点,不需要空间级别权限 - admin_knowledge.py: import API 增加 parent_node_token 参数 - api.ts/App.vue: 前端增加父节点 NodeToken 输入框
This commit is contained in:
@@ -37,6 +37,9 @@ const chatDetailOpen = ref(false);
|
|||||||
const recordTab = ref("chats");
|
const recordTab = ref("chats");
|
||||||
const editingModelId = ref<number | null>(null);
|
const editingModelId = ref<number | null>(null);
|
||||||
const editingKnowledgeId = ref<number | null>(null);
|
const editingKnowledgeId = ref<number | null>(null);
|
||||||
|
const batchSpaceId = ref("");
|
||||||
|
const batchParentToken = ref("");
|
||||||
|
const batchImporting = ref(false);
|
||||||
const agentDebugging = ref(false);
|
const agentDebugging = ref(false);
|
||||||
const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([
|
const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([
|
||||||
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
|
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
|
||||||
@@ -589,6 +592,27 @@ function resetKnowledgeForm() {
|
|||||||
Object.assign(knowledgeForm, { name: "", feishuSpaceId: "", feishuNodeId: "", status: 1, remark: "" });
|
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() {
|
async function savePrompt() {
|
||||||
const result = await api.savePrompt(promptContent.value);
|
const result = await api.savePrompt(promptContent.value);
|
||||||
promptContent.value = result.promptContent;
|
promptContent.value = result.promptContent;
|
||||||
@@ -1101,6 +1125,13 @@ function buildChatQuery() {
|
|||||||
<el-button @click="resetKnowledgeForm">清空</el-button>
|
<el-button @click="resetKnowledgeForm">清空</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
|
<el-divider />
|
||||||
|
<div class="batch-import-row">
|
||||||
|
<el-input v-model="batchSpaceId" placeholder="SpaceID" style="width: 220px" />
|
||||||
|
<el-input v-model="batchParentToken" placeholder="父节点 NodeToken(如 wikcnXXX)" style="width: 260px" />
|
||||||
|
<el-button type="primary" @click="batchImportKnowledge" :loading="batchImporting">递归导入子节点</el-button>
|
||||||
|
</div>
|
||||||
<el-table :data="knowledge" stripe>
|
<el-table :data="knowledge" stripe>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
<el-table-column prop="name" label="名称" min-width="160" />
|
<el-table-column prop="name" label="名称" min-width="160" />
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ export const api = {
|
|||||||
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
|
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
|
||||||
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||||
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
|
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
|
||||||
|
importKnowledgeFromSpace: (body: Record<string, string>) =>
|
||||||
|
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"),
|
prompt: () => request<{ promptContent: string }>("/admin/prompt"),
|
||||||
savePrompt: (promptContent: string) =>
|
savePrompt: (promptContent: string) =>
|
||||||
request<{ promptContent: string }>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }),
|
request<{ promptContent: string }>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.models.admin import Admin
|
|||||||
from app.models.knowledge import Knowledge, UserKnowledgePermission
|
from app.models.knowledge import Knowledge, UserKnowledgePermission
|
||||||
from app.schemas.admin import KnowledgeSaveRequest
|
from app.schemas.admin import KnowledgeSaveRequest
|
||||||
from app.services.admin_service import OperationLogService
|
from app.services.admin_service import OperationLogService
|
||||||
|
from app.services.feishu_service import FeishuKnowledgeService
|
||||||
|
|
||||||
router = APIRouter()
|
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])
|
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")
|
@router.post("/knowledge")
|
||||||
def create_knowledge(
|
def create_knowledge(
|
||||||
payload: KnowledgeSaveRequest,
|
payload: KnowledgeSaveRequest,
|
||||||
|
|||||||
@@ -330,6 +330,49 @@ class FeishuKnowledgeService:
|
|||||||
cls._content_cache.clear()
|
cls._content_cache.clear()
|
||||||
return count
|
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
|
@classmethod
|
||||||
def retrieve(
|
def retrieve(
|
||||||
cls,
|
cls,
|
||||||
|
|||||||
Reference in New Issue
Block a user