ForcePilot/backend/package/yuxi/knowledge/implementations/dify.py
Wenjie Zhang a0f9b0b6fd feat(kb):新增知识库文件管理功能
- 更新了 KnowledgeBaseManager 以支持新的文件管理方法。
- 在 workspace_router 中为知识库文件操作新增了端点,包括文件树列表、文件预览和文件下载。
- 在 workspace_api.js 中添加了相应的 API 调用,用于知识库交互。
- 增强了 AgentFilePreview 组件,支持预览变体之间的切换。
- 更新了导航逻辑,重定向至知识管理标签页,而非数据库视图。
- 修改了 WorkspaceView 以处理知识库条目和预览,包括加载和展示知识文件。
- 通过在文件操作期间提供加载与错误状态的反馈,改善了用户体验。
2026-05-26 17:39:12 +08:00

243 lines
9.4 KiB
Python
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 traceback
from typing import Any
import httpx
from yuxi.knowledge.base import KnowledgeBase
from yuxi.utils import logger
class DifyKB(KnowledgeBase):
"""基于 Dify Dataset Retrieve API 的只读检索知识库实现"""
def __init__(self, work_dir: str, **kwargs):
del kwargs
super().__init__(work_dir)
@property
def kb_type(self) -> str:
return "dify"
async def _create_kb_instance(self, db_id: str, config: dict) -> Any:
return None
async def _initialize_kb_instance(self, instance: Any) -> None:
return None
@staticmethod
def _readonly_error() -> ValueError:
return ValueError("Dify 知识库为只读检索类型,不支持该操作")
async def add_file_record(
self, db_id: str, item: str, params: dict | None = None, operator_id: str | None = None
) -> dict:
raise self._readonly_error()
async def parse_file(self, db_id: str, file_id: str, operator_id: str | None = None) -> dict:
raise self._readonly_error()
async def update_file_params(self, db_id: str, file_id: str, params: dict, operator_id: str | None = None) -> None:
raise self._readonly_error()
async def create_folder(self, db_id: str, folder_name: str, parent_id: str | None = None) -> dict:
raise self._readonly_error()
async def move_file(self, db_id: str, file_id: str, new_parent_id: str | None) -> dict:
raise self._readonly_error()
async def delete_folder(self, db_id: str, folder_id: str) -> None:
raise self._readonly_error()
async def index_file(self, db_id: str, file_id: str, operator_id: str | None = None) -> dict:
raise self._readonly_error()
async def update_content(self, db_id: str, file_ids: list[str], params: dict | None = None) -> list[dict]:
raise self._readonly_error()
async def delete_file(self, db_id: str, file_id: str) -> None:
raise self._readonly_error()
async def get_file_basic_info(self, db_id: str, file_id: str) -> dict:
raise self._readonly_error()
async def get_file_content(self, db_id: str, file_id: str) -> dict:
raise self._readonly_error()
async def open_file_content(self, db_id: str, file_id: str, offset: int = 0, limit: int = 800) -> dict:
del offset, limit
raise self._readonly_error()
async def get_file_info(self, db_id: str, file_id: str) -> dict:
raise self._readonly_error()
async def list_file_tree(
self,
db_id: str,
parent_id: str | None = None,
recursive: bool = False,
files_only: bool = False,
) -> dict:
del db_id, parent_id, recursive, files_only
raise ValueError("Dify 知识库不支持文件树预览")
async def read_file_preview(self, db_id: str, file_id: str, variant: str = "parsed") -> dict:
del db_id, file_id, variant
raise ValueError("Dify 知识库不支持文件预览")
async def get_file_download(self, db_id: str, file_id: str, variant: str = "original") -> dict:
del db_id, file_id, variant
raise ValueError("Dify 知识库不支持文件下载")
async def aquery(self, query_text: str, db_id: str, agent_call: bool = False, **kwargs) -> list[dict]:
del agent_call
metadata = self.databases_meta.get(db_id, {}).get("metadata", {}) or {}
api_url = str(metadata.get("dify_api_url") or "").strip()
token = str(metadata.get("dify_token") or "").strip()
dataset_id = str(metadata.get("dify_dataset_id") or "").strip()
if not api_url or not token or not dataset_id:
logger.error(f"Dify config incomplete for db_id={db_id}")
return []
query_params = self._get_query_params(db_id)
merged = {**query_params, **kwargs}
search_mode = str(merged.get("search_mode", "vector")).lower()
search_method_map = {
"vector": "semantic_search",
"keyword": "keyword_search",
"hybrid": "hybrid_search",
}
search_method = search_method_map.get(search_mode, "semantic_search")
top_k = int(merged.get("final_top_k", 10))
top_k = max(top_k, 1)
score_threshold_enabled = bool(merged.get("score_threshold_enabled", False))
score_threshold = float(merged.get("similarity_threshold", 0.0))
payload: dict[str, Any] = {
"query": query_text,
"retrieval_model": {
"search_method": search_method,
"top_k": top_k,
# 某些 Dify 部署版本会直接读取该字段,缺失时抛 KeyError
"reranking_enable": False,
"score_threshold_enabled": score_threshold_enabled,
},
}
if score_threshold_enabled:
payload["retrieval_model"]["score_threshold"] = score_threshold
request_url = f"{api_url.rstrip('/')}/datasets/{dataset_id}/retrieve"
headers = {"Authorization": f"Bearer {token}"}
try:
response_json = await self._request_dify(client_payload=payload, request_url=request_url, headers=headers)
except Exception as e: # noqa: BLE001
logger.error(f"Dify query failed for db_id={db_id}: {e}, {traceback.format_exc()}")
# 一些 Dify 部署版本对 retrieval_model 兼容性较差,失败时降级为仅 query 请求重试一次
try:
response_json = await self._request_dify(
client_payload={"query": query_text},
request_url=request_url,
headers=headers,
)
logger.warning(f"Dify query fallback to query-only succeeded for db_id={db_id}")
except Exception as fallback_error: # noqa: BLE001
logger.error(
f"Dify query fallback failed for db_id={db_id}: {fallback_error}, {traceback.format_exc()}"
)
return []
records = response_json.get("records", []) if isinstance(response_json, dict) else []
if not isinstance(records, list):
return []
results = []
for record in records:
if not isinstance(record, dict):
continue
segment = record.get("segment") or {}
if not isinstance(segment, dict):
continue
document = segment.get("document") or {}
if not isinstance(document, dict):
document = {}
content = segment.get("content")
if not content:
continue
results.append(
{
"content": content,
"score": float(record.get("score") or 0.0),
"metadata": {
"source": document.get("name") or "Dify",
"file_id": document.get("id"),
"chunk_id": segment.get("id"),
"chunk_index": segment.get("position"),
},
}
)
return results
async def _request_dify(self, client_payload: dict[str, Any], request_url: str, headers: dict[str, str]) -> dict:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(request_url, json=client_payload, headers=headers)
try:
response.raise_for_status()
except httpx.HTTPStatusError as e:
body_preview = response.text[:1000] if response.text else ""
logger.error(
f"Dify HTTP error: status={response.status_code}, url={request_url}, "
f"payload_keys={list(client_payload.keys())}, body={body_preview}"
)
raise e
return response.json()
def get_query_params_config(self, db_id: str, **kwargs) -> dict:
del db_id, kwargs
options = [
{
"key": "search_mode",
"label": "检索模式",
"type": "select",
"default": "vector",
"options": [
{"value": "vector", "label": "向量检索", "description": "映射为 semantic_search"},
{"value": "keyword", "label": "关键词检索", "description": "映射为 keyword_search"},
{"value": "hybrid", "label": "混合检索", "description": "映射为 hybrid_search"},
],
"description": "Dify 检索方法映射",
},
{
"key": "final_top_k",
"label": "最终返回 Chunk 数",
"type": "number",
"default": 10,
"min": 1,
"max": 100,
"description": "映射为 Dify retrieval_model.top_k",
},
{
"key": "score_threshold_enabled",
"label": "启用分数阈值",
"type": "boolean",
"default": False,
"description": "映射为 Dify retrieval_model.score_threshold_enabled",
},
{
"key": "similarity_threshold",
"label": "分数阈值0-1",
"type": "number",
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.1,
"description": "映射为 Dify retrieval_model.score_threshold",
},
]
return {"type": "dify", "options": options}