diff --git a/backend/package/yuxi/agents/toolkits/kbs/tools.py b/backend/package/yuxi/agents/toolkits/kbs/tools.py index 3302cc6c..62daa0af 100644 --- a/backend/package/yuxi/agents/toolkits/kbs/tools.py +++ b/backend/package/yuxi/agents/toolkits/kbs/tools.py @@ -241,8 +241,6 @@ async def query_kb(kb_name: str, query_text: str, file_name: str | None = None, metadata = target_info.get("metadata") if isinstance(target_info, dict) else None kb_type = str((metadata or {}).get("kb_type") or "").strip().lower() - if kb_type != "milvus": - return f"知识库 '{kb_name}' 不是 Milvus 类型,当前 query_kb 仅支持 Milvus" try: retriever = target_info["retriever"] @@ -255,11 +253,15 @@ async def query_kb(kb_name: str, query_text: str, file_name: str | None = None, else: result = retriever(query_text, **kwargs) + if kb_type != "milvus": + return result + if not isinstance(result, list): - return f"知识库 '{kb_name}' 返回结果不是 Milvus chunks 列表,当前 query_kb 仅支持 Milvus" + return f"知识库 '{kb_name}' 返回结果不是 Milvus chunks 列表,无法注入文件路径" from yuxi.agents.backends.knowledge_base_backend import inject_filepaths_into_retrieval_result + # 只有 Milvus 结果的 file_id 对应本地文件系统,可补充沙盒可读路径。 return await inject_filepaths_into_retrieval_result( retrieval_chunks=result, visible_kbs=visible_kbs, diff --git a/backend/test/unit/toolkits/test_kbs_tools.py b/backend/test/unit/toolkits/test_kbs_tools.py index 755df5ad..888c8be0 100644 --- a/backend/test/unit/toolkits/test_kbs_tools.py +++ b/backend/test/unit/toolkits/test_kbs_tools.py @@ -79,10 +79,61 @@ async def test_query_kb_injects_filepath_into_chunk_metadata(monkeypatch) -> Non @pytest.mark.asyncio -async def test_query_kb_rejects_non_milvus_knowledge_base(monkeypatch) -> None: +async def test_query_kb_allows_dify_knowledge_base(monkeypatch) -> None: async def _fake_retriever(query_text: str, **kwargs): assert query_text == "auth" - return [] + return [ + { + "content": "auth guide", + "score": 0.98, + "metadata": { + "file_id": "dify-doc-1", + "source": "Dify Doc", + }, + } + ] + + monkeypatch.setattr( + tools.knowledge_base, + "get_retrievers", + lambda: { + "db-1": { + "name": "FAQ", + "retriever": _fake_retriever, + "metadata": {"kb_type": "dify"}, + } + }, + ) + + async def _fake_visible_kbs(runtime): + return [{"db_id": "db-1", "name": "FAQ"}] + + monkeypatch.setattr(tools, "_resolve_visible_knowledge_bases_for_query", _fake_visible_kbs) + monkeypatch.setattr( + "yuxi.agents.backends.knowledge_base_backend.inject_filepaths_into_retrieval_result", + pytest.fail, + ) + + runtime = SimpleNamespace(context=SimpleNamespace()) + result = await _run_query_kb(kb_name="FAQ", query_text="auth", runtime=runtime) + + assert result == [ + { + "content": "auth guide", + "score": 0.98, + "metadata": { + "file_id": "dify-doc-1", + "source": "Dify Doc", + }, + } + ] + + +@pytest.mark.asyncio +async def test_query_kb_returns_lightrag_result_without_filepath_injection(monkeypatch) -> None: + async def _fake_retriever(query_text: str, **kwargs): + assert query_text == "auth" + return "LightRAG context" monkeypatch.setattr( tools.knowledge_base, @@ -100,11 +151,15 @@ async def test_query_kb_rejects_non_milvus_knowledge_base(monkeypatch) -> None: return [{"db_id": "db-1", "name": "FAQ"}] monkeypatch.setattr(tools, "_resolve_visible_knowledge_bases_for_query", _fake_visible_kbs) + monkeypatch.setattr( + "yuxi.agents.backends.knowledge_base_backend.inject_filepaths_into_retrieval_result", + pytest.fail, + ) runtime = SimpleNamespace(context=SimpleNamespace()) result = await _run_query_kb(kb_name="FAQ", query_text="auth", runtime=runtime) - assert result == "知识库 'FAQ' 不是 Milvus 类型,当前 query_kb 仅支持 Milvus" + assert result == "LightRAG context" @pytest.mark.asyncio diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 0f82268f..ea8ef08f 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -45,7 +45,7 @@ - 新增 Skills 远程安装能力:Skills 管理页支持填写 `owner/repo` 或 GitHub URL,后端通过隔离的临时 `HOME` 调用 `npx skills add` 下载指定 skill,再复用现有导入链路写入 `saves/skills` 和数据库,避免将 `~/.agents/skills` 直接作为系统主存储;前端远程安装弹窗补充多选串行安装与批量进度展示,复用现有单 skill 安装接口逐个提交请求 - 调整部门删除语义:删除部门时不再要求用户数为 0,而是将部门下用户迁移到默认部门,同时清理部门级配置和部门 API Key,保证测试部门、撤换部门等场景可直接删除,并补充对应集成测试覆盖该链路 - 重构 MCP 运行时配置加载模型:移除 `MCP_SERVERS` 作为运行正确性前提的设计,改为每次直接从数据库读取最新 MCP 配置,并用 `server_name:config_hash` 作为本地工具缓存 key;同时将内置 MCP 初始化职责收敛为仅同步数据库默认项,前端 MCP 选项改为直接使用实时资源列表,解决 `api`/`worker` 分进程下的配置不一致与缓存失效问题 -- 为知识库检索工具补充 `metadata.filepath` 注入:在 `query_kb` 统一出口基于会话可见知识库构建 `file_id -> /home/gem/kbs/...` 映射并回填检索结果,注入逻辑复用知识库只读后端命名规则;并将工具调用范围收敛为 Milvus(仅支持 Milvus chunks 列表且要求显式 `file_id`),不再兼容无显式 `file_id` 的推断注入,新增单测覆盖该约束 +- 为知识库检索工具补充 `metadata.filepath` 注入:在 `query_kb` 统一出口基于会话可见知识库构建 `file_id -> /home/gem/kbs/...` 映射并回填 Milvus 检索结果,注入逻辑复用知识库只读后端命名规则;路径注入仅作用于 Milvus chunks 列表,Dify 和 LightRAG 等其他知识库保持原检索结果返回,不再兼容无显式 `file_id` 的推断注入,新增单测覆盖该约束 - 调整 Milvus 混合检索实现:集合 schema 增加 Milvus 内置 BM25 稀疏向量字段、BM25 函数和中文 analyzer 配置,`keyword` 模式改为 BM25 全文检索,`hybrid` 模式改为 Milvus 原生向量 + BM25 混合检索,并同步更新检索参数说明。 - 修复 DOCX 解析中的图片回插顺序:Docling 导出的多个 `` 占位符现在按文档图片顺序替换,避免多图文档中的图片链接前后颠倒。 - 修复前端依赖安全告警:通过 `pnpm.overrides` 将传递依赖 `flatted` 锁定到 `3.4.2`、`lodash-es` 锁定到 `4.18.1`,并同步更新 `pnpm-lock.yaml` 以消除 DriftGuard 报告的高危 CVE diff --git a/web/src/components/ToolCallingResult/tools/QueryKbTool.vue b/web/src/components/ToolCallingResult/tools/QueryKbTool.vue index 7306ab8c..4be4ec6b 100644 --- a/web/src/components/ToolCallingResult/tools/QueryKbTool.vue +++ b/web/src/components/ToolCallingResult/tools/QueryKbTool.vue @@ -11,7 +11,79 @@ @@ -47,18 +119,91 @@ const operationLabel = computed(() => `${toolName.value} 搜索`) const kbName = computed(() => args.value.kb_name || '') const queryText = computed(() => args.value.query_text || '') -const parseData = (content) => { - if (typeof content === 'string') { - try { - return JSON.parse(content) - } catch { - return [] - } - } - return content || [] +const EMPTY_RESULT = Object.freeze({ + chunks: [], + entities: [], + relationships: [], + references: [] +}) + +let lastResultContent = null +let lastParsedResult = EMPTY_RESULT + +const normalizeChunks = (payload) => { + if (Array.isArray(payload)) return payload + if (!payload || typeof payload !== 'object') return [] + + if (Array.isArray(payload.chunks)) return payload.chunks + if (Array.isArray(payload.data?.chunks)) return payload.data.chunks + + return [] } -const parsedData = (content) => parseData(content) +const parseResult = (content) => { + if (content === lastResultContent) return lastParsedResult + + let payload = content + if (typeof content === 'string') { + try { + payload = JSON.parse(content) + } catch { + lastResultContent = content + lastParsedResult = EMPTY_RESULT + return lastParsedResult + } + } + + if (!payload || typeof payload !== 'object') { + lastResultContent = content + lastParsedResult = EMPTY_RESULT + return lastParsedResult + } + + // 兼容 Milvus chunks 与 LightRAG graph/all 结构。 + const nextResult = { + chunks: normalizeChunks(payload), + entities: Array.isArray(payload.entities) + ? payload.entities + : Array.isArray(payload.data?.entities) + ? payload.data.entities + : [], + relationships: Array.isArray(payload.relationships) + ? payload.relationships + : Array.isArray(payload.data?.relationships) + ? payload.data.relationships + : [], + references: Array.isArray(payload.references) + ? payload.references + : Array.isArray(payload.data?.references) + ? payload.data.references + : [] + } + + lastResultContent = content + lastParsedResult = nextResult + return nextResult +} + +const parsedResult = (content) => parseResult(content) + +const hasGraphData = (result) => + result.entities.length > 0 || result.relationships.length > 0 || result.references.length > 0 + +const getEntityName = (entity) => entity?.entity_name || entity?.name || '未命名实体' +const getEntityType = (entity) => entity?.entity_type || entity?.type || '未分类' + +const getPreviewText = (text = '', maxLength = 100) => { + const normalized = String(text) + return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength)}...` +} + +const getReferenceUrl = (reference) => reference?.file_path || reference?.url || '#' + +const getReferenceLabel = (reference, index) => { + const referenceId = reference?.reference_id || `#${index + 1}` + const url = getReferenceUrl(reference) + return `${referenceId}: ${url}` +} diff --git a/web/src/components/sources/KbResultGroupedList.vue b/web/src/components/sources/KbResultGroupedList.vue index b260e172..d6ace920 100644 --- a/web/src/components/sources/KbResultGroupedList.vue +++ b/web/src/components/sources/KbResultGroupedList.vue @@ -68,7 +68,7 @@ import KbChunkDetailModal from './KbChunkDetailModal.vue' const props = defineProps({ chunks: { - type: Array, + type: [Array, Object], default: () => [] }, showSummary: { @@ -86,8 +86,18 @@ const modalVisible = ref(false) const selectedChunk = ref(null) const selectedChunkIndex = ref(null) +const resolveChunks = (input) => { + if (Array.isArray(input)) return input + if (!input || typeof input !== 'object') return [] + + if (Array.isArray(input.chunks)) return input.chunks + if (Array.isArray(input.data?.chunks)) return input.data.chunks + + return [] +} + const normalizedChunks = computed(() => - (props.chunks || []).filter((item) => item && typeof item === 'object' && item.content) + resolveChunks(props.chunks).filter((item) => item && typeof item === 'object' && item.content) ) const fileGroupList = computed(() => {