diff --git a/pyproject.toml b/pyproject.toml index 2999a995..3c63e574 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ dependencies = [ "chromadb>=0.4.24", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.0.0b7", - "langbot-plugin==0.2.7", + "langbot-plugin==0.3.0rc1", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "tboxsdk>=0.0.10", diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/base.py b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py index 96ed001c..4f9bb5b4 100644 --- a/src/langbot/pkg/api/http/controller/groups/knowledge/base.py +++ b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py @@ -13,7 +13,10 @@ class KnowledgeBaseRouterGroup(group.RouterGroup): elif quart.request.method == 'POST': json_data = await quart.request.json - knowledge_base_uuid = await self.ap.knowledge_service.create_knowledge_base(json_data) + try: + knowledge_base_uuid = await self.ap.knowledge_service.create_knowledge_base(json_data) + except ValueError as e: + return self.http_status(400, -1, str(e)) return self.success(data={'uuid': knowledge_base_uuid}) return self.http_status(405, -1, 'Method not allowed') @@ -39,7 +42,7 @@ class KnowledgeBaseRouterGroup(group.RouterGroup): elif quart.request.method == 'PUT': json_data = await quart.request.json await self.ap.knowledge_service.update_knowledge_base(knowledge_base_uuid, json_data) - return self.success({}) + return self.success(data={'uuid': knowledge_base_uuid}) elif quart.request.method == 'DELETE': await self.ap.knowledge_service.delete_knowledge_base(knowledge_base_uuid) @@ -65,8 +68,12 @@ class KnowledgeBaseRouterGroup(group.RouterGroup): if not file_id: return self.http_status(400, -1, 'File ID is required') + parser_plugin_id = json_data.get('parser_plugin_id') + # 调用服务层方法将文件与知识库关联 - task_id = await self.ap.knowledge_service.store_file(knowledge_base_uuid, file_id) + task_id = await self.ap.knowledge_service.store_file( + knowledge_base_uuid, file_id, parser_plugin_id=parser_plugin_id + ) return self.success( { 'task_id': task_id, @@ -90,5 +97,13 @@ class KnowledgeBaseRouterGroup(group.RouterGroup): async def retrieve_knowledge_base(knowledge_base_uuid: str) -> str: json_data = await quart.request.json query = json_data.get('query') - results = await self.ap.knowledge_service.retrieve_knowledge_base(knowledge_base_uuid, query) + + if not query or not query.strip(): + return self.http_status(400, -1, 'Query is required and cannot be empty') + + # Extract retrieval_settings to allow dynamic control over Knowledge Engine behavior (e.g. top_k, filters) + retrieval_settings = json_data.get('retrieval_settings', {}) + results = await self.ap.knowledge_service.retrieve_knowledge_base( + knowledge_base_uuid, query, retrieval_settings + ) return self.success(data={'results': results}) diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py b/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py new file mode 100644 index 00000000..28f0710e --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py @@ -0,0 +1,45 @@ +import quart +from urllib.parse import unquote +from ... import group + + +@group.group_class('knowledge_engines', '/api/v1/knowledge/engines') +class KnowledgeEnginesRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def list_knowledge_engines() -> quart.Response: + """List all available Knowledge Engines from plugins. + + Returns a list of Knowledge Engines with their capabilities and configuration schemas. + This is used by the frontend to render the knowledge base creation wizard. + """ + engines = await self.ap.knowledge_service.list_knowledge_engines() + return self.success(data={'engines': engines}) + + @self.route( + '//creation-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def get_engine_creation_schema(plugin_id: str) -> quart.Response: + """Get creation settings schema for a specific Knowledge Engine. + + plugin_id is in 'author/name' format, captured via converter. + """ + plugin_id = unquote(plugin_id) + if '/' not in plugin_id: + return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.') + schema = await self.ap.knowledge_service.get_engine_creation_schema(plugin_id) + return self.success(data={'schema': schema}) + + @self.route( + '//retrieval-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def get_engine_retrieval_schema(plugin_id: str) -> quart.Response: + """Get retrieval settings schema for a specific Knowledge Engine. + + plugin_id is in 'author/name' format, captured via converter. + """ + plugin_id = unquote(plugin_id) + if '/' not in plugin_id: + return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.') + schema = await self.ap.knowledge_service.get_engine_retrieval_schema(plugin_id) + return self.success(data={'schema': schema}) diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/external.py b/src/langbot/pkg/api/http/controller/groups/knowledge/external.py deleted file mode 100644 index 324889e7..00000000 --- a/src/langbot/pkg/api/http/controller/groups/knowledge/external.py +++ /dev/null @@ -1,61 +0,0 @@ -import quart -from ... import group - - -@group.group_class('external_knowledge_base', '/api/v1/knowledge/external-bases') -class ExternalKnowledgeBaseRouterGroup(group.RouterGroup): - async def initialize(self) -> None: - @self.route('/retrievers', methods=['GET']) - async def list_knowledge_retrievers() -> quart.Response: - """List all available knowledge retrievers from plugins.""" - retrievers = await self.ap.plugin_connector.list_knowledge_retrievers() - return self.success(data={'retrievers': retrievers}) - - @self.route('', methods=['POST', 'GET']) - async def handle_external_knowledge_bases() -> quart.Response: - if quart.request.method == 'GET': - external_kbs = await self.ap.external_kb_service.get_external_knowledge_bases() - return self.success(data={'bases': external_kbs}) - - elif quart.request.method == 'POST': - json_data = await quart.request.json - kb_uuid = await self.ap.external_kb_service.create_external_knowledge_base(json_data) - return self.success(data={'uuid': kb_uuid}) - - return self.http_status(405, -1, 'Method not allowed') - - @self.route( - '/', - methods=['GET', 'DELETE', 'PUT'], - ) - async def handle_specific_external_knowledge_base(kb_uuid: str) -> quart.Response: - if quart.request.method == 'GET': - external_kb = await self.ap.external_kb_service.get_external_knowledge_base(kb_uuid) - - if external_kb is None: - return self.http_status(404, -1, 'external knowledge base not found') - - return self.success( - data={ - 'base': external_kb, - } - ) - - elif quart.request.method == 'PUT': - json_data = await quart.request.json - await self.ap.external_kb_service.update_external_knowledge_base(kb_uuid, json_data) - return self.success({}) - - elif quart.request.method == 'DELETE': - await self.ap.external_kb_service.delete_external_knowledge_base(kb_uuid) - return self.success({}) - - @self.route( - '//retrieve', - methods=['POST'], - ) - async def retrieve_external_knowledge_base(kb_uuid: str) -> str: - json_data = await quart.request.json - query = json_data.get('query') - results = await self.ap.external_kb_service.retrieve_external_knowledge_base(kb_uuid, query) - return self.success(data={'results': results}) diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py b/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py new file mode 100644 index 00000000..a5e853cb --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py @@ -0,0 +1,16 @@ +import quart +from ... import group + + +@group.group_class('parsers', '/api/v1/knowledge/parsers') +class ParsersRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def list_parsers() -> quart.Response: + """List all available parsers from plugins. + + Optional query parameter `mime_type` to filter parsers by supported MIME type. + """ + mime_type = quart.request.args.get('mime_type') + parsers = await self.ap.knowledge_service.list_parsers(mime_type) + return self.success(data={'parsers': parsers}) diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py index 1828fb2b..e7fb6118 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -68,7 +68,7 @@ class PipelinesRouterGroup(group.RouterGroup): return self.http_status(404, -1, 'pipeline not found') # Only include plugins with pipeline-related components (Command, EventListener, Tool) - # Plugins that only have KnowledgeRetriever components are not suitable for pipeline extensions + # Plugins that only have KnowledgeEngine components are not suitable for pipeline extensions pipeline_component_kinds = ['Command', 'EventListener', 'Tool'] plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds) mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True) diff --git a/src/langbot/pkg/api/http/service/external_kb.py b/src/langbot/pkg/api/http/service/external_kb.py deleted file mode 100644 index 4ac5d0fc..00000000 --- a/src/langbot/pkg/api/http/service/external_kb.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from ....core import app -import sqlalchemy -from langbot.pkg.entity.persistence import rag as persistence_rag -import uuid - - -class ExternalKBService: - """External KB service""" - - ap: app.Application - - def __init__(self, ap: app.Application) -> None: - self.ap = ap - - # External Knowledge Base methods - async def get_external_knowledge_bases(self) -> list[dict]: - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_rag.ExternalKnowledgeBase)) - external_kbs = result.all() - return [ - self.ap.persistence_mgr.serialize_model(persistence_rag.ExternalKnowledgeBase, external_kb) - for external_kb in external_kbs - ] - - async def get_external_knowledge_base(self, kb_uuid: str) -> dict | None: - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_rag.ExternalKnowledgeBase).where( - persistence_rag.ExternalKnowledgeBase.uuid == kb_uuid - ) - ) - external_kb = result.first() - if external_kb is None: - return None - return self.ap.persistence_mgr.serialize_model(persistence_rag.ExternalKnowledgeBase, external_kb) - - async def create_external_knowledge_base(self, kb_data: dict) -> str: - kb_data['uuid'] = str(uuid.uuid4()) - await self.ap.persistence_mgr.execute_async( - sqlalchemy.insert(persistence_rag.ExternalKnowledgeBase).values(kb_data) - ) - - kb = await self.get_external_knowledge_base(kb_data['uuid']) - - await self.ap.rag_mgr.load_external_knowledge_base(kb) - - return kb_data['uuid'] - - async def retrieve_external_knowledge_base(self, kb_uuid: str, query: str) -> list[dict]: - """Retrieve external knowledge base""" - runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) - if runtime_kb is None: - raise Exception('Knowledge base not found') - return [ - result.model_dump() for result in await runtime_kb.retrieve(query, 5) - ] # top_k is just a placeholder for external knowledge base - - async def update_external_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None: - if 'uuid' in kb_data: - del kb_data['uuid'] - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.update(persistence_rag.ExternalKnowledgeBase) - .values(kb_data) - .where(persistence_rag.ExternalKnowledgeBase.uuid == kb_uuid) - ) - await self.ap.rag_mgr.remove_knowledge_base_from_runtime(kb_uuid) - - kb = await self.get_external_knowledge_base(kb_uuid) - - await self.ap.rag_mgr.load_external_knowledge_base(kb) - - async def delete_external_knowledge_base(self, kb_uuid: str) -> None: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.delete(persistence_rag.ExternalKnowledgeBase).where( - persistence_rag.ExternalKnowledgeBase.uuid == kb_uuid - ) - ) - - await self.ap.rag_mgr.delete_knowledge_base(kb_uuid) diff --git a/src/langbot/pkg/api/http/service/knowledge.py b/src/langbot/pkg/api/http/service/knowledge.py index b753ce5a..3170a113 100644 --- a/src/langbot/pkg/api/http/service/knowledge.py +++ b/src/langbot/pkg/api/http/service/knowledge.py @@ -1,6 +1,5 @@ from __future__ import annotations -import uuid import sqlalchemy from ....core import app @@ -17,64 +16,77 @@ class KnowledgeService: async def get_knowledge_bases(self) -> list[dict]: """获取所有知识库""" - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_rag.KnowledgeBase)) - knowledge_bases = result.all() - return [ - self.ap.persistence_mgr.serialize_model(persistence_rag.KnowledgeBase, knowledge_base) - for knowledge_base in knowledge_bases - ] + return await self.ap.rag_mgr.get_all_knowledge_base_details() async def get_knowledge_base(self, kb_uuid: str) -> dict | None: """获取知识库""" - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid) - ) - knowledge_base = result.first() - if knowledge_base is None: - return None - return self.ap.persistence_mgr.serialize_model(persistence_rag.KnowledgeBase, knowledge_base) + return await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid) async def create_knowledge_base(self, kb_data: dict) -> str: """创建知识库""" - kb_data['uuid'] = str(uuid.uuid4()) - await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.KnowledgeBase).values(kb_data)) + # In new architecture, we delegate entirely to RAGManager which uses plugins. + # Legacy internal KB creation is removed. - kb = await self.get_knowledge_base(kb_data['uuid']) + knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id') + if not knowledge_engine_plugin_id: + raise ValueError('knowledge_engine_plugin_id is required') - await self.ap.rag_mgr.load_knowledge_base(kb) - - return kb_data['uuid'] + kb = await self.ap.rag_mgr.create_knowledge_base( + name=kb_data.get('name', 'Untitled'), + knowledge_engine_plugin_id=knowledge_engine_plugin_id, + creation_settings=kb_data.get('creation_settings', {}), + retrieval_settings=kb_data.get('retrieval_settings', {}), + description=kb_data.get('description', ''), + ) + return kb.uuid async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None: """更新知识库""" - if 'uuid' in kb_data: - del kb_data['uuid'] + # Filter to only mutable fields + filtered_data = {k: v for k, v in kb_data.items() if k in persistence_rag.KnowledgeBase.MUTABLE_FIELDS} - if 'embedding_model_uuid' in kb_data: - del kb_data['embedding_model_uuid'] + if not filtered_data: + return await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_rag.KnowledgeBase) - .values(kb_data) + .values(filtered_data) .where(persistence_rag.KnowledgeBase.uuid == kb_uuid) ) await self.ap.rag_mgr.remove_knowledge_base_from_runtime(kb_uuid) kb = await self.get_knowledge_base(kb_uuid) + if kb is None: + raise Exception('Knowledge base not found after update') await self.ap.rag_mgr.load_knowledge_base(kb) - async def store_file(self, kb_uuid: str, file_id: str) -> int: + async def _check_doc_capability(self, kb_uuid: str, operation: str) -> None: + """Check if the KB's Knowledge Engine supports document operations. + + Args: + kb_uuid: Knowledge base UUID. + operation: Human-readable operation name for error messages. + + Raises: + Exception: If the KB does not support doc_ingestion. + """ + kb_info = await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid) + if not kb_info: + raise Exception('Knowledge base not found') + capabilities = kb_info.get('knowledge_engine', {}).get('capabilities', []) + if 'doc_ingestion' not in capabilities: + raise Exception(f'This knowledge base does not support {operation}') + + async def store_file(self, kb_uuid: str, file_id: str, parser_plugin_id: str | None = None) -> str: """存储文件""" - # await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.File).values(kb_id=kb_uuid, file_id=file_id)) - # await self.ap.rag_mgr.store_file(file_id) runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) if runtime_kb is None: raise Exception('Knowledge base not found') - # Only internal KBs support file storage - if runtime_kb.get_type() != 'internal': - raise Exception('Only internal knowledge bases support file storage') - result = await runtime_kb.store_file(file_id) + + await self._check_doc_capability(kb_uuid, 'document upload') + + result = await runtime_kb.store_file(file_id, parser_plugin_id=parser_plugin_id) # Update the KB's updated_at timestamp await self.ap.persistence_mgr.execute_async( @@ -85,14 +97,18 @@ class KnowledgeService: return result - async def retrieve_knowledge_base(self, kb_uuid: str, query: str) -> list[dict]: + async def retrieve_knowledge_base( + self, kb_uuid: str, query: str, retrieval_settings: dict | None = None + ) -> list[dict]: """检索知识库""" runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) if runtime_kb is None: raise Exception('Knowledge base not found') - return [ - result.model_dump() for result in await runtime_kb.retrieve(query, runtime_kb.knowledge_base_entity.top_k) - ] + + # Pass retrieval_settings + results = await runtime_kb.retrieve(query, settings=retrieval_settings) + + return [result.model_dump() for result in results] async def get_files_by_knowledge_base(self, kb_uuid: str) -> list[dict]: """获取知识库文件""" @@ -107,9 +123,9 @@ class KnowledgeService: runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) if runtime_kb is None: raise Exception('Knowledge base not found') - # Only internal KBs support file deletion - if runtime_kb.get_type() != 'internal': - raise Exception('Only internal knowledge bases support file deletion') + + await self._check_doc_capability(kb_uuid, 'document deletion') + await runtime_kb.delete_file(file_id) # Update the KB's updated_at timestamp @@ -121,13 +137,14 @@ class KnowledgeService: async def delete_knowledge_base(self, kb_uuid: str) -> None: """删除知识库""" - await self.ap.rag_mgr.delete_knowledge_base(kb_uuid) - + # Delete from DB first to commit the deletion, then clean up runtime/plugin (best-effort) await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid) ) # delete files + # NOTE: Chunk cleanup is for legacy (pre-plugin) KBs that stored chunks locally. + # For plugin-based Knowledge Engines, the Chunk table is not populated, so this is a no-op. files = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_rag.File).where(persistence_rag.File.kb_id == kb_uuid) ) @@ -140,3 +157,53 @@ class KnowledgeService: await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_rag.File).where(persistence_rag.File.uuid == file.uuid) ) + + # Remove from runtime and notify plugin (best-effort, DB is already cleaned up) + await self.ap.rag_mgr.delete_knowledge_base(kb_uuid) + + # ================= Knowledge Engine Discovery ================= + + async def list_knowledge_engines(self) -> list[dict]: + """List all available Knowledge Engines from plugins.""" + engines = [] + + if not self.ap.plugin_connector.is_enable_plugin: + return engines + + # Get KnowledgeEngine plugins + try: + knowledge_engines = await self.ap.plugin_connector.list_knowledge_engines() + engines.extend(knowledge_engines) + except Exception as e: + self.ap.logger.warning(f'Failed to list Knowledge Engines from plugins: {e}') + + return engines + + async def list_parsers(self, mime_type: str | None = None) -> list[dict]: + """List available parsers, optionally filtered by MIME type.""" + if not self.ap.plugin_connector.is_enable_plugin: + return [] + try: + parsers = await self.ap.plugin_connector.list_parsers() + if mime_type: + parsers = [p for p in parsers if mime_type in p.get('supported_mime_types', [])] + return parsers + except Exception as e: + self.ap.logger.warning(f'Failed to list parsers: {e}') + return [] + + async def get_engine_creation_schema(self, plugin_id: str) -> dict: + """Get creation settings schema for a specific Knowledge Engine.""" + try: + return await self.ap.plugin_connector.get_rag_creation_schema(plugin_id) + except Exception as e: + self.ap.logger.warning(f'Failed to get creation schema for {plugin_id}: {e}') + return {} + + async def get_engine_retrieval_schema(self, plugin_id: str) -> dict: + """Get retrieval settings schema for a specific Knowledge Engine.""" + try: + return await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id) + except Exception as e: + self.ap.logger.warning(f'Failed to get retrieval schema for {plugin_id}: {e}') + return {} diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index a5b096f3..98e88617 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -29,7 +29,6 @@ from ..api.http.service import knowledge as knowledge_service from ..api.http.service import mcp as mcp_service from ..api.http.service import apikey as apikey_service from ..api.http.service import webhook as webhook_service -from ..api.http.service import external_kb as external_kb_service from ..api.http.service import monitoring as monitoring_service from ..discover import engine as discover_engine from ..storage import mgr as storagemgr @@ -37,6 +36,7 @@ from ..utils import logcache from . import taskmgr from . import entities as core_entities from ..rag.knowledge import kbmgr as rag_mgr +from ..rag.service import RAGRuntimeService from ..vector import mgr as vectordb_mgr from ..telemetry import telemetry as telemetry_module from ..survey import manager as survey_module @@ -63,6 +63,7 @@ class Application: model_mgr: llm_model_mgr.ModelManager = None rag_mgr: rag_mgr.RAGManager = None + rag_runtime_service: RAGRuntimeService = None # TODO move to pipeline tool_mgr: llm_tool_mgr.ToolManager = None @@ -138,8 +139,6 @@ class Application: knowledge_service: knowledge_service.KnowledgeService = None - external_kb_service: external_kb_service.ExternalKBService = None - mcp_service: mcp_service.MCPService = None apikey_service: apikey_service.ApiKeyService = None diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index f0398a25..62f0ae7b 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -12,6 +12,7 @@ from ...provider.session import sessionmgr as llm_session_mgr from ...provider.modelmgr import modelmgr as llm_model_mgr from ...provider.tools import toolmgr as llm_tool_mgr from ...rag.knowledge import kbmgr as rag_mgr +from ...rag.service import RAGRuntimeService from ...platform import botmgr as im_mgr from ...platform.webhook_pusher import WebhookPusher from ...persistence import mgr as persistencemgr @@ -26,7 +27,6 @@ from ...api.http.service import knowledge as knowledge_service from ...api.http.service import mcp as mcp_service from ...api.http.service import apikey as apikey_service from ...api.http.service import webhook as webhook_service -from ...api.http.service import external_kb as external_kb_service from ...api.http.service import monitoring as monitoring_service from ...discover import engine as discover_engine from ...storage import mgr as storagemgr @@ -73,9 +73,6 @@ class BuildAppStage(stage.BootingStage): knowledge_service_inst = knowledge_service.KnowledgeService(ap) ap.knowledge_service = knowledge_service_inst - external_kb_service_inst = external_kb_service.ExternalKBService(ap) - ap.external_kb_service = external_kb_service_inst - mcp_service_inst = mcp_service.MCPService(ap) ap.mcp_service = mcp_service_inst @@ -152,6 +149,9 @@ class BuildAppStage(stage.BootingStage): await rag_mgr_inst.initialize() ap.rag_mgr = rag_mgr_inst + # Initialize RAG Runtime Service for plugins + ap.rag_runtime_service = RAGRuntimeService(ap) + # 初始化向量数据库管理器 vectordb_mgr_inst = vectordb_mgr.VectorDBManager(ap) await vectordb_mgr_inst.initialize() diff --git a/src/langbot/pkg/entity/persistence/rag.py b/src/langbot/pkg/entity/persistence/rag.py index 5abd6c1a..cfb1f0a5 100644 --- a/src/langbot/pkg/entity/persistence/rag.py +++ b/src/langbot/pkg/entity/persistence/rag.py @@ -10,8 +10,21 @@ class KnowledgeBase(Base): emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='📚') created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now()) - embedding_model_uuid = sqlalchemy.Column(sqlalchemy.String, default='') - top_k = sqlalchemy.Column(sqlalchemy.Integer, default=5) + # New fields for plugin-based RAG + knowledge_engine_plugin_id = sqlalchemy.Column(sqlalchemy.String, nullable=True) + collection_id = sqlalchemy.Column(sqlalchemy.String, nullable=True) + creation_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None) + retrieval_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None) + + # Field sets for different operations + MUTABLE_FIELDS = {'name', 'description', 'retrieval_settings'} + """Fields that can be updated after creation.""" + + CREATE_FIELDS = MUTABLE_FIELDS | {'uuid', 'knowledge_engine_plugin_id', 'collection_id', 'creation_settings'} + """Fields used when creating a new knowledge base.""" + + ALL_DB_FIELDS = CREATE_FIELDS | {'emoji', 'created_at', 'updated_at'} + """All fields stored in database (for loading from DB row).""" class File(Base): @@ -29,16 +42,3 @@ class Chunk(Base): uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) file_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) text = sqlalchemy.Column(sqlalchemy.Text) - - -class ExternalKnowledgeBase(Base): - __tablename__ = 'external_knowledge_bases' - uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) - name = sqlalchemy.Column(sqlalchemy.String, index=True) - description = sqlalchemy.Column(sqlalchemy.Text) - emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='🔗') - plugin_author = sqlalchemy.Column(sqlalchemy.String, nullable=False) - plugin_name = sqlalchemy.Column(sqlalchemy.String, nullable=False) - retriever_name = sqlalchemy.Column(sqlalchemy.String, nullable=False) - retriever_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) - created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now()) diff --git a/src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py b/src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py new file mode 100644 index 00000000..7bca300c --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py @@ -0,0 +1,184 @@ +import json + +import sqlalchemy +from .. import migration + + +@migration.migration_class(20) +class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration): + """Migrate to unified Knowledge Engine plugin architecture. + + Changes: + - Add knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings columns to knowledge_bases + - Migrate existing top_k values into retrieval_settings JSON + - Migrate existing embedding_model_uuid into creation_settings JSON + - Drop embedding_model_uuid and top_k columns (PostgreSQL only; SQLite leaves them unmapped) + - Drop external_knowledge_bases table (no longer needed; external KB data is not migrated) + """ + + async def upgrade(self): + """Upgrade""" + await self._add_columns_to_knowledge_bases() + await self._migrate_top_k_to_retrieval_settings() + await self._migrate_embedding_model_uuid_to_creation_settings() + await self._drop_old_columns() + await self._drop_external_knowledge_bases_table() + + async def _get_table_columns(self, table_name: str) -> list[str]: + """Get column names from a table (works for both SQLite and PostgreSQL).""" + if self.ap.persistence_mgr.db.name == 'postgresql': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;' + ).bindparams(table_name=table_name) + ) + return [row[0] for row in result.fetchall()] + else: + # SQLite PRAGMA does not support bind parameters; validate identifier. + if not table_name.isidentifier(): + raise ValueError(f'Invalid table name: {table_name}') + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) + return [row[1] for row in result.fetchall()] + + async def _table_exists(self, table_name: str) -> bool: + """Check if a table exists.""" + if self.ap.persistence_mgr.db.name == 'postgresql': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);' + ).bindparams(table_name=table_name) + ) + return result.scalar() + else: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams( + table_name=table_name + ) + ) + return result.first() is not None + + async def _add_columns_to_knowledge_bases(self): + """Add new RAG plugin architecture columns to knowledge_bases table.""" + columns = await self._get_table_columns('knowledge_bases') + + new_columns = { + 'knowledge_engine_plugin_id': 'VARCHAR', + 'collection_id': 'VARCHAR', + 'creation_settings': 'TEXT', # JSON stored as TEXT for SQLite compatibility + 'retrieval_settings': 'TEXT', + } + + for col_name, col_type in new_columns.items(): + if col_name not in columns: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};') + ) + + # For existing knowledge bases without knowledge_engine_plugin_id, + # set collection_id = uuid (same default as new KBs) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('UPDATE knowledge_bases SET collection_id = uuid WHERE collection_id IS NULL;') + ) + + async def _migrate_top_k_to_retrieval_settings(self): + """Migrate existing top_k values into retrieval_settings JSON.""" + columns = await self._get_table_columns('knowledge_bases') + if 'top_k' not in columns: + return + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'SELECT uuid, top_k FROM knowledge_bases WHERE top_k IS NOT NULL AND retrieval_settings IS NULL;' + ) + ) + rows = result.fetchall() + + for row in rows: + kb_uuid = row[0] + top_k = row[1] + retrieval_settings = json.dumps({'top_k': top_k}) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('UPDATE knowledge_bases SET retrieval_settings = :rs WHERE uuid = :uuid;').bindparams( + rs=retrieval_settings, uuid=kb_uuid + ) + ) + + async def _migrate_embedding_model_uuid_to_creation_settings(self): + """Migrate existing embedding_model_uuid into creation_settings JSON.""" + columns = await self._get_table_columns('knowledge_bases') + if 'embedding_model_uuid' not in columns: + return + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'SELECT uuid, embedding_model_uuid, creation_settings FROM knowledge_bases ' + "WHERE embedding_model_uuid IS NOT NULL AND embedding_model_uuid != '';" + ) + ) + rows = result.fetchall() + + for row in rows: + kb_uuid = row[0] + emb_uuid = row[1] + existing_settings = row[2] + + if existing_settings and isinstance(existing_settings, str): + try: + settings = json.loads(existing_settings) + except (json.JSONDecodeError, TypeError): + settings = {} + elif isinstance(existing_settings, dict): + settings = existing_settings + else: + settings = {} + + if 'embedding_model_uuid' not in settings: + settings['embedding_model_uuid'] = emb_uuid + new_settings = json.dumps(settings) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE knowledge_bases SET creation_settings = :cs WHERE uuid = :uuid;' + ).bindparams(cs=new_settings, uuid=kb_uuid) + ) + + async def _drop_old_columns(self): + """Drop embedding_model_uuid and top_k columns (PostgreSQL only). + + SQLite does not support DROP COLUMN in older versions, so we leave the + columns in place — the SQLAlchemy entity simply won't map them. + """ + if self.ap.persistence_mgr.db.name != 'postgresql': + return + + columns = await self._get_table_columns('knowledge_bases') + + if 'embedding_model_uuid' in columns: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN embedding_model_uuid;') + ) + + if 'top_k' in columns: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;') + ) + + async def _drop_external_knowledge_bases_table(self): + """Drop the external_knowledge_bases table if it exists.""" + if await self._table_exists('external_knowledge_bases'): + # Log existing external KBs before dropping, so users are aware of data loss + rows = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT * FROM external_knowledge_bases;') + ) + existing = rows.fetchall() + if existing: + self.ap.logger.warning( + 'Dropping external_knowledge_bases table with %d existing record(s). ' + 'These external KB configurations will be removed: %s', + len(existing), + [dict(row._mapping) for row in existing], + ) + await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE external_knowledge_bases;')) + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 1ae54375..5404f0ed 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -7,7 +7,6 @@ import typing import os import sys import httpx -import traceback import sqlalchemy from async_lru import alru_cache from langbot_plugin.api.entities.builtin.pipeline.query import provider_session @@ -102,12 +101,6 @@ class PluginRuntimeConnector: self.handler_task = asyncio.create_task(self.handler.run()) _ = await self.handler.ping() self.ap.logger.info('Connected to plugin runtime.') - # Sync polymorphic component instances after connection - try: - await self.sync_polymorphic_component_instances() - except Exception as e: - traceback.print_exc() - self.ap.logger.error(f'Failed to sync polymorphic component instances: {e}') await self.handler_task task: asyncio.Task | None = None @@ -463,30 +456,18 @@ class PluginRuntimeConnector: yield cmd_ret - # KnowledgeRetriever methods - async def list_knowledge_retrievers(self, bound_plugins: list[str] | None = None) -> list[dict[str, Any]]: - """List all available KnowledgeRetriever components.""" - if not self.is_enable_plugin: - return [] - - retrievers_data = await self.handler.list_knowledge_retrievers(include_plugins=bound_plugins) - return retrievers_data - async def retrieve_knowledge( self, plugin_author: str, plugin_name: str, retriever_name: str, - instance_id: str, retrieval_context: dict[str, Any], - ) -> list[dict[str, Any]]: - """Retrieve knowledge using a KnowledgeRetriever instance.""" + ) -> dict[str, Any]: + """Retrieve knowledge using a KnowledgeEngine instance.""" if not self.is_enable_plugin: - return [] + return {'results': []} - return await self.handler.retrieve_knowledge( - plugin_author, plugin_name, retriever_name, instance_id, retrieval_context - ) + return await self.handler.retrieve_knowledge(plugin_author, plugin_name, retriever_name, retrieval_context) def dispose(self): # No need to consider the shutdown on Windows @@ -500,41 +481,84 @@ class PluginRuntimeConnector: self.heartbeat_task.cancel() self.heartbeat_task = None - async def sync_polymorphic_component_instances(self) -> dict[str, Any]: - """Sync polymorphic component instances with runtime. + @staticmethod + def _parse_plugin_id(plugin_id: str) -> tuple[str, str]: + """Parse a plugin ID string into (author, name). - This collects all external knowledge bases from database and sends to runtime - to ensure instance integrity across restarts. + Args: + plugin_id: Plugin ID in 'author/name' format. + + Returns: + Tuple of (plugin_author, plugin_name). + + Raises: + ValueError: If plugin_id is not in the expected 'author/name' format. + """ + if '/' not in plugin_id: + raise ValueError( + f"Invalid plugin_id format: '{plugin_id}'. Expected 'author/name' format (e.g. 'langbot/rag-engine')." + ) + return plugin_id.split('/', 1) + + async def call_rag_ingest(self, plugin_id: str, context_data: dict[str, Any]) -> dict[str, Any]: + """Call plugin to ingest document. + + Args: + plugin_id: Target plugin ID (author/name). + context_data: IngestionContext data. + """ + plugin_author, plugin_name = self._parse_plugin_id(plugin_id) + return await self.handler.rag_ingest_document(plugin_author, plugin_name, context_data) + + async def call_rag_delete_document(self, plugin_id: str, document_id: str, kb_id: str) -> bool: + plugin_author, plugin_name = self._parse_plugin_id(plugin_id) + return await self.handler.rag_delete_document(plugin_author, plugin_name, document_id, kb_id) + + async def get_rag_creation_schema(self, plugin_id: str) -> dict[str, Any]: + plugin_author, plugin_name = self._parse_plugin_id(plugin_id) + return await self.handler.get_rag_creation_schema(plugin_author, plugin_name) + + async def get_rag_retrieval_schema(self, plugin_id: str) -> dict[str, Any]: + plugin_author, plugin_name = self._parse_plugin_id(plugin_id) + return await self.handler.get_rag_retrieval_schema(plugin_author, plugin_name) + + async def rag_on_kb_create(self, plugin_id: str, kb_id: str, config: dict[str, Any]) -> dict[str, Any]: + """Notify plugin about KB creation.""" + plugin_author, plugin_name = self._parse_plugin_id(plugin_id) + return await self.handler.rag_on_kb_create(plugin_author, plugin_name, kb_id, config) + + async def rag_on_kb_delete(self, plugin_id: str, kb_id: str) -> dict[str, Any]: + """Notify plugin about KB deletion.""" + plugin_author, plugin_name = self._parse_plugin_id(plugin_id) + return await self.handler.rag_on_kb_delete(plugin_author, plugin_name, kb_id) + + async def call_rag_retrieve(self, plugin_id: str, retrieval_context: dict[str, Any]) -> dict[str, Any]: + """Call plugin to retrieve knowledge. + + Args: + plugin_id: Target plugin ID (author/name). + retrieval_context: RetrievalContext data. + """ + plugin_author, plugin_name = self._parse_plugin_id(plugin_id) + return await self.handler.retrieve_knowledge(plugin_author, plugin_name, '', retrieval_context) + + async def list_knowledge_engines(self) -> list[dict[str, Any]]: + """List all available Knowledge Engines from plugins. + + Returns a list of Knowledge Engines with their capabilities and configuration schemas. """ if not self.is_enable_plugin: - return {} + return [] - # ===== external knowledge bases ===== + return await self.handler.list_knowledge_engines() - external_kbs = await self.ap.external_kb_service.get_external_knowledge_bases() + async def list_parsers(self) -> list[dict[str, Any]]: + """List all available parsers from plugins.""" + if not self.is_enable_plugin: + return [] + return await self.handler.list_parsers() - # Build required_instances list - required_instances = [] - for kb in external_kbs: - required_instances.append( - { - 'instance_id': kb['uuid'], - 'plugin_author': kb['plugin_author'], - 'plugin_name': kb['plugin_name'], - 'component_kind': 'KnowledgeRetriever', - 'component_name': kb['retriever_name'], - 'config': kb['retriever_config'], - } - ) - - self.ap.logger.info(f'Syncing {len(required_instances)} polymorphic component instances to runtime') - - # Send to runtime - sync_result = await self.handler.sync_polymorphic_component_instances(required_instances) - - self.ap.logger.info( - f'Sync complete: {len(sync_result.get("success_instances", []))} succeeded, ' - f'{len(sync_result.get("failed_instances", []))} failed' - ) - - return sync_result + async def call_parser(self, plugin_id: str, context_data: dict[str, Any], file_bytes: bytes) -> dict[str, Any]: + """Call plugin to parse a document.""" + plugin_author, plugin_name = self._parse_plugin_id(plugin_id) + return await self.handler.parse_document(plugin_author, plugin_name, context_data, file_bytes) diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index e6b3b69c..dbe4698c 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -26,6 +26,20 @@ from ..core import app from ..utils import constants +def _make_rag_error_response(error: Exception, error_type: str, **extra_context) -> handler.ActionResponse: + """Create a clean error response for RAG operations. + + Args: + error: The caught exception. + error_type: A category string like 'EmbeddingError', 'VectorStoreError'. + **extra_context: Additional context fields for the error message. + """ + context_parts = [f'{k}={v}' for k, v in extra_context.items()] + context_str = f' [{", ".join(context_parts)}]' if context_parts else '' + message = f'[{error_type}/{type(error).__name__}]{context_str} {str(error)}' + return handler.ActionResponse.error(message=message) + + class RuntimeConnectionHandler(handler.Handler): """Runtime connection handler""" @@ -439,7 +453,7 @@ class RuntimeConnectionHandler(handler.Handler): }, ) - @self.action(RuntimeToLangBotAction.GET_CONFIG_FILE) + @self.action(PluginToRuntimeAction.GET_CONFIG_FILE) async def get_config_file(data: dict[str, Any]) -> handler.ActionResponse: """Get a config file by file key""" file_key = data['file_key'] @@ -458,6 +472,125 @@ class RuntimeConnectionHandler(handler.Handler): message=f'Failed to load config file {file_key}: {e}', ) + # ================= RAG Capability Handlers ================= + + @self.action(PluginToRuntimeAction.INVOKE_EMBEDDING) + async def invoke_embedding(data: dict[str, Any]) -> handler.ActionResponse: + embedding_model_uuid = data['embedding_model_uuid'] + texts = data['texts'] + + embedding_model = await self.ap.model_mgr.get_embedding_model_by_uuid(embedding_model_uuid) + if embedding_model is None: + return handler.ActionResponse.error( + message=f'Embedding model with embedding_model_uuid {embedding_model_uuid} not found', + ) + + try: + vectors = await embedding_model.provider.invoke_embedding(embedding_model, texts) + return handler.ActionResponse.success(data={'vectors': vectors}) + except Exception as e: + return _make_rag_error_response(e, 'EmbeddingError', embedding_model_uuid=embedding_model_uuid) + + @self.action(PluginToRuntimeAction.VECTOR_UPSERT) + async def vector_upsert(data: dict[str, Any]) -> handler.ActionResponse: + collection_id = data['collection_id'] + vectors = data['vectors'] + ids = data['ids'] + metadata = data.get('metadata') + documents = data.get('documents') + if len(vectors) != len(ids): + return handler.ActionResponse.error(message='vectors and ids must have same length') + if metadata and len(metadata) != len(vectors): + return handler.ActionResponse.error(message='metadata must match vectors length') + if documents and len(documents) != len(vectors): + return handler.ActionResponse.error(message='documents must match vectors length') + try: + await self.ap.rag_runtime_service.vector_upsert( + collection_id, + vectors, + ids, + metadata, + documents, + ) + return handler.ActionResponse.success(data={}) + except Exception as e: + return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id) + + @self.action(PluginToRuntimeAction.VECTOR_SEARCH) + async def vector_search(data: dict[str, Any]) -> handler.ActionResponse: + collection_id = data['collection_id'] + query_vector = data['query_vector'] + top_k = data['top_k'] + filters = data.get('filters') + search_type = data.get('search_type', 'vector') + query_text = data.get('query_text', '') + try: + results = await self.ap.rag_runtime_service.vector_search( + collection_id, + query_vector, + top_k, + filters, + search_type, + query_text, + ) + return handler.ActionResponse.success(data={'results': results}) + except Exception as e: + return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id) + + @self.action(PluginToRuntimeAction.VECTOR_DELETE) + async def vector_delete(data: dict[str, Any]) -> handler.ActionResponse: + collection_id = data['collection_id'] + file_ids = data.get('file_ids') + filters = data.get('filters') + try: + count = await self.ap.rag_runtime_service.vector_delete(collection_id, file_ids, filters) + return handler.ActionResponse.success(data={'count': count}) + except Exception as e: + return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id) + + @self.action(PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM) + async def get_knowledge_file_stream(data: dict[str, Any]) -> handler.ActionResponse: + storage_path = data['storage_path'] + try: + content_bytes = await self.ap.rag_runtime_service.get_file_stream(storage_path) + file_key = await self.send_file(content_bytes, '') + return handler.ActionResponse.success(data={'file_key': file_key}) + except Exception as e: + return _make_rag_error_response(e, 'FileServiceError', storage_path=storage_path) + + @self.action(PluginToRuntimeAction.INVOKE_PARSER) + async def invoke_parser(data: dict[str, Any]) -> handler.ActionResponse: + """Plugin requests host to invoke a parser plugin.""" + plugin_author = data['plugin_author'] + plugin_name = data['plugin_name'] + storage_path = data['storage_path'] + mime_type = data.get('mime_type', 'application/octet-stream') + filename = data.get('filename', '') + metadata = data.get('metadata', {}) + try: + # Read file from storage + file_bytes = await self.ap.rag_runtime_service.get_file_stream(storage_path) + context_data = { + 'mime_type': mime_type, + 'filename': filename, + 'metadata': metadata, + } + result = await self.ap.plugin_connector.call_parser( + f'{plugin_author}/{plugin_name}', context_data, file_bytes + ) + return handler.ActionResponse.success(data=result) + except Exception as e: + return _make_rag_error_response(e, 'ParserError') + + @self.action(CommonAction.PING) + async def ping(data: dict[str, Any]) -> handler.ActionResponse: + """Ping""" + return handler.ActionResponse.success( + data={ + 'pong': 'pong', + }, + ) + async def ping(self) -> dict[str, Any]: """Ping the runtime""" return await self.call_action( @@ -717,26 +850,13 @@ class RuntimeConnectionHandler(handler.Handler): async for ret in gen: yield ret - # KnowledgeRetriever methods - async def list_knowledge_retrievers(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]: - """List knowledge retrievers""" - result = await self.call_action( - LangBotToRuntimeAction.LIST_KNOWLEDGE_RETRIEVERS, - { - 'include_plugins': include_plugins, - }, - timeout=10, - ) - return result['retrievers'] - async def retrieve_knowledge( self, plugin_author: str, plugin_name: str, retriever_name: str, - instance_id: str, retrieval_context: dict[str, Any], - ) -> list[dict[str, Any]]: + ) -> dict[str, Any]: """Retrieve knowledge""" result = await self.call_action( LangBotToRuntimeAction.RETRIEVE_KNOWLEDGE, @@ -744,22 +864,10 @@ class RuntimeConnectionHandler(handler.Handler): 'plugin_author': plugin_author, 'plugin_name': plugin_name, 'retriever_name': retriever_name, - 'instance_id': instance_id, 'retrieval_context': retrieval_context, }, timeout=30, ) - return result['retrieval_results'] - - async def sync_polymorphic_component_instances(self, required_instances: list[dict[str, Any]]) -> dict[str, Any]: - """Sync polymorphic component instances with runtime""" - result = await self.call_action( - LangBotToRuntimeAction.SYNC_POLYMORPHIC_COMPONENT_INSTANCES, - { - 'required_instances': required_instances, - }, - timeout=30, - ) return result async def get_debug_info(self) -> dict[str, Any]: @@ -770,3 +878,91 @@ class RuntimeConnectionHandler(handler.Handler): timeout=10, ) return result + + # ================= RAG Capability Callers (LangBot -> Runtime) ================= + + async def rag_ingest_document( + self, plugin_author: str, plugin_name: str, context_data: dict[str, Any] + ) -> dict[str, Any]: + """Send INGEST_DOCUMENT action to runtime.""" + result = await self.call_action( + LangBotToRuntimeAction.RAG_INGEST_DOCUMENT, + {'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data}, + timeout=300, # Ingestion can be slow + ) + return result + + async def rag_delete_document(self, plugin_author: str, plugin_name: str, document_id: str, kb_id: str) -> bool: + result = await self.call_action( + LangBotToRuntimeAction.RAG_DELETE_DOCUMENT, + {'plugin_author': plugin_author, 'plugin_name': plugin_name, 'document_id': document_id, 'kb_id': kb_id}, + timeout=30, + ) + return result.get('success', False) + + async def rag_on_kb_create( + self, plugin_author: str, plugin_name: str, kb_id: str, config: dict[str, Any] + ) -> dict[str, Any]: + """Notify plugin about KB creation.""" + result = await self.call_action( + LangBotToRuntimeAction.RAG_ON_KB_CREATE, + {'plugin_author': plugin_author, 'plugin_name': plugin_name, 'kb_id': kb_id, 'config': config}, + timeout=30, + ) + return result + + async def rag_on_kb_delete(self, plugin_author: str, plugin_name: str, kb_id: str) -> dict[str, Any]: + """Notify plugin about KB deletion.""" + result = await self.call_action( + LangBotToRuntimeAction.RAG_ON_KB_DELETE, + {'plugin_author': plugin_author, 'plugin_name': plugin_name, 'kb_id': kb_id}, + timeout=30, + ) + return result + + async def get_rag_creation_schema(self, plugin_author: str, plugin_name: str) -> dict[str, Any]: + return await self.call_action( + LangBotToRuntimeAction.GET_RAG_CREATION_SETTINGS_SCHEMA, + {'plugin_author': plugin_author, 'plugin_name': plugin_name}, + timeout=10, + ) + + async def get_rag_retrieval_schema(self, plugin_author: str, plugin_name: str) -> dict[str, Any]: + return await self.call_action( + LangBotToRuntimeAction.GET_RAG_RETRIEVAL_SETTINGS_SCHEMA, + {'plugin_author': plugin_author, 'plugin_name': plugin_name}, + timeout=10, + ) + + async def list_knowledge_engines(self) -> list[dict[str, Any]]: + """List all available Knowledge Engines from plugins.""" + result = await self.call_action(LangBotToRuntimeAction.LIST_KNOWLEDGE_ENGINES, {}, timeout=60) + return result.get('engines', []) + + # ================= Parser Capability Callers (LangBot -> Runtime) ================= + + async def list_parsers(self) -> list[dict[str, Any]]: + """List all available parsers from plugins.""" + result = await self.call_action(LangBotToRuntimeAction.LIST_PARSERS, {}, timeout=60) + return result.get('parsers', []) + + async def parse_document( + self, plugin_author: str, plugin_name: str, context_data: dict[str, Any], file_bytes: bytes + ) -> dict[str, Any]: + """Send PARSE_DOCUMENT action to runtime. + + Sends file content via chunked FILE_CHUNK transfer, then invokes + the PARSE_DOCUMENT action with a file_key reference. + """ + # Send file to runtime via chunked transfer + file_key = await self.send_file(file_bytes, '') + + # Include file_key in context_data for the runtime to read + context_data['file_key'] = file_key + + result = await self.call_action( + LangBotToRuntimeAction.PARSE_DOCUMENT, + {'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data}, + timeout=300, + ) + return result diff --git a/src/langbot/pkg/provider/runners/localagent.py b/src/langbot/pkg/provider/runners/localagent.py index dbda6622..f444529b 100644 --- a/src/langbot/pkg/provider/runners/localagent.py +++ b/src/langbot/pkg/provider/runners/localagent.py @@ -74,15 +74,7 @@ class LocalAgentRunner(runner.RequestRunner): self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping') continue - # Get top_k based on KB type - if kb.get_type() == 'internal': - top_k = kb.knowledge_base_entity.top_k - elif kb.get_type() == 'external': - top_k = 5 # external kb's top_k is managed by plugin config - else: - top_k = 5 # default fallback - - result = await kb.retrieve(user_message_text, top_k) + result = await kb.retrieve(user_message_text) if result: all_results.extend(result) @@ -97,9 +89,9 @@ class LocalAgentRunner(runner.RequestRunner): if content.type == 'text' and content.text is not None: texts.append(f'[{idx}] {content.text}') idx += 1 - rag_context = '\n\n'.join(texts) + rag_context_text = '\n\n'.join(texts) final_user_message_text = rag_combined_prompt_template.format( - rag_context=rag_context, user_message=user_message_text + rag_context=rag_context_text, user_message=user_message_text ) else: diff --git a/src/langbot/pkg/rag/knowledge/base.py b/src/langbot/pkg/rag/knowledge/base.py index 4b183eae..28d010fe 100644 --- a/src/langbot/pkg/rag/knowledge/base.py +++ b/src/langbot/pkg/rag/knowledge/base.py @@ -22,12 +22,12 @@ class KnowledgeBaseInterface(metaclass=abc.ABCMeta): pass @abc.abstractmethod - async def retrieve(self, query: str, top_k: int) -> list[rag_context.RetrievalResultEntry]: + async def retrieve(self, query: str, settings: dict | None = None) -> list[rag_context.RetrievalResultEntry]: """Retrieve relevant documents from the knowledge base Args: query: The query string - top_k: Number of top results to return + settings: Optional per-request retrieval settings overrides Returns: List of retrieve result entries @@ -45,8 +45,8 @@ class KnowledgeBaseInterface(metaclass=abc.ABCMeta): pass @abc.abstractmethod - def get_type(self) -> str: - """Get the type of knowledge base (internal/external)""" + def get_knowledge_engine_plugin_id(self) -> str: + """Get the Knowledge Engine plugin ID""" pass @abc.abstractmethod diff --git a/src/langbot/pkg/rag/knowledge/external.py b/src/langbot/pkg/rag/knowledge/external.py deleted file mode 100644 index f1a5fed3..00000000 --- a/src/langbot/pkg/rag/knowledge/external.py +++ /dev/null @@ -1,85 +0,0 @@ -"""External knowledge base implementation""" - -from __future__ import annotations - -from langbot.pkg.core import app -from langbot.pkg.entity.persistence import rag as persistence_rag -from langbot_plugin.api.entities.builtin.rag import context as rag_context -from .base import KnowledgeBaseInterface - - -class ExternalKnowledgeBase(KnowledgeBaseInterface): - """External knowledge base that queries via HTTP API or plugin retriever""" - - external_kb_entity: persistence_rag.ExternalKnowledgeBase - - # Plugin retriever instance ID - retriever_instance_id: str | None - - def __init__(self, ap: app.Application, external_kb_entity: persistence_rag.ExternalKnowledgeBase): - super().__init__(ap) - self.external_kb_entity = external_kb_entity - self.retriever_instance_id = None - - async def initialize(self): - """Initialize the external knowledge base""" - # Use KB UUID as instance ID - # Instance creation is now handled by the unified sync mechanism - # when LangBot connects to runtime - self.retriever_instance_id = self.external_kb_entity.uuid - - self.ap.logger.info( - f'Initialized external KB {self.external_kb_entity.uuid}, instance will be created by sync mechanism' - ) - - async def retrieve(self, query: str, top_k: int = 5) -> list[rag_context.RetrievalResultEntry]: - """Retrieve documents from external knowledge base via plugin retriever""" - if not self.retriever_instance_id: - self.ap.logger.error(f'No retriever instance for KB {self.external_kb_entity.uuid}') - return [] - - try: - results = await self.ap.plugin_connector.retrieve_knowledge( - self.external_kb_entity.plugin_author, - self.external_kb_entity.plugin_name, - self.external_kb_entity.retriever_name, - self.retriever_instance_id, - {'query': query}, - ) - - # Convert plugin results to RetrievalResultEntry - retrieval_entries = [] - for result in results: - retrieval_entries.append(rag_context.RetrievalResultEntry(**result)) - - return retrieval_entries - except Exception as e: - self.ap.logger.error(f'Plugin retriever error: {e}') - import traceback - - traceback.print_exc() - return [] - - def get_uuid(self) -> str: - """Get the UUID of the external knowledge base""" - return self.external_kb_entity.uuid - - def get_name(self) -> str: - """Get the name of the external knowledge base""" - return self.external_kb_entity.name - - def get_type(self) -> str: - """Get the type of knowledge base""" - return 'external' - - async def dispose(self): - """Clean up resources""" - # Trigger sync to immediately delete the instance from plugin process - # This ensures instance is cleaned up without waiting for next LangBot restart - try: - await self.ap.plugin_connector.sync_polymorphic_component_instances() - self.ap.logger.info( - f'Disposed external KB {self.external_kb_entity.uuid}, triggered sync to delete instance' - ) - except Exception as e: - self.ap.logger.error(f'Failed to sync after disposing KB: {e}') diff --git a/src/langbot/pkg/rag/knowledge/kbmgr.py b/src/langbot/pkg/rag/knowledge/kbmgr.py index 5fd44854..5831da30 100644 --- a/src/langbot/pkg/rag/knowledge/kbmgr.py +++ b/src/langbot/pkg/rag/knowledge/kbmgr.py @@ -1,18 +1,19 @@ from __future__ import annotations +import mimetypes +import os.path import traceback import uuid import zipfile import io -from .services import parser, chunker +from typing import Any from langbot.pkg.core import app -from langbot.pkg.rag.knowledge.services.embedder import Embedder -from langbot.pkg.rag.knowledge.services.retriever import Retriever import sqlalchemy + + from langbot.pkg.entity.persistence import rag as persistence_rag from langbot.pkg.core import taskmgr from langbot_plugin.api.entities.builtin.rag import context as rag_context from .base import KnowledgeBaseInterface -from .external import ExternalKnowledgeBase class RuntimeKnowledgeBase(KnowledgeBaseInterface): @@ -20,28 +21,16 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface): knowledge_base_entity: persistence_rag.KnowledgeBase - parser: parser.FileParser - - chunker: chunker.Chunker - - embedder: Embedder - - retriever: Retriever - def __init__(self, ap: app.Application, knowledge_base_entity: persistence_rag.KnowledgeBase): super().__init__(ap) self.knowledge_base_entity = knowledge_base_entity - self.parser = parser.FileParser(ap=self.ap) - self.chunker = chunker.Chunker(ap=self.ap) - self.embedder = Embedder(ap=self.ap) - self.retriever = Retriever(ap=self.ap) - # 传递kb_id给retriever - self.retriever.kb_id = knowledge_base_entity.uuid async def initialize(self): pass - async def _store_file_task(self, file: persistence_rag.File, task_context: taskmgr.TaskContext): + async def _store_file_task( + self, file: persistence_rag.File, task_context: taskmgr.TaskContext, parser_plugin_id: str | None = None + ): try: # set file status to processing await self.ap.persistence_mgr.execute_async( @@ -50,31 +39,46 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface): .values(status='processing') ) - task_context.set_current_action('Parsing file') - # parse file - text = await self.parser.parse(file.file_name, file.extension) - if not text: - raise Exception(f'No text extracted from file {file.file_name}') + task_context.set_current_action('Processing file') - task_context.set_current_action('Chunking file') - # chunk file - chunks_texts = await self.chunker.chunk(text) - if not chunks_texts: - raise Exception(f'No chunks extracted from file {file.file_name}') + # Get file size from storage + file_size = await self.ap.storage_mgr.storage_provider.size(file.file_name) - task_context.set_current_action('Embedding chunks') + # Detect MIME type from extension + mime_type, _ = mimetypes.guess_type(file.file_name) + if mime_type is None: + mime_type = 'application/octet-stream' - embedding_model = await self.ap.model_mgr.get_embedding_model_by_uuid( - self.knowledge_base_entity.embedding_model_uuid - ) - # embed chunks - await self.embedder.embed_and_store( - kb_id=self.knowledge_base_entity.uuid, - file_id=file.uuid, - chunks=chunks_texts, - embedding_model=embedding_model, + # If a parser plugin is specified, call it before ingestion + parsed_content = None + if parser_plugin_id: + task_context.set_current_action('Parsing file') + file_bytes = await self.ap.storage_mgr.storage_provider.load(file.file_name) + parse_context = { + 'mime_type': mime_type, + 'filename': file.file_name, + 'metadata': {}, + } + parsed_content = await self.ap.plugin_connector.call_parser(parser_plugin_id, parse_context, file_bytes) + + # Call plugin to ingest document + result = await self._ingest_document( + { + 'document_id': file.uuid, + 'filename': file.file_name, + 'extension': file.extension, + 'file_size': file_size, + 'mime_type': mime_type, + }, + file.file_name, # storage path + parsed_content=parsed_content, ) + # Check plugin result status + if result.get('status') == 'failed': + error_msg = result.get('error_message', 'Plugin ingestion returned failed status') + raise Exception(error_msg) + # set file status to completed await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_rag.File) @@ -97,16 +101,17 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface): # delete file from storage await self.ap.storage_mgr.storage_provider.delete(file.file_name) - async def store_file(self, file_id: str) -> str: + async def store_file(self, file_id: str, parser_plugin_id: str | None = None) -> str: # pre checking if not await self.ap.storage_mgr.storage_provider.exists(file_id): raise Exception(f'File {file_id} not found') file_name = file_id - extension = file_name.split('.')[-1].lower() + _, ext = os.path.splitext(file_name) + extension = ext.lstrip('.').lower() if ext else '' if extension == 'zip': - return await self._store_zip_file(file_id) + return await self._store_zip_file(file_id, parser_plugin_id=parser_plugin_id) file_uuid = str(uuid.uuid4()) kb_id = self.knowledge_base_entity.uuid @@ -126,7 +131,7 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface): # run background task asynchronously ctx = taskmgr.TaskContext.new() wrapper = self.ap.task_mgr.create_user_task( - self._store_file_task(file_obj, task_context=ctx), + self._store_file_task(file_obj, task_context=ctx, parser_plugin_id=parser_plugin_id), kind='knowledge-operation', name=f'knowledge-store-file-{file_id}', label=f'Store file {file_id}', @@ -134,7 +139,7 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface): ) return wrapper.id - async def _store_zip_file(self, zip_file_id: str) -> str: + async def _store_zip_file(self, zip_file_id: str, parser_plugin_id: str | None = None) -> str: """Handle ZIP file by extracting each document and storing them separately.""" self.ap.logger.info(f'Processing ZIP file: {zip_file_id}') @@ -150,7 +155,8 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface): if file_info.is_dir() or file_info.filename.startswith('.'): continue - file_extension = file_info.filename.split('.')[-1].lower() + _, file_ext = os.path.splitext(file_info.filename) + file_extension = file_ext.lstrip('.').lower() if file_extension not in supported_extensions: self.ap.logger.debug(f'Skipping unsupported file in ZIP: {file_info.filename}') continue @@ -159,18 +165,18 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface): file_content = zip_ref.read(file_info.filename) base_name = file_info.filename.replace('/', '_').replace('\\', '_') - extension = base_name.split('.')[-1] - file_name = base_name.split('.')[0] + file_stem, file_ext = os.path.splitext(base_name) + extension = file_ext.lstrip('.') - if file_name.startswith('__MACOSX'): + if file_stem.startswith('__MACOSX'): continue - extracted_file_id = file_name + '_' + str(uuid.uuid4())[:8] + '.' + extension + extracted_file_id = file_stem + '_' + str(uuid.uuid4())[:8] + '.' + extension # save file to storage await self.ap.storage_mgr.storage_provider.save(extracted_file_id, file_content) - task_id = await self.store_file(extracted_file_id) + task_id = await self.store_file(extracted_file_id, parser_plugin_id=parser_plugin_id) stored_file_tasks.append(task_id) self.ap.logger.info( @@ -189,21 +195,28 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface): return stored_file_tasks[0] if stored_file_tasks else '' - async def retrieve(self, query: str, top_k: int) -> list[rag_context.RetrievalResultEntry]: - embedding_model = await self.ap.model_mgr.get_embedding_model_by_uuid( - self.knowledge_base_entity.embedding_model_uuid - ) - return await self.retriever.retrieve(self.knowledge_base_entity.uuid, query, embedding_model, top_k) + async def retrieve(self, query: str, settings: dict | None = None) -> list[rag_context.RetrievalResultEntry]: + # Merge stored retrieval_settings with per-request overrides + stored = self.knowledge_base_entity.retrieval_settings or {} + merged = {**stored, **(settings or {})} + if 'top_k' not in merged: + merged['top_k'] = 5 # fallback default + + response = await self._retrieve(query, merged) + + results_data = response.get('results', []) + entries = [] + for r in results_data: + if isinstance(r, dict): + entries.append(rag_context.RetrievalResultEntry(**r)) + elif isinstance(r, rag_context.RetrievalResultEntry): + entries.append(r) + return entries async def delete_file(self, file_id: str): - # delete vector - await self.ap.vector_db_mgr.vector_db.delete_by_file_id(self.knowledge_base_entity.uuid, file_id) - - # delete chunk - await self.ap.persistence_mgr.execute_async( - sqlalchemy.delete(persistence_rag.Chunk).where(persistence_rag.Chunk.file_id == file_id) - ) + await self._delete_document(file_id) + # Also cleanup DB record await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_rag.File).where(persistence_rag.File.uuid == file_id) ) @@ -216,32 +229,289 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface): """Get the name of the knowledge base""" return self.knowledge_base_entity.name - def get_type(self) -> str: - """Get the type of knowledge base""" - return 'internal' + def get_knowledge_engine_plugin_id(self) -> str: + """Get the Knowledge Engine plugin ID""" + return self.knowledge_base_entity.knowledge_engine_plugin_id or '' async def dispose(self): - await self.ap.vector_db_mgr.vector_db.delete_collection(self.knowledge_base_entity.uuid) + """Dispose the knowledge base, notifying the plugin to cleanup.""" + await self._on_kb_delete() + + # ========== Plugin Communication Methods ========== + + async def _on_kb_create(self) -> None: + """Notify plugin about KB creation.""" + plugin_id = self.knowledge_base_entity.knowledge_engine_plugin_id + if not plugin_id: + return + + try: + config = self.knowledge_base_entity.creation_settings or {} + self.ap.logger.info( + f'Calling RAG plugin {plugin_id}: on_knowledge_base_create(kb_id={self.knowledge_base_entity.uuid})' + ) + await self.ap.plugin_connector.rag_on_kb_create(plugin_id, self.knowledge_base_entity.uuid, config) + except Exception as e: + self.ap.logger.error(f'Failed to notify plugin {plugin_id} on KB create: {e}') + raise + + async def _on_kb_delete(self) -> None: + """Notify plugin about KB deletion.""" + plugin_id = self.knowledge_base_entity.knowledge_engine_plugin_id + if not plugin_id: + return + + try: + self.ap.logger.info( + f'Calling RAG plugin {plugin_id}: on_knowledge_base_delete(kb_id={self.knowledge_base_entity.uuid})' + ) + await self.ap.plugin_connector.rag_on_kb_delete(plugin_id, self.knowledge_base_entity.uuid) + except Exception as e: + self.ap.logger.error(f'Failed to notify plugin {plugin_id} on KB delete: {e}') + + async def _ingest_document( + self, + file_metadata: dict[str, Any], + storage_path: str, + parsed_content: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Call plugin to ingest document.""" + kb = self.knowledge_base_entity + plugin_id = kb.knowledge_engine_plugin_id + if not plugin_id: + self.ap.logger.error(f'No RAG plugin ID configured for KB {kb.uuid}. Ingestion failed.') + raise ValueError('RAG Plugin ID required') + + self.ap.logger.info(f'Calling RAG plugin {plugin_id}: ingest(doc={file_metadata.get("filename")})') + + # Inject knowledge_base_id into file metadata as required by SDK schema + file_metadata['knowledge_base_id'] = kb.uuid + + context_data = { + 'file_object': { + 'metadata': file_metadata, + 'storage_path': storage_path, + }, + 'knowledge_base_id': kb.uuid, + 'collection_id': kb.collection_id or kb.uuid, + 'creation_settings': kb.creation_settings or {}, + 'parsed_content': parsed_content, + } + + try: + result = await self.ap.plugin_connector.call_rag_ingest(plugin_id, context_data) + return result + except Exception as e: + self.ap.logger.error(f'Plugin ingestion failed: {e}') + raise + + async def _retrieve( + self, + query: str, + settings: dict[str, Any], + ) -> dict[str, Any]: + """Call plugin to retrieve documents. + + Raises: + ValueError: If no RAG plugin is configured for this KB. + Exception: If the plugin retrieval call fails. + """ + kb = self.knowledge_base_entity + plugin_id = kb.knowledge_engine_plugin_id + if not plugin_id: + raise ValueError(f'No RAG plugin ID configured for KB {kb.uuid}. Retrieval failed.') + + retrieval_context = { + 'query': query, + 'knowledge_base_id': kb.uuid, + 'collection_id': kb.collection_id or kb.uuid, + 'retrieval_settings': settings, + 'creation_settings': kb.creation_settings or {}, + 'filters': settings.pop('filters', {}), + } + + result = await self.ap.plugin_connector.call_rag_retrieve( + plugin_id, + retrieval_context, + ) + return result + + async def _delete_document(self, document_id: str) -> bool: + """Call plugin to delete document.""" + kb = self.knowledge_base_entity + plugin_id = kb.knowledge_engine_plugin_id + if not plugin_id: + return False + + self.ap.logger.info(f'Calling RAG plugin {plugin_id}: delete_document(doc_id={document_id})') + + try: + return await self.ap.plugin_connector.call_rag_delete_document(plugin_id, document_id, kb.uuid) + except Exception as e: + self.ap.logger.error(f'Plugin document deletion failed: {e}') + return False class RAGManager: ap: app.Application - knowledge_bases: list[KnowledgeBaseInterface] + knowledge_bases: dict[str, KnowledgeBaseInterface] def __init__(self, ap: app.Application): self.ap = ap - self.knowledge_bases = [] + self.knowledge_bases = {} async def initialize(self): await self.load_knowledge_bases_from_db() + async def get_all_knowledge_base_details(self) -> list[dict]: + """Get all knowledge bases with enriched Knowledge Engine details.""" + # 1. Get raw KBs from DB + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_rag.KnowledgeBase)) + knowledge_bases = result.all() + + # 2. Get all available Knowledge Engines for enrichment + engine_map = {} + if self.ap.plugin_connector.is_enable_plugin: + try: + engines = await self.ap.plugin_connector.list_knowledge_engines() + engine_map = {e['plugin_id']: e for e in engines} + except Exception as e: + self.ap.logger.warning(f'Failed to list Knowledge Engines: {e}') + + # 3. Serialize and enrich + kb_list = [] + for kb in knowledge_bases: + kb_dict = self.ap.persistence_mgr.serialize_model(persistence_rag.KnowledgeBase, kb) + self._enrich_kb_dict(kb_dict, engine_map) + kb_list.append(kb_dict) + + return kb_list + + async def get_knowledge_base_details(self, kb_uuid: str) -> dict | None: + """Get specific knowledge base with enriched Knowledge Engine details.""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid) + ) + kb = result.first() + if not kb: + return None + + kb_dict = self.ap.persistence_mgr.serialize_model(persistence_rag.KnowledgeBase, kb) + + # Fetch engines + engine_map = {} + if self.ap.plugin_connector.is_enable_plugin: + try: + engines = await self.ap.plugin_connector.list_knowledge_engines() + engine_map = {e['plugin_id']: e for e in engines} + except Exception as e: + self.ap.logger.warning(f'Failed to list Knowledge Engines: {e}') + + self._enrich_kb_dict(kb_dict, engine_map) + return kb_dict + + @staticmethod + def _to_i18n_name(name) -> dict: + """Ensure name is always an I18nObject-compatible dict. + + If *name* is already a dict (with ``en_US`` / ``zh_Hans`` keys) it is + returned as-is. A plain string is wrapped into an I18nObject so the + frontend ``extractI18nObject`` helper never receives an unexpected type. + """ + if isinstance(name, dict): + return name + return {'en_US': str(name), 'zh_Hans': str(name)} + + def _enrich_kb_dict(self, kb_dict: dict, engine_map: dict) -> None: + """Helper to inject engine info into KB dict.""" + plugin_id = kb_dict.get('knowledge_engine_plugin_id') + + # Default fallback structure — name must be I18nObject for frontend compatibility + fallback_name = self._to_i18n_name(plugin_id or 'Internal (Legacy)') + fallback_info = { + 'plugin_id': plugin_id, + 'name': fallback_name, + 'capabilities': [], + } + + if not plugin_id: + kb_dict['knowledge_engine'] = fallback_info + return + + engine_info = engine_map.get(plugin_id) + if engine_info: + kb_dict['knowledge_engine'] = { + 'plugin_id': plugin_id, + 'name': self._to_i18n_name(engine_info.get('name', plugin_id)), + 'capabilities': engine_info.get('capabilities', []), + } + else: + kb_dict['knowledge_engine'] = fallback_info + + async def create_knowledge_base( + self, + name: str, + knowledge_engine_plugin_id: str, + creation_settings: dict, + retrieval_settings: dict | None = None, + description: str = '', + ) -> persistence_rag.KnowledgeBase: + """Create a new knowledge base using a RAG plugin.""" + # Validate that the Knowledge Engine plugin exists + if self.ap.plugin_connector.is_enable_plugin: + try: + engines = await self.ap.plugin_connector.list_knowledge_engines() + engine_ids = [e.get('plugin_id') for e in engines] + if knowledge_engine_plugin_id not in engine_ids: + raise ValueError(f'Knowledge Engine plugin {knowledge_engine_plugin_id} not found') + except ValueError: + raise + except Exception as e: + self.ap.logger.warning(f'Failed to validate Knowledge Engine plugin existence: {e}') + + kb_uuid = str(uuid.uuid4()) + # Use UUID as collection ID by default for isolation + collection_id = kb_uuid + + kb_data = { + 'uuid': kb_uuid, + 'name': name, + 'description': description, + 'knowledge_engine_plugin_id': knowledge_engine_plugin_id, + 'collection_id': collection_id, + 'creation_settings': creation_settings, + 'retrieval_settings': retrieval_settings or {}, + } + + # Create Entity + kb = persistence_rag.KnowledgeBase(**kb_data) + + # Persist + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.KnowledgeBase).values(kb_data)) + + # Load into Runtime + runtime_kb = await self.load_knowledge_base(kb) + + # Notify Plugin — rollback DB record and runtime entry on failure + try: + await runtime_kb._on_kb_create() + except Exception: + self.knowledge_bases.pop(kb_uuid, None) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid) + ) + raise + + self.ap.logger.info(f'Created new Knowledge Base {name} ({kb_uuid}) using plugin {knowledge_engine_plugin_id}') + return kb + async def load_knowledge_bases_from_db(self): self.ap.logger.info('Loading knowledge bases from db...') - self.knowledge_bases = [] + self.knowledge_bases = {} - # Load internal knowledge bases + # Load knowledge bases result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_rag.KnowledgeBase)) knowledge_bases = result.all() @@ -253,86 +523,37 @@ class RAGManager: f'Error loading knowledge base {knowledge_base.uuid}: {e}\n{traceback.format_exc()}' ) - # Load external knowledge bases - external_result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_rag.ExternalKnowledgeBase) - ) - external_kbs = external_result.all() - - for external_kb in external_kbs: - try: - # Don't trigger sync during batch loading - will sync once after LangBot connects to runtime - await self.load_external_knowledge_base(external_kb, trigger_sync=False) - except Exception as e: - self.ap.logger.error( - f'Error loading external knowledge base {external_kb.uuid}: {e}\n{traceback.format_exc()}' - ) - async def load_knowledge_base( self, knowledge_base_entity: persistence_rag.KnowledgeBase | sqlalchemy.Row | dict, ) -> RuntimeKnowledgeBase: if isinstance(knowledge_base_entity, sqlalchemy.Row): + # Safe access to _mapping for SQLAlchemy 1.4+ knowledge_base_entity = persistence_rag.KnowledgeBase(**knowledge_base_entity._mapping) elif isinstance(knowledge_base_entity, dict): - knowledge_base_entity = persistence_rag.KnowledgeBase(**knowledge_base_entity) + # Filter out non-database fields (like knowledge_engine which is computed) + filtered_dict = { + k: v for k, v in knowledge_base_entity.items() if k in persistence_rag.KnowledgeBase.ALL_DB_FIELDS + } + knowledge_base_entity = persistence_rag.KnowledgeBase(**filtered_dict) runtime_knowledge_base = RuntimeKnowledgeBase(ap=self.ap, knowledge_base_entity=knowledge_base_entity) await runtime_knowledge_base.initialize() - self.knowledge_bases.append(runtime_knowledge_base) + self.knowledge_bases[runtime_knowledge_base.get_uuid()] = runtime_knowledge_base return runtime_knowledge_base - async def load_external_knowledge_base( - self, - external_kb_entity: persistence_rag.ExternalKnowledgeBase | sqlalchemy.Row | dict, - trigger_sync: bool = True, - ) -> ExternalKnowledgeBase: - """Load external knowledge base into runtime - - Args: - external_kb_entity: External KB entity to load - trigger_sync: Whether to trigger sync after loading (default True for manual creation, False for batch loading) - """ - if isinstance(external_kb_entity, sqlalchemy.Row): - external_kb_entity = persistence_rag.ExternalKnowledgeBase(**external_kb_entity._mapping) - elif isinstance(external_kb_entity, dict): - external_kb_entity = persistence_rag.ExternalKnowledgeBase(**external_kb_entity) - - external_kb = ExternalKnowledgeBase(ap=self.ap, external_kb_entity=external_kb_entity) - - await external_kb.initialize() - - self.knowledge_bases.append(external_kb) - - # Trigger sync to create the instance immediately (for manual creation) - # Skip sync during batch loading from DB to avoid multiple sync calls - if trigger_sync: - try: - await self.ap.plugin_connector.sync_polymorphic_component_instances() - self.ap.logger.info(f'Triggered sync after loading external KB {external_kb_entity.uuid}') - except Exception as e: - self.ap.logger.error(f'Failed to sync after loading external KB: {e}') - - return external_kb - async def get_knowledge_base_by_uuid(self, kb_uuid: str) -> KnowledgeBaseInterface | None: - for kb in self.knowledge_bases: - if kb.get_uuid() == kb_uuid: - return kb - return None + return self.knowledge_bases.get(kb_uuid) async def remove_knowledge_base_from_runtime(self, kb_uuid: str): - for kb in self.knowledge_bases: - if kb.get_uuid() == kb_uuid: - self.knowledge_bases.remove(kb) - return + self.knowledge_bases.pop(kb_uuid, None) async def delete_knowledge_base(self, kb_uuid: str): - for kb in self.knowledge_bases: - if kb.get_uuid() == kb_uuid: - await kb.dispose() - self.knowledge_bases.remove(kb) - return + kb = self.knowledge_bases.pop(kb_uuid, None) + if kb is not None: + await kb.dispose() + else: + self.ap.logger.warning(f'Knowledge base {kb_uuid} not found in runtime, skipping plugin notification') diff --git a/src/langbot/pkg/rag/knowledge/services/__init__.py b/src/langbot/pkg/rag/knowledge/services/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/langbot/pkg/rag/knowledge/services/base_service.py b/src/langbot/pkg/rag/knowledge/services/base_service.py deleted file mode 100644 index 0f71a508..00000000 --- a/src/langbot/pkg/rag/knowledge/services/base_service.py +++ /dev/null @@ -1,15 +0,0 @@ -# 封装异步操作 -import asyncio - - -class BaseService: - def __init__(self): - pass - - async def _run_sync(self, func, *args, **kwargs): - """ - 在单独的线程中运行同步函数。 - 如果第一个参数是 session,则在 to_thread 中获取新的 session。 - """ - - return await asyncio.to_thread(func, *args, **kwargs) diff --git a/src/langbot/pkg/rag/knowledge/services/chunker.py b/src/langbot/pkg/rag/knowledge/services/chunker.py deleted file mode 100644 index 0cb16816..00000000 --- a/src/langbot/pkg/rag/knowledge/services/chunker.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -import json -from typing import List -from langbot.pkg.rag.knowledge.services import base_service -from langbot.pkg.core import app -from langchain_text_splitters import RecursiveCharacterTextSplitter - - -class Chunker(base_service.BaseService): - """ - A class for splitting long texts into smaller, overlapping chunks. - """ - - def __init__(self, ap: app.Application, chunk_size: int = 500, chunk_overlap: int = 50): - self.ap = ap - self.chunk_size = chunk_size - self.chunk_overlap = chunk_overlap - if self.chunk_overlap >= self.chunk_size: - self.ap.logger.warning( - 'Chunk overlap is greater than or equal to chunk size. This may lead to empty or malformed chunks.' - ) - - def _split_text_sync(self, text: str) -> List[str]: - """ - Synchronously splits a long text into chunks with specified overlap. - This is a CPU-bound operation, intended to be run in a separate thread. - """ - if not text: - return [] - - text_splitter = RecursiveCharacterTextSplitter( - chunk_size=self.chunk_size, - chunk_overlap=self.chunk_overlap, - length_function=len, - is_separator_regex=False, - ) - return text_splitter.split_text(text) - - async def chunk(self, text: str) -> List[str]: - """ - Asynchronously chunks a given text into smaller pieces. - """ - self.ap.logger.info(f'Chunking text (length: {len(text)})...') - # Run the synchronous splitting logic in a separate thread - chunks = await self._run_sync(self._split_text_sync, text) - self.ap.logger.info(f'Text chunked into {len(chunks)} pieces.') - self.ap.logger.debug(f'Chunks: {json.dumps(chunks, indent=4, ensure_ascii=False)}') - return chunks diff --git a/src/langbot/pkg/rag/knowledge/services/embedder.py b/src/langbot/pkg/rag/knowledge/services/embedder.py deleted file mode 100644 index 168b839d..00000000 --- a/src/langbot/pkg/rag/knowledge/services/embedder.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations -import uuid -from typing import List -from langbot.pkg.rag.knowledge.services.base_service import BaseService -from langbot.pkg.entity.persistence import rag as persistence_rag -from langbot.pkg.core import app -from langbot.pkg.provider.modelmgr.requester import RuntimeEmbeddingModel -import sqlalchemy - - -class Embedder(BaseService): - def __init__(self, ap: app.Application) -> None: - super().__init__() - self.ap = ap - - async def embed_and_store( - self, kb_id: str, file_id: str, chunks: List[str], embedding_model: RuntimeEmbeddingModel - ) -> list[persistence_rag.Chunk]: - # save chunk to db - chunk_entities: list[persistence_rag.Chunk] = [] - chunk_ids: list[str] = [] - - for chunk_text in chunks: - chunk_uuid = str(uuid.uuid4()) - chunk_ids.append(chunk_uuid) - chunk_entity = persistence_rag.Chunk(uuid=chunk_uuid, file_id=file_id, text=chunk_text) - chunk_entities.append(chunk_entity) - - chunk_dicts = [ - self.ap.persistence_mgr.serialize_model(persistence_rag.Chunk, chunk) for chunk in chunk_entities - ] - - await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.Chunk).values(chunk_dicts)) - - # get embeddings (batch size limit: 64 for OpenAI) - MAX_BATCH_SIZE = 64 - embeddings_list: list[list[float]] = [] - - for i in range(0, len(chunks), MAX_BATCH_SIZE): - batch = chunks[i : i + MAX_BATCH_SIZE] - batch_embeddings = await embedding_model.provider.invoke_embedding( - model=embedding_model, - input_text=batch, - extra_args={}, # TODO: add extra args - knowledge_base_id=kb_id, - call_type='embedding', - ) - embeddings_list.extend(batch_embeddings) - - # save embeddings to vdb - await self.ap.vector_db_mgr.vector_db.add_embeddings(kb_id, chunk_ids, embeddings_list, chunk_dicts) - - self.ap.logger.info(f'Successfully saved {len(chunk_entities)} embeddings to Knowledge Base.') - - return chunk_entities diff --git a/src/langbot/pkg/rag/knowledge/services/parser.py b/src/langbot/pkg/rag/knowledge/services/parser.py deleted file mode 100644 index 50410738..00000000 --- a/src/langbot/pkg/rag/knowledge/services/parser.py +++ /dev/null @@ -1,291 +0,0 @@ -from __future__ import annotations - -import PyPDF2 -import io -from docx import Document -import chardet -from typing import Union, Callable, Any -import markdown -from bs4 import BeautifulSoup -import re -import asyncio # Import asyncio for async operations -from langbot.pkg.core import app - - -class FileParser: - """ - A robust file parser class to extract text content from various document formats. - It supports TXT, PDF, DOCX, XLSX, CSV, Markdown, HTML, and EPUB files. - All core file reading operations are designed to be run synchronously in a thread pool - to avoid blocking the asyncio event loop. - """ - - def __init__(self, ap: app.Application): - self.ap = ap - - async def _run_sync(self, sync_func: Callable, *args: Any, **kwargs: Any) -> Any: - """ - Runs a synchronous function in a separate thread to prevent blocking the event loop. - This is a general utility method for wrapping blocking I/O operations. - """ - try: - return await asyncio.to_thread(sync_func, *args, **kwargs) - except Exception as e: - self.ap.logger.error(f'Error running synchronous function {sync_func.__name__}: {e}') - raise - - async def parse(self, file_name: str, extension: str) -> Union[str, None]: - """ - Parses the file based on its extension and returns the extracted text content. - This is the main asynchronous entry point for parsing. - - Args: - file_name (str): The name of the file to be parsed, get from ap.storage_mgr - - Returns: - Union[str, None]: The extracted text content as a single string, or None if parsing fails. - """ - - file_extension = extension.lower() - parser_method = getattr(self, f'_parse_{file_extension}', None) - - if parser_method is None: - self.ap.logger.error(f'Unsupported file format: {file_extension} for file {file_name}') - return None - - try: - # Pass file_path to the specific parser methods - return await parser_method(file_name) - except Exception as e: - self.ap.logger.error(f'Failed to parse {file_extension} file {file_name}: {e}') - return None - - # --- Helper for reading files with encoding detection --- - async def _read_file_content(self, file_name: str) -> Union[str, bytes]: - """ - Reads a file with automatic encoding detection, ensuring the synchronous - file read operation runs in a separate thread. - """ - - # def _read_sync(): - # with open(file_path, 'rb') as file: - # raw_data = file.read() - # detected = chardet.detect(raw_data) - # encoding = detected['encoding'] or 'utf-8' - - # if mode == 'r': - # return raw_data.decode(encoding, errors='ignore') - # return raw_data # For binary mode - - # return await self._run_sync(_read_sync) - file_bytes = await self.ap.storage_mgr.storage_provider.load(file_name) - - detected = chardet.detect(file_bytes) - encoding = detected['encoding'] or 'utf-8' - - return file_bytes.decode(encoding, errors='ignore') - - # --- Specific Parser Methods --- - - async def _parse_txt(self, file_name: str) -> str: - """Parses a TXT file and returns its content.""" - self.ap.logger.info(f'Parsing TXT file: {file_name}') - return await self._read_file_content(file_name) - - async def _parse_pdf(self, file_name: str) -> str: - """Parses a PDF file and returns its text content.""" - self.ap.logger.info(f'Parsing PDF file: {file_name}') - - # def _parse_pdf_sync(): - # text_content = [] - # with open(file_name, 'rb') as file: - # pdf_reader = PyPDF2.PdfReader(file) - # for page in pdf_reader.pages: - # text = page.extract_text() - # if text: - # text_content.append(text) - # return '\n'.join(text_content) - - # return await self._run_sync(_parse_pdf_sync) - - pdf_bytes = await self.ap.storage_mgr.storage_provider.load(file_name) - - def _parse_pdf_sync(): - pdf_reader = PyPDF2.PdfReader(io.BytesIO(pdf_bytes)) - text_content = [] - for page in pdf_reader.pages: - text = page.extract_text() - if text: - text_content.append(text) - return '\n'.join(text_content) - - return await self._run_sync(_parse_pdf_sync) - - async def _parse_docx(self, file_name: str) -> str: - """Parses a DOCX file and returns its text content.""" - self.ap.logger.info(f'Parsing DOCX file: {file_name}') - - docx_bytes = await self.ap.storage_mgr.storage_provider.load(file_name) - - def _parse_docx_sync(): - doc = Document(io.BytesIO(docx_bytes)) - text_content = [paragraph.text for paragraph in doc.paragraphs if paragraph.text.strip()] - return '\n'.join(text_content) - - return await self._run_sync(_parse_docx_sync) - - async def _parse_doc(self, file_name: str) -> str: - """Handles .doc files, explicitly stating lack of direct support.""" - self.ap.logger.warning(f'Direct .doc parsing is not supported for {file_name}. Please convert to .docx first.') - raise NotImplementedError('Direct .doc parsing not supported. Please convert to .docx first.') - - # async def _parse_xlsx(self, file_name: str) -> str: - # """Parses an XLSX file, returning text from all sheets.""" - # self.ap.logger.info(f'Parsing XLSX file: {file_name}') - - # xlsx_bytes = await self.ap.storage_mgr.storage_provider.load(file_name) - - # def _parse_xlsx_sync(): - # excel_file = pd.ExcelFile(io.BytesIO(xlsx_bytes)) - # all_sheet_content = [] - # for sheet_name in excel_file.sheet_names: - # df = pd.read_excel(io.BytesIO(xlsx_bytes), sheet_name=sheet_name) - # sheet_text = f'--- Sheet: {sheet_name} ---\n{df.to_string(index=False)}\n' - # all_sheet_content.append(sheet_text) - # return '\n'.join(all_sheet_content) - - # return await self._run_sync(_parse_xlsx_sync) - - # async def _parse_csv(self, file_name: str) -> str: - # """Parses a CSV file and returns its content as a string.""" - # self.ap.logger.info(f'Parsing CSV file: {file_name}') - - # csv_bytes = await self.ap.storage_mgr.storage_provider.load(file_name) - - # def _parse_csv_sync(): - # # pd.read_csv can often detect encoding, but explicit detection is safer - # # raw_data = self._read_file_content( - # # file_name, mode='rb' - # # ) # Note: this will need to be await outside this sync function - # # _ = raw_data - # # For simplicity, we'll let pandas handle encoding internally after a raw read. - # # A more robust solution might pass encoding directly to pd.read_csv after detection. - # detected = chardet.detect(io.BytesIO(csv_bytes)) - # encoding = detected['encoding'] or 'utf-8' - # df = pd.read_csv(io.BytesIO(csv_bytes), encoding=encoding) - # return df.to_string(index=False) - - # return await self._run_sync(_parse_csv_sync) - - async def _parse_md(self, file_name: str) -> str: - """Parses a Markdown file, converting it to structured plain text.""" - self.ap.logger.info(f'Parsing Markdown file: {file_name}') - - md_bytes = await self.ap.storage_mgr.storage_provider.load(file_name) - - def _parse_markdown_sync(): - md_content = io.BytesIO(md_bytes).read().decode('utf-8', errors='ignore') - html_content = markdown.markdown( - md_content, extensions=['extra', 'codehilite', 'tables', 'toc', 'fenced_code'] - ) - soup = BeautifulSoup(html_content, 'html.parser') - text_parts = [] - for element in soup.children: - if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']: - level = int(element.name[1]) - text_parts.append('#' * level + ' ' + element.get_text().strip()) - elif element.name == 'p': - text = element.get_text().strip() - if text: - text_parts.append(text) - elif element.name in ['ul', 'ol']: - for li in element.find_all('li'): - text_parts.append(f'* {li.get_text().strip()}') - elif element.name == 'pre': - code_block = element.get_text().strip() - if code_block: - text_parts.append(f'```\n{code_block}\n```') - elif element.name == 'table': - table_str = self._extract_table_to_markdown_sync(element) # Call sync helper - if table_str: - text_parts.append(table_str) - elif element.name: - text = element.get_text(separator=' ', strip=True) - if text: - text_parts.append(text) - cleaned_text = re.sub(r'\n\s*\n', '\n\n', '\n'.join(text_parts)) - return cleaned_text.strip() - - return await self._run_sync(_parse_markdown_sync) - - async def _parse_html(self, file_name: str) -> str: - """Parses an HTML file, extracting structured plain text.""" - self.ap.logger.info(f'Parsing HTML file: {file_name}') - - html_bytes = await self.ap.storage_mgr.storage_provider.load(file_name) - - def _parse_html_sync(): - html_content = io.BytesIO(html_bytes).read().decode('utf-8', errors='ignore') - soup = BeautifulSoup(html_content, 'html.parser') - for script_or_style in soup(['script', 'style']): - script_or_style.decompose() - text_parts = [] - for element in soup.body.children if soup.body else soup.children: - if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']: - level = int(element.name[1]) - text_parts.append('#' * level + ' ' + element.get_text().strip()) - elif element.name == 'p': - text = element.get_text().strip() - if text: - text_parts.append(text) - elif element.name in ['ul', 'ol']: - for li in element.find_all('li'): - text = li.get_text().strip() - if text: - text_parts.append(f'* {text}') - elif element.name == 'table': - table_str = self._extract_table_to_markdown_sync(element) # Call sync helper - if table_str: - text_parts.append(table_str) - elif element.name: - text = element.get_text(separator=' ', strip=True) - if text: - text_parts.append(text) - cleaned_text = re.sub(r'\n\s*\n', '\n\n', '\n'.join(text_parts)) - return cleaned_text.strip() - - return await self._run_sync(_parse_html_sync) - - def _add_toc_items_sync(self, toc_list: list, text_content: list, level: int): - """Recursively adds TOC items to text_content (synchronous helper).""" - indent = ' ' * level - for item in toc_list: - if isinstance(item, tuple): - chapter, subchapters = item - text_content.append(f'{indent}- {chapter.title}') - self._add_toc_items_sync(subchapters, text_content, level + 1) - else: - text_content.append(f'{indent}- {item.title}') - - def _extract_table_to_markdown_sync(self, table_element: BeautifulSoup) -> str: - """Helper to convert a BeautifulSoup table element into a Markdown table string (synchronous).""" - headers = [th.get_text().strip() for th in table_element.find_all('th')] - rows = [] - for tr in table_element.find_all('tr'): - cells = [td.get_text().strip() for td in tr.find_all('td')] - if cells: - rows.append(cells) - - if not headers and not rows: - return '' - - table_lines = [] - if headers: - table_lines.append(' | '.join(headers)) - table_lines.append(' | '.join(['---'] * len(headers))) - - for row_cells in rows: - padded_cells = row_cells + [''] * (len(headers) - len(row_cells)) if headers else row_cells - table_lines.append(' | '.join(padded_cells)) - - return '\n'.join(table_lines) diff --git a/src/langbot/pkg/rag/knowledge/services/retriever.py b/src/langbot/pkg/rag/knowledge/services/retriever.py deleted file mode 100644 index 15619c94..00000000 --- a/src/langbot/pkg/rag/knowledge/services/retriever.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -from . import base_service -from ....core import app -from ....provider.modelmgr.requester import RuntimeEmbeddingModel -from langbot_plugin.api.entities.builtin.rag import context as rag_context -from langbot_plugin.api.entities.builtin.provider.message import ContentElement - - -class Retriever(base_service.BaseService): - def __init__(self, ap: app.Application): - super().__init__() - self.ap = ap - - async def retrieve( - self, kb_id: str, query: str, embedding_model: RuntimeEmbeddingModel, k: int = 5 - ) -> list[rag_context.RetrievalResultEntry]: - self.ap.logger.info( - f"Retrieving for query: '{query[:10]}' with k={k} using {embedding_model.model_entity.uuid}" - ) - - query_embedding: list[float] = await embedding_model.provider.invoke_embedding( - model=embedding_model, - input_text=[query], - extra_args={}, # TODO: add extra args - knowledge_base_id=kb_id, - query_text=query, - call_type='retrieve', - ) - - vector_results = await self.ap.vector_db_mgr.vector_db.search(kb_id, query_embedding[0], k) - - # 'ids' shape mirrors the Chroma-style response contract for compatibility - matched_vector_ids = vector_results.get('ids', [[]])[0] - distances = vector_results.get('distances', [[]])[0] - vector_metadatas = vector_results.get('metadatas', [[]])[0] - - if not matched_vector_ids: - self.ap.logger.info('No relevant chunks found in vector database.') - return [] - - result: list[rag_context.RetrievalResultEntry] = [] - - for i, id in enumerate(matched_vector_ids): - entry = rag_context.RetrievalResultEntry( - id=id, - content=[ContentElement.from_text(vector_metadatas[i].get('text', ''))], - metadata=vector_metadatas[i], - distance=distances[i], - ) - result.append(entry) - - return result diff --git a/src/langbot/pkg/rag/service/__init__.py b/src/langbot/pkg/rag/service/__init__.py new file mode 100644 index 00000000..2501f49f --- /dev/null +++ b/src/langbot/pkg/rag/service/__init__.py @@ -0,0 +1 @@ +from .runtime import RAGRuntimeService as RAGRuntimeService diff --git a/src/langbot/pkg/rag/service/runtime.py b/src/langbot/pkg/rag/service/runtime.py new file mode 100644 index 00000000..d02cc374 --- /dev/null +++ b/src/langbot/pkg/rag/service/runtime.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import posixpath +from typing import Any +from langbot.pkg.core import app + + +class RAGRuntimeService: + """Service to handle RAG-related requests from plugins (Runtime). + + This service acts as the bridge between plugin RPC requests and + LangBot's infrastructure (embedding models, vector databases, file storage). + """ + + def __init__(self, ap: app.Application): + self.ap = ap + + async def vector_upsert( + self, + collection_id: str, + vectors: list[list[float]], + ids: list[str], + metadata: list[dict[str, Any]] | None = None, + documents: list[str] | None = None, + ) -> None: + """Handle VECTOR_UPSERT action.""" + metadatas = metadata if metadata else [{} for _ in vectors] + await self.ap.vector_db_mgr.upsert( + collection_name=collection_id, + vectors=vectors, + ids=ids, + metadata=metadatas, + documents=documents, + ) + + async def vector_search( + self, + collection_id: str, + query_vector: list[float], + top_k: int, + filters: dict[str, Any] | None = None, + search_type: str = 'vector', + query_text: str = '', + ) -> list[dict[str, Any]]: + """Handle VECTOR_SEARCH action.""" + return await self.ap.vector_db_mgr.search( + collection_name=collection_id, + query_vector=query_vector, + limit=top_k, + filter=filters, + search_type=search_type, + query_text=query_text, + ) + + async def vector_delete( + self, collection_id: str, file_ids: list[str] | None = None, filters: dict[str, Any] | None = None + ) -> int: + """Handle VECTOR_DELETE action. + + Deletes vectors associated with the given file IDs from the collection. + Each file_id corresponds to a document whose vectors will be removed. + + Args: + collection_id: The collection to delete from. + file_ids: File IDs whose associated vectors should be deleted. + Each file_id maps to a set of vectors stored with that file_id + in their metadata. + filters: Filter-based deletion (not yet supported, will raise). + """ + count = 0 + if file_ids: + await self.ap.vector_db_mgr.delete_by_file_id(collection_name=collection_id, file_ids=file_ids) + count = len(file_ids) + elif filters: + count = await self.ap.vector_db_mgr.delete_by_filter(collection_name=collection_id, filter=filters) + return count + + async def get_file_stream(self, storage_path: str) -> bytes: + """Handle GET_KNOWLEDEGE_FILE_STREAM action. + + Uses the storage manager abstraction to load file content, + regardless of the underlying storage provider. + """ + # Validate storage_path to prevent path traversal + normalized = posixpath.normpath(storage_path) + if normalized.startswith('/') or '..' in normalized.split('/'): + raise ValueError('Invalid storage path') + content_bytes = await self.ap.storage_mgr.storage_provider.load(normalized) + return content_bytes if content_bytes else b'' diff --git a/src/langbot/pkg/storage/provider.py b/src/langbot/pkg/storage/provider.py index 09d8d93e..e24dcbf9 100644 --- a/src/langbot/pkg/storage/provider.py +++ b/src/langbot/pkg/storage/provider.py @@ -43,6 +43,13 @@ class StorageProvider(abc.ABC): ): pass + @abc.abstractmethod + async def size( + self, + key: str, + ) -> int: + pass + @abc.abstractmethod async def delete_dir_recursive( self, diff --git a/src/langbot/pkg/storage/providers/localstorage.py b/src/langbot/pkg/storage/providers/localstorage.py index d21f5427..592c0be2 100644 --- a/src/langbot/pkg/storage/providers/localstorage.py +++ b/src/langbot/pkg/storage/providers/localstorage.py @@ -47,6 +47,12 @@ class LocalStorageProvider(provider.StorageProvider): ): os.remove(os.path.join(LOCAL_STORAGE_PATH, f'{key}')) + async def size( + self, + key: str, + ) -> int: + return os.path.getsize(os.path.join(LOCAL_STORAGE_PATH, f'{key}')) + async def delete_dir_recursive( self, dir_path: str, diff --git a/src/langbot/pkg/storage/providers/s3storage.py b/src/langbot/pkg/storage/providers/s3storage.py index ed4fc443..43cc2e96 100644 --- a/src/langbot/pkg/storage/providers/s3storage.py +++ b/src/langbot/pkg/storage/providers/s3storage.py @@ -117,6 +117,21 @@ class S3StorageProvider(provider.StorageProvider): self.ap.logger.error(f'Failed to delete from S3: {e}') raise + async def size( + self, + key: str, + ) -> int: + """Get object size from S3 without downloading it""" + try: + response = self.s3_client.head_object( + Bucket=self.bucket_name, + Key=key, + ) + return response['ContentLength'] + except Exception as e: + self.ap.logger.error(f'Failed to get size from S3: {e}') + raise + async def delete_dir_recursive( self, dir_path: str, diff --git a/src/langbot/pkg/vector/filter_utils.py b/src/langbot/pkg/vector/filter_utils.py new file mode 100644 index 00000000..6c8a187a --- /dev/null +++ b/src/langbot/pkg/vector/filter_utils.py @@ -0,0 +1,69 @@ +"""Shared utilities for metadata filter handling across VDB backends. + +Canonical filter format (Chroma-style ``where`` syntax): + + {"file_id": "abc"} # implicit $eq + {"file_id": {"$eq": "abc"}} # explicit $eq + {"created_at": {"$gte": 1700000000}} # comparison + {"file_type": {"$in": ["pdf", "docx"]}} # in-list + +Multiple top-level keys are AND-ed. Supported operators: +``$eq``, ``$ne``, ``$gt``, ``$gte``, ``$lt``, ``$lte``, ``$in``, ``$nin``. +""" + +from __future__ import annotations + +import logging +from typing import Any + +SUPPORTED_OPS = frozenset({'$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin'}) + +logger = logging.getLogger(__name__) + + +def normalize_filter( + raw: dict[str, Any] | None, +) -> list[tuple[str, str, Any]]: + """Parse a canonical filter dict into ``[(field, op, value)]`` triples. + + Returns an empty list when *raw* is ``None`` or empty. + + Raises ``ValueError`` on unsupported operators or malformed entries. + """ + if not raw: + return [] + + triples: list[tuple[str, str, Any]] = [] + for field, condition in raw.items(): + if isinstance(condition, dict): + for op, value in condition.items(): + if op not in SUPPORTED_OPS: + raise ValueError(f'Unsupported filter operator: {op}') + triples.append((field, op, value)) + else: + # Bare value -> implicit $eq + triples.append((field, '$eq', condition)) + return triples + + +def strip_unsupported_fields( + triples: list[tuple[str, str, Any]], + supported_fields: set[str], +) -> list[tuple[str, str, Any]]: + """Return only triples whose field is in *supported_fields*. + + Dropped fields are logged at WARNING level so the caller knows they were + silently ignored (useful for Milvus / pgvector which only store a fixed + schema). + """ + kept: list[tuple[str, str, Any]] = [] + for field, op, value in triples: + if field in supported_fields: + kept.append((field, op, value)) + else: + logger.warning( + 'Filter field %r is not supported by this backend and will be ignored (supported: %s)', + field, + ', '.join(sorted(supported_fields)), + ) + return kept diff --git a/src/langbot/pkg/vector/mgr.py b/src/langbot/pkg/vector/mgr.py index f0cb742c..41114bb7 100644 --- a/src/langbot/pkg/vector/mgr.py +++ b/src/langbot/pkg/vector/mgr.py @@ -1,7 +1,7 @@ from __future__ import annotations from ..core import app -from .vdb import VectorDatabase +from .vdb import VectorDatabase, SearchType from .vdbs.chroma import ChromaVectorDatabase from .vdbs.qdrant import QdrantVectorDatabase from .vdbs.seekdb import SeekDBVectorDatabase @@ -65,3 +65,95 @@ class VectorDBManager: else: self.vector_db = ChromaVectorDatabase(self.ap) self.ap.logger.warning('No vector database backend configured, defaulting to Chroma.') + + def get_supported_search_types(self) -> list[str]: + """Return the search types supported by the current VDB backend.""" + if self.vector_db is None: + return [SearchType.VECTOR.value] + return [st.value for st in self.vector_db.supported_search_types()] + + async def upsert( + self, + collection_name: str, + vectors: list[list[float]], + ids: list[str], + metadata: list[dict] | None = None, + documents: list[str] | None = None, + ): + """Proxy: Upsert vectors""" + await self.vector_db.add_embeddings( + collection=collection_name, + ids=ids, + embeddings_list=vectors, + metadatas=metadata or [{} for _ in vectors], + documents=documents, + ) + + async def search( + self, + collection_name: str, + query_vector: list[float], + limit: int, + filter: dict | None = None, + search_type: str = 'vector', + query_text: str = '', + ) -> list[dict]: + """Proxy: Search vectors. + + Returns a list of dicts with keys: 'id', 'score', 'metadata'. + The underlying VectorDatabase.search returns Chroma-style format: + { 'ids': [['id1']], 'distances': [[0.1]], 'metadatas': [[{}]] } + """ + results = await self.vector_db.search( + collection=collection_name, + query_embedding=query_vector, + k=limit, + search_type=search_type, + query_text=query_text, + filter=filter, + ) + + if not results or 'ids' not in results or not results['ids']: + return [] + + # Flatten nested lists (Chroma returns batch-style: list of lists) + raw_ids = results['ids'] + raw_dists = results.get('distances', []) + raw_metas = results.get('metadatas', []) + + r_ids = raw_ids[0] if raw_ids and isinstance(raw_ids[0], list) else raw_ids + r_dists = raw_dists[0] if raw_dists and isinstance(raw_dists[0], list) else raw_dists + r_metas = raw_metas[0] if raw_metas and isinstance(raw_metas[0], list) else raw_metas + + parsed_results = [] + for i, id_val in enumerate(r_ids): + parsed_results.append( + { + 'id': id_val, + 'score': r_dists[i] if r_dists and i < len(r_dists) else 0.0, + 'metadata': r_metas[i] if r_metas and i < len(r_metas) else {}, + } + ) + + return parsed_results + + async def delete_by_file_id(self, collection_name: str, file_ids: list[str]): + """Proxy: Delete vectors by file_id (metadata-level identifier). + + This delegates to VectorDatabase.delete_by_file_id which removes + all vectors associated with the given file IDs. + """ + for file_id in file_ids: + await self.vector_db.delete_by_file_id(collection_name, file_id) + + async def delete_collection(self, collection_name: str): + """Proxy: Delete an entire collection.""" + await self.vector_db.delete_collection(collection_name) + + async def delete_by_filter(self, collection_name: str, filter: dict) -> int: + """Proxy: Delete vectors by metadata filter. + + Returns: + Number of deleted vectors (best-effort; some backends return 0). + """ + return await self.vector_db.delete_by_filter(collection_name, filter) diff --git a/src/langbot/pkg/vector/vdb.py b/src/langbot/pkg/vector/vdb.py index 137bdb06..83356e11 100644 --- a/src/langbot/pkg/vector/vdb.py +++ b/src/langbot/pkg/vector/vdb.py @@ -1,10 +1,28 @@ from __future__ import annotations import abc +import enum from typing import Any, Dict import numpy as np +class SearchType(str, enum.Enum): + """Supported search types for vector databases.""" + + VECTOR = 'vector' + FULL_TEXT = 'full_text' + HYBRID = 'hybrid' + + class VectorDatabase(abc.ABC): + @classmethod + def supported_search_types(cls) -> list[SearchType]: + """Return the search types supported by this VDB backend. + + Default: vector search only. Override in subclasses that support + full-text or hybrid search. + """ + return [SearchType.VECTOR] + @abc.abstractmethod async def add_embeddings( self, @@ -12,14 +30,47 @@ class VectorDatabase(abc.ABC): ids: list[str], embeddings_list: list[list[float]], metadatas: list[dict[str, Any]], - documents: list[str], + documents: list[str] | None = None, ) -> None: - """Add vector data to the specified collection.""" + """Add vector data to the specified collection. + + Args: + collection: Collection name. + ids: Unique IDs for each vector. + embeddings_list: List of embedding vectors. + metadatas: List of metadata dicts. + documents: Optional raw text documents. Required for full-text + and hybrid search in backends that support them. + """ pass @abc.abstractmethod - async def search(self, collection: str, query_embedding: np.ndarray, k: int = 5) -> Dict[str, Any]: - """Search for the most similar vectors in the specified collection.""" + async def search( + self, + collection: str, + query_embedding: np.ndarray, + k: int = 5, + search_type: str = 'vector', + query_text: str = '', + filter: dict[str, Any] | None = None, + ) -> Dict[str, Any]: + """Search for the most similar vectors in the specified collection. + + Args: + collection: Collection name. + query_embedding: Query vector for similarity search. + k: Number of results to return. + search_type: One of 'vector', 'full_text', 'hybrid'. + query_text: Raw query text, used for full_text and hybrid search. + filter: Optional metadata filters using Chroma-style ``where`` + syntax. Multiple top-level keys are AND-ed. Supported + operators: ``$eq``, ``$ne``, ``$gt``, ``$gte``, ``$lt``, + ``$lte``, ``$in``, ``$nin``. Example:: + + {"file_id": "abc"} + {"created_at": {"$gte": 1700000000}} + {"file_type": {"$in": ["pdf", "docx"]}} + """ pass @abc.abstractmethod @@ -27,6 +78,20 @@ class VectorDatabase(abc.ABC): """Delete vectors from the specified collection by file_id.""" pass + @abc.abstractmethod + async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int: + """Delete vectors matching the given metadata filter. + + Args: + collection: Collection name. + filter: Metadata filter dict in canonical format (see ``search``). + + Returns: + Number of deleted vectors (best-effort; backends that cannot + report an exact count may return 0). + """ + pass + @abc.abstractmethod async def get_or_create_collection(self, collection: str): """Get or create collection.""" diff --git a/src/langbot/pkg/vector/vdbs/chroma.py b/src/langbot/pkg/vector/vdbs/chroma.py index 94227c75..6cefce13 100644 --- a/src/langbot/pkg/vector/vdbs/chroma.py +++ b/src/langbot/pkg/vector/vdbs/chroma.py @@ -28,19 +28,33 @@ class ChromaVectorDatabase(VectorDatabase): ids: list[str], embeddings_list: list[list[float]], metadatas: list[dict[str, Any]], + documents: list[str] | None = None, ) -> None: col = await self.get_or_create_collection(collection) - await asyncio.to_thread(col.add, embeddings=embeddings_list, ids=ids, metadatas=metadatas) + kwargs: dict[str, Any] = dict(embeddings=embeddings_list, ids=ids, metadatas=metadatas) + if documents is not None: + kwargs['documents'] = documents + await asyncio.to_thread(col.add, **kwargs) self.ap.logger.info(f"Added {len(ids)} embeddings to Chroma collection '{collection}'.") - async def search(self, collection: str, query_embedding: list[float], k: int = 5) -> dict[str, Any]: + async def search( + self, + collection: str, + query_embedding: list[float], + k: int = 5, + search_type: str = 'vector', + query_text: str = '', + filter: dict[str, Any] | None = None, + ) -> dict[str, Any]: col = await self.get_or_create_collection(collection) - results = await asyncio.to_thread( - col.query, + query_kwargs: dict[str, Any] = dict( query_embeddings=query_embedding, n_results=k, include=['metadatas', 'distances', 'documents'], ) + if filter: + query_kwargs['where'] = filter + results = await asyncio.to_thread(col.query, **query_kwargs) self.ap.logger.info(f"Chroma search in '{collection}' returned {len(results.get('ids', [[]])[0])} results.") return results @@ -49,6 +63,12 @@ class ChromaVectorDatabase(VectorDatabase): await asyncio.to_thread(col.delete, where={'file_id': file_id}) self.ap.logger.info(f"Deleted embeddings from Chroma collection '{collection}' with file_id: {file_id}") + async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int: + col = await self.get_or_create_collection(collection) + await asyncio.to_thread(col.delete, where=filter) + self.ap.logger.info(f"Deleted embeddings from Chroma collection '{collection}' by filter") + return 0 # Chroma delete does not return a count + async def delete_collection(self, collection: str): if collection in self._collections: del self._collections[collection] diff --git a/src/langbot/pkg/vector/vdbs/milvus.py b/src/langbot/pkg/vector/vdbs/milvus.py index 2852dea1..967f3bed 100644 --- a/src/langbot/pkg/vector/vdbs/milvus.py +++ b/src/langbot/pkg/vector/vdbs/milvus.py @@ -4,8 +4,51 @@ from typing import Any, Dict from pymilvus import MilvusClient, DataType, CollectionSchema, FieldSchema from pymilvus.milvus_client.index import IndexParams from langbot.pkg.vector.vdb import VectorDatabase +from langbot.pkg.vector.filter_utils import normalize_filter, strip_unsupported_fields from langbot.pkg.core import app +# Milvus schema only stores these metadata fields; filter on other fields is +# silently dropped with a warning. +_MILVUS_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'} + + +def _build_milvus_expr(filter_dict: dict[str, Any]) -> str: + """Translate canonical filter dict into a Milvus boolean expression string.""" + triples = normalize_filter(filter_dict) + triples = strip_unsupported_fields(triples, _MILVUS_SUPPORTED_FIELDS) + if not triples: + return '' + + parts: list[str] = [] + for field, op, value in triples: + if op == '$eq': + parts.append(f'{field} == {_milvus_literal(value)}') + elif op == '$ne': + parts.append(f'{field} != {_milvus_literal(value)}') + elif op == '$gt': + parts.append(f'{field} > {_milvus_literal(value)}') + elif op == '$gte': + parts.append(f'{field} >= {_milvus_literal(value)}') + elif op == '$lt': + parts.append(f'{field} < {_milvus_literal(value)}') + elif op == '$lte': + parts.append(f'{field} <= {_milvus_literal(value)}') + elif op == '$in': + items = ', '.join(_milvus_literal(v) for v in value) + parts.append(f'{field} in [{items}]') + elif op == '$nin': + items = ', '.join(_milvus_literal(v) for v in value) + parts.append(f'{field} not in [{items}]') + return ' and '.join(parts) + + +def _milvus_literal(value: Any) -> str: + """Format a Python value as a Milvus expression literal.""" + if isinstance(value, str): + escaped = value.replace('\\', '\\\\').replace('"', '\\"') + return f'"{escaped}"' + return str(value) + class MilvusVectorDatabase(VectorDatabase): """Milvus vector database implementation""" @@ -155,6 +198,7 @@ class MilvusVectorDatabase(VectorDatabase): ids: list[str], embeddings_list: list[list[float]], metadatas: list[dict[str, Any]], + documents: list[str] | None = None, ) -> None: """Add vector embeddings to Milvus collection @@ -200,7 +244,15 @@ class MilvusVectorDatabase(VectorDatabase): self.ap.logger.info(f"Added {len(ids)} embeddings to Milvus collection '{collection}'") - async def search(self, collection: str, query_embedding: list[float], k: int = 5) -> Dict[str, Any]: + async def search( + self, + collection: str, + query_embedding: list[float], + k: int = 5, + search_type: str = 'vector', + query_text: str = '', + filter: dict[str, Any] | None = None, + ) -> Dict[str, Any]: """Search for similar vectors in Milvus collection Args: @@ -217,14 +269,19 @@ class MilvusVectorDatabase(VectorDatabase): # Perform search search_params = {'metric_type': 'COSINE', 'params': {}} - results = await asyncio.to_thread( - self.client.search, + search_kwargs: dict[str, Any] = dict( collection_name=collection, data=[query_embedding], limit=k, search_params=search_params, output_fields=['text', 'file_id', 'chunk_uuid'], ) + if filter: + expr = _build_milvus_expr(filter) + if expr: + search_kwargs['filter'] = expr + + results = await asyncio.to_thread(self.client.search, **search_kwargs) # Convert results to Chroma-compatible format # Milvus returns: [[ {id, distance, entity: {...}} ]] @@ -268,6 +325,21 @@ class MilvusVectorDatabase(VectorDatabase): await asyncio.to_thread(self.client.delete, collection_name=collection, filter=f'file_id == "{file_id}"') self.ap.logger.info(f"Deleted embeddings from Milvus collection '{collection}' with file_id: {file_id}") + async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int: + collection = self._normalize_collection_name(collection) + await self.get_or_create_collection(collection) + + expr = _build_milvus_expr(filter) + if not expr: + self.ap.logger.warning( + f"Milvus delete_by_filter on '{collection}': filter produced empty expression, skipping" + ) + return 0 + + await asyncio.to_thread(self.client.delete, collection_name=collection, filter=expr) + self.ap.logger.info(f"Deleted embeddings from Milvus collection '{collection}' by filter") + return 0 # Milvus delete does not return a count + async def delete_collection(self, collection: str): """Delete a Milvus collection diff --git a/src/langbot/pkg/vector/vdbs/pgvector_db.py b/src/langbot/pkg/vector/vdbs/pgvector_db.py index 7490f228..66242894 100644 --- a/src/langbot/pkg/vector/vdbs/pgvector_db.py +++ b/src/langbot/pkg/vector/vdbs/pgvector_db.py @@ -5,10 +5,21 @@ from sqlalchemy.orm import declarative_base from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from pgvector.sqlalchemy import Vector from langbot.pkg.vector.vdb import VectorDatabase +from langbot.pkg.vector.filter_utils import normalize_filter, strip_unsupported_fields from langbot.pkg.core import app Base = declarative_base() +# pgvector schema only stores these metadata fields. +_PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'} + +# Map schema field names to SQLAlchemy columns (resolved lazily from PgVectorEntry). +_PG_COLUMN_MAP = { + 'text': 'text', + 'file_id': 'file_id', + 'chunk_uuid': 'chunk_uuid', +} + class PgVectorEntry(Base): """SQLAlchemy model for pgvector entries""" @@ -23,6 +34,33 @@ class PgVectorEntry(Base): chunk_uuid = Column(String) +def _build_pg_conditions(filter_dict: dict[str, Any]) -> list: + """Translate canonical filter dict into a list of SQLAlchemy conditions.""" + triples = normalize_filter(filter_dict) + triples = strip_unsupported_fields(triples, _PG_SUPPORTED_FIELDS) + + conditions = [] + for field, op, value in triples: + col = getattr(PgVectorEntry, _PG_COLUMN_MAP[field]) + if op == '$eq': + conditions.append(col == value) + elif op == '$ne': + conditions.append(col != value) + elif op == '$gt': + conditions.append(col > value) + elif op == '$gte': + conditions.append(col >= value) + elif op == '$lt': + conditions.append(col < value) + elif op == '$lte': + conditions.append(col <= value) + elif op == '$in': + conditions.append(col.in_(value)) + elif op == '$nin': + conditions.append(col.notin_(value)) + return conditions + + class PgVectorDatabase(VectorDatabase): """PostgreSQL with pgvector extension database implementation""" @@ -109,6 +147,7 @@ class PgVectorDatabase(VectorDatabase): ids: list[str], embeddings_list: list[list[float]], metadatas: list[dict[str, Any]], + documents: list[str] | None = None, ) -> None: """Add vector embeddings to pgvector @@ -142,7 +181,15 @@ class PgVectorDatabase(VectorDatabase): self.ap.logger.error(f'Error adding embeddings to pgvector: {e}') raise - async def search(self, collection: str, query_embedding: list[float], k: int = 5) -> Dict[str, Any]: + async def search( + self, + collection: str, + query_embedding: list[float], + k: int = 5, + search_type: str = 'vector', + query_text: str = '', + filter: dict[str, Any] | None = None, + ) -> Dict[str, Any]: """Search for similar vectors using cosine distance Args: @@ -174,6 +221,10 @@ class PgVectorDatabase(VectorDatabase): .limit(k) ) + if filter: + for cond in _build_pg_conditions(filter): + stmt = stmt.filter(cond) + result = await session.execute(stmt) rows = result.fetchall() @@ -225,6 +276,39 @@ class PgVectorDatabase(VectorDatabase): self.ap.logger.error(f'Error deleting from pgvector: {e}') raise + async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int: + """Delete vectors matching a metadata filter. + + Args: + collection: Collection name + filter: Canonical metadata filter dict + """ + conditions = _build_pg_conditions(filter) + if not conditions: + self.ap.logger.warning( + f"pgvector delete_by_filter on '{collection}': filter produced no conditions, skipping" + ) + return 0 + + await self.get_or_create_collection(collection) + + async with self.AsyncSessionLocal() as session: + try: + from sqlalchemy import delete + + stmt = delete(PgVectorEntry).where(PgVectorEntry.collection == collection) + for cond in conditions: + stmt = stmt.where(cond) + result = await session.execute(stmt) + await session.commit() + deleted = result.rowcount + self.ap.logger.info(f"Deleted {deleted} embeddings from pgvector collection '{collection}' by filter") + return deleted + except Exception as e: + await session.rollback() + self.ap.logger.error(f'Error deleting from pgvector by filter: {e}') + raise + async def delete_collection(self, collection: str): """Delete all vectors in a collection diff --git a/src/langbot/pkg/vector/vdbs/qdrant.py b/src/langbot/pkg/vector/vdbs/qdrant.py index a6fbd4ab..40772040 100644 --- a/src/langbot/pkg/vector/vdbs/qdrant.py +++ b/src/langbot/pkg/vector/vdbs/qdrant.py @@ -5,6 +5,37 @@ from typing import Any, Dict, List from qdrant_client import AsyncQdrantClient, models from langbot.pkg.core import app from langbot.pkg.vector.vdb import VectorDatabase +from langbot.pkg.vector.filter_utils import normalize_filter + + +def _build_qdrant_filter(filter_dict: dict[str, Any]) -> models.Filter: + """Translate canonical filter dict into a Qdrant ``models.Filter``.""" + triples = normalize_filter(filter_dict) + must: list[models.Condition] = [] + must_not: list[models.Condition] = [] + + for field, op, value in triples: + if op == '$eq': + must.append(models.FieldCondition(key=field, match=models.MatchValue(value=value))) + elif op == '$ne': + must_not.append(models.FieldCondition(key=field, match=models.MatchValue(value=value))) + elif op == '$in': + must.append(models.FieldCondition(key=field, match=models.MatchAny(any=value))) + elif op == '$nin': + must_not.append(models.FieldCondition(key=field, match=models.MatchAny(any=value))) + elif op in ('$gt', '$gte', '$lt', '$lte'): + range_kwargs: dict[str, Any] = {} + if op == '$gt': + range_kwargs['gt'] = value + elif op == '$gte': + range_kwargs['gte'] = value + elif op == '$lt': + range_kwargs['lt'] = value + elif op == '$lte': + range_kwargs['lte'] = value + must.append(models.FieldCondition(key=field, range=models.Range(**range_kwargs))) + + return models.Filter(must=must or None, must_not=must_not or None) class QdrantVectorDatabase(VectorDatabase): @@ -48,6 +79,7 @@ class QdrantVectorDatabase(VectorDatabase): ids: List[str], embeddings_list: List[List[float]], metadatas: List[Dict[str, Any]], + documents: List[str] | None = None, ) -> None: if not embeddings_list: return @@ -60,19 +92,29 @@ class QdrantVectorDatabase(VectorDatabase): await self.client.upsert(collection_name=collection, points=points) self.ap.logger.info(f"Added {len(ids)} embeddings to Qdrant collection '{collection}'.") - async def search(self, collection: str, query_embedding: list[float], k: int = 5) -> dict[str, Any]: + async def search( + self, + collection: str, + query_embedding: list[float], + k: int = 5, + search_type: str = 'vector', + query_text: str = '', + filter: dict[str, Any] | None = None, + ) -> dict[str, Any]: exists = await self.client.collection_exists(collection) if not exists: return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]} - hits = ( - await self.client.query_points( - collection_name=collection, - query=query_embedding, - limit=k, - with_payload=True, - ) - ).points + query_kwargs: dict[str, Any] = dict( + collection_name=collection, + query=query_embedding, + limit=k, + with_payload=True, + ) + if filter: + query_kwargs['query_filter'] = _build_qdrant_filter(filter) + + hits = (await self.client.query_points(**query_kwargs)).points ids = [str(hit.id) for hit in hits] metadatas = [hit.payload or {} for hit in hits] # Qdrant's score is similarity; convert to a pseudo-distance for consistency @@ -95,6 +137,19 @@ class QdrantVectorDatabase(VectorDatabase): ) self.ap.logger.info(f"Deleted embeddings from Qdrant collection '{collection}' with file_id: {file_id}") + async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int: + exists = await self.client.collection_exists(collection) + if not exists: + return 0 + + qdrant_filter = _build_qdrant_filter(filter) + await self.client.delete( + collection_name=collection, + points_selector=qdrant_filter, + ) + self.ap.logger.info(f"Deleted embeddings from Qdrant collection '{collection}' by filter") + return 0 # Qdrant delete does not return a count + async def delete_collection(self, collection: str): try: await self.client.delete_collection(collection) diff --git a/src/langbot/pkg/vector/vdbs/seekdb.py b/src/langbot/pkg/vector/vdbs/seekdb.py index b007f2fb..cc22e158 100644 --- a/src/langbot/pkg/vector/vdbs/seekdb.py +++ b/src/langbot/pkg/vector/vdbs/seekdb.py @@ -5,7 +5,7 @@ from typing import Any, Dict, List from langbot.pkg.core import app -from langbot.pkg.vector.vdb import VectorDatabase +from langbot.pkg.vector.vdb import VectorDatabase, SearchType try: import pyseekdb @@ -25,9 +25,13 @@ class SeekDBVectorDatabase(VectorDatabase): SeekDB is an AI-native search database by OceanBase that unifies relational, vector, text, JSON and GIS in a single engine. - Supports both embedded mode and remote server mode. + Supports embedded mode, remote server mode, and full-text/hybrid search. """ + @classmethod + def supported_search_types(cls) -> list[SearchType]: + return [SearchType.VECTOR, SearchType.FULL_TEXT, SearchType.HYBRID] + def __init__(self, ap: app.Application): if not SEEKDB_AVAILABLE: raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb') @@ -89,6 +93,7 @@ class SeekDBVectorDatabase(VectorDatabase): { '\x00': '', '\\': '\\\\', + "'": "''", # Standard SQL escaping (OceanBase NO_BACKSLASH_ESCAPES) '"': '\\"', '\n': '\\n', '\r': '\\r', @@ -111,8 +116,10 @@ class SeekDBVectorDatabase(VectorDatabase): # Collection doesn't exist, create it if vector_size is None: - # Default dimension if not specified - vector_size = 384 + raise ValueError( + f"Cannot create SeekDB collection '{collection}' without knowing the vector dimension. " + 'Ensure add_embeddings is called before any standalone get_or_create_collection.' + ) # Create HNSW configuration config = HNSWConfiguration(dimension=vector_size, distance='cosine') @@ -147,7 +154,12 @@ class SeekDBVectorDatabase(VectorDatabase): return await self._get_or_create_collection_internal(collection) async def add_embeddings( - self, collection: str, ids: List[str], embeddings_list: List[List[float]], metadatas: List[Dict[str, Any]] + self, + collection: str, + ids: List[str], + embeddings_list: List[List[float]], + metadatas: List[Dict[str, Any]], + documents: List[str] | None = None, ) -> None: """Add vector embeddings to the specified collection. @@ -156,6 +168,7 @@ class SeekDBVectorDatabase(VectorDatabase): ids: List of document IDs embeddings_list: List of embedding vectors metadatas: List of metadata dictionaries + documents: Optional raw text documents for full-text search support """ if not embeddings_list: return @@ -166,17 +179,33 @@ class SeekDBVectorDatabase(VectorDatabase): cleaned_metadatas = [self._clean_metadata(meta) for meta in metadatas] - await asyncio.to_thread(coll.add, ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas) + kwargs: Dict[str, Any] = dict(ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas) + if documents is not None: + kwargs['documents'] = [doc.translate(self._escape_table) for doc in documents] + await asyncio.to_thread(coll.add, **kwargs) self.ap.logger.info(f"Added {len(ids)} embeddings to SeekDB collection '{collection}'") - async def search(self, collection: str, query_embedding: List[float], k: int = 5) -> Dict[str, Any]: + async def search( + self, + collection: str, + query_embedding: List[float], + k: int = 5, + search_type: str = 'vector', + query_text: str = '', + filter: Dict[str, Any] | None = None, + ) -> Dict[str, Any]: """Search for the most similar vectors in the specified collection. + SeekDB supports vector, full-text, and hybrid search modes. + Args: collection: Collection name - query_embedding: Query vector + query_embedding: Query vector (used for vector and hybrid modes) k: Number of results to return + search_type: One of 'vector', 'full_text', 'hybrid' + query_text: Raw query text (used for full_text and hybrid modes) + filter: Optional metadata filters (Chroma-style ``where`` syntax). Returns: Dictionary with 'ids', 'metadatas', 'distances' keys @@ -193,11 +222,73 @@ class SeekDBVectorDatabase(VectorDatabase): else: coll = self._collections[collection] - # Perform query - # SeekDB's query() returns: {'ids': [[...]], 'metadatas': [[...]], 'distances': [[...]]} - results = await asyncio.to_thread(coll.query, query_embeddings=query_embedding, n_results=k) + # Route by search type. + # pyseekdb's query() always requires embeddings, so full-text and + # hybrid modes use hybrid_search() which supports text-only queries + # and returns the same nested-list format with distances. + if search_type == SearchType.FULL_TEXT: + if not query_text: + return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]} - self.ap.logger.info(f"SeekDB search in '{collection}' returned {len(results.get('ids', [[]])[0])} results") + query_cfg: Dict[str, Any] = { + 'where_document': {'$contains': query_text}, + 'n_results': k, + } + if filter: + query_cfg['where'] = filter + + # TODO: pyseekdb hybrid_search with query-only (no knn) returns None + # for IDs due to column name mismatch (*/_id vs _id). + # See: https://github.com/oceanbase/pyseekdb/issues/171 + results = await asyncio.to_thread( + coll.hybrid_search, + query=query_cfg, + knn=None, + n_results=k, + include=['documents', 'metadatas'], + ) + + elif search_type == SearchType.HYBRID: + if not query_text: + # Fall back to pure vector search when no text is provided + query_kwargs: Dict[str, Any] = { + 'n_results': k, + 'query_embeddings': query_embedding, + } + if filter: + query_kwargs['where'] = filter + results = await asyncio.to_thread(coll.query, **query_kwargs) + else: + query_cfg = { + 'where_document': {'$contains': query_text}, + 'n_results': k, + } + knn_cfg: Dict[str, Any] = { + 'query_embeddings': query_embedding, + 'n_results': k, + } + if filter: + query_cfg['where'] = filter + knn_cfg['where'] = filter + + results = await asyncio.to_thread( + coll.hybrid_search, + query=query_cfg, + knn=knn_cfg, + rank={'rrf': {}}, + n_results=k, + include=['documents', 'metadatas'], + ) + else: + # Default: vector search via query() + query_kwargs = {'n_results': k, 'query_embeddings': query_embedding} + if filter: + query_kwargs['where'] = filter + results = await asyncio.to_thread(coll.query, **query_kwargs) + + self.ap.logger.info( + f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results" + ) return results @@ -227,6 +318,28 @@ class SeekDBVectorDatabase(VectorDatabase): self.ap.logger.info(f"Deleted embeddings from SeekDB collection '{collection}' with file_id: {file_id}") + async def delete_by_filter(self, collection: str, filter: Dict[str, Any]) -> int: + """Delete vectors from the collection by metadata filter. + + Args: + collection: Collection name + filter: Chroma-style ``where`` filter dict + """ + exists = await asyncio.to_thread(self.client.has_collection, collection) + if not exists: + self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion") + return 0 + + if collection not in self._collections: + coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None) + self._collections[collection] = coll + else: + coll = self._collections[collection] + + await asyncio.to_thread(coll.delete, where=filter) + self.ap.logger.info(f"Deleted embeddings from SeekDB collection '{collection}' by filter") + return 0 # SeekDB delete does not return a count + async def delete_collection(self, collection: str): """Delete the entire collection. diff --git a/tests/unit_tests/plugin/test_plugin_component_filtering.py b/tests/unit_tests/plugin/test_plugin_component_filtering.py index c2c4fd76..45940fed 100644 --- a/tests/unit_tests/plugin/test_plugin_component_filtering.py +++ b/tests/unit_tests/plugin/test_plugin_component_filtering.py @@ -38,13 +38,11 @@ async def test_plugin_list_filter_by_component_kinds(): 'manifest': { 'metadata': { 'author': 'author2', - 'name': 'plugin_with_knowledge_retriever_only', + 'name': 'plugin_with_knowledge_engine_only', } } }, - 'components': [ - {'manifest': {'manifest': {'kind': 'KnowledgeRetriever', 'metadata': {'name': 'retriever1'}}}} - ], + 'components': [{'manifest': {'manifest': {'kind': 'KnowledgeEngine', 'metadata': {'name': 'retriever1'}}}}], }, { 'debug': False, @@ -81,7 +79,7 @@ async def test_plugin_list_filter_by_component_kinds(): } }, 'components': [ - {'manifest': {'manifest': {'kind': 'KnowledgeRetriever', 'metadata': {'name': 'retriever2'}}}}, + {'manifest': {'manifest': {'kind': 'KnowledgeEngine', 'metadata': {'name': 'retriever2'}}}}, {'manifest': {'manifest': {'kind': 'Tool', 'metadata': {'name': 'tool2'}}}}, ], }, @@ -108,8 +106,8 @@ async def test_plugin_list_filter_by_component_kinds(): assert 'plugin_with_command' in plugin_names assert 'plugin_with_event_listener' in plugin_names assert 'plugin_with_mixed_components' in plugin_names - # Plugin with only KnowledgeRetriever should NOT be included - assert 'plugin_with_knowledge_retriever_only' not in plugin_names + # Plugin with only KnowledgeEngine should NOT be included + assert 'plugin_with_knowledge_engine_only' not in plugin_names @pytest.mark.asyncio @@ -150,9 +148,7 @@ async def test_plugin_list_filter_no_filter(): } } }, - 'components': [ - {'manifest': {'manifest': {'kind': 'KnowledgeRetriever', 'metadata': {'name': 'retriever1'}}}} - ], + 'components': [{'manifest': {'manifest': {'kind': 'KnowledgeEngine', 'metadata': {'name': 'retriever1'}}}}], }, ] @@ -189,7 +185,7 @@ async def test_plugin_list_filter_empty_result(): connector = PluginRuntimeConnector(mock_app, AsyncMock()) connector.handler = MagicMock() - # Mock plugin data - only KnowledgeRetriever plugins + # Mock plugin data - only KnowledgeEngine plugins mock_plugins = [ { 'debug': False, @@ -201,9 +197,7 @@ async def test_plugin_list_filter_empty_result(): } } }, - 'components': [ - {'manifest': {'manifest': {'kind': 'KnowledgeRetriever', 'metadata': {'name': 'retriever1'}}}} - ], + 'components': [{'manifest': {'manifest': {'kind': 'KnowledgeEngine', 'metadata': {'name': 'retriever1'}}}}], }, ] diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index aec212c8..28086510 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -508,6 +508,7 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -516,6 +517,7 @@ packages: resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -524,6 +526,7 @@ packages: resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -532,6 +535,7 @@ packages: resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -540,6 +544,7 @@ packages: resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -548,6 +553,7 @@ packages: resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -556,6 +562,7 @@ packages: resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true @@ -564,6 +571,7 @@ packages: resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true @@ -573,6 +581,7 @@ packages: engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] requiresBuild: true optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.4 @@ -584,6 +593,7 @@ packages: engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] requiresBuild: true optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.4 @@ -595,6 +605,7 @@ packages: engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] requiresBuild: true optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 @@ -606,6 +617,7 @@ packages: engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] requiresBuild: true optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 @@ -617,6 +629,7 @@ packages: engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] requiresBuild: true optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.4 @@ -628,6 +641,7 @@ packages: engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] requiresBuild: true optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.4 @@ -639,6 +653,7 @@ packages: engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] requiresBuild: true optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 @@ -650,6 +665,7 @@ packages: engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] requiresBuild: true optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 @@ -766,6 +782,7 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -775,6 +792,7 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true @@ -784,6 +802,7 @@ packages: engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -793,6 +812,7 @@ packages: engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true @@ -1892,6 +1912,7 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -1901,6 +1922,7 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true @@ -1910,6 +1932,7 @@ packages: engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -1919,6 +1942,7 @@ packages: engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true @@ -2334,6 +2358,7 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] requiresBuild: true dev: true optional: true @@ -2342,6 +2367,7 @@ packages: resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] requiresBuild: true dev: true optional: true @@ -2350,6 +2376,7 @@ packages: resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] requiresBuild: true dev: true optional: true @@ -2358,6 +2385,7 @@ packages: resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] requiresBuild: true dev: true optional: true @@ -2366,6 +2394,7 @@ packages: resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] requiresBuild: true dev: true optional: true @@ -2374,6 +2403,7 @@ packages: resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] requiresBuild: true dev: true optional: true @@ -2382,6 +2412,7 @@ packages: resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] requiresBuild: true dev: true optional: true @@ -2390,6 +2421,7 @@ packages: resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] requiresBuild: true dev: true optional: true @@ -4424,6 +4456,7 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -4433,6 +4466,7 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true @@ -4442,6 +4476,7 @@ packages: engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -4451,6 +4486,7 @@ packages: engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 8fd63aef..d2ca22d7 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -319,6 +319,7 @@ export default function BotForm({ required: item.required, type: parseDynamicFormItemType(item.type), options: item.options, + show_if: item.show_if, }), ), ); diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index b7319529..b40563ae 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -13,20 +13,26 @@ import { import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import { useCallback, useEffect, useRef } from 'react'; import { extractI18nObject } from '@/i18n/I18nProvider'; +import { useTranslation } from 'react-i18next'; export default function DynamicFormComponent({ itemConfigList, onSubmit, initialValues, onFileUploaded, + isEditing, + externalDependentValues, }: { itemConfigList: IDynamicFormItemSchema[]; onSubmit?: (val: object) => unknown; initialValues?: Record; onFileUploaded?: (fileKey: string) => void; + isEditing?: boolean; + externalDependentValues?: Record; }) { const isInitialMount = useRef(true); const previousInitialValues = useRef(initialValues); + const { t } = useTranslation(); // 根据 itemConfigList 动态生成 zod schema const formSchema = z.object( @@ -55,6 +61,9 @@ export default function DynamicFormComponent({ case 'llm-model-selector': fieldSchema = z.string(); break; + case 'embedding-model-selector': + fieldSchema = z.string(); + break; case 'knowledge-base-selector': fieldSchema = z.string(); break; @@ -81,7 +90,9 @@ export default function DynamicFormComponent({ (fieldSchema instanceof z.ZodString || fieldSchema instanceof z.ZodArray) ) { - fieldSchema = fieldSchema.min(1, { message: '此字段为必填项' }); + fieldSchema = fieldSchema.min(1, { + message: t('common.fieldRequired'), + }); } return { @@ -141,6 +152,9 @@ export default function DynamicFormComponent({ } }, [initialValues, form, itemConfigList]); + // Get reactive form values for conditional rendering + const watchedValues = form.watch(); + // Stable ref for onSubmit to avoid re-triggering the effect when the // parent passes a new closure on every render. const onSubmitRef = useRef(onSubmit); @@ -183,34 +197,75 @@ export default function DynamicFormComponent({ return (
- {itemConfigList.map((config) => ( - ( - - - {extractI18nObject(config.label)}{' '} - {config.required && *} - - - - - {config.description && ( -

- {extractI18nObject(config.description)} -

- )} - -
- )} - /> - ))} + {itemConfigList.map((config) => { + if (config.show_if) { + const dependValue = + watchedValues[ + config.show_if.field as keyof typeof watchedValues + ] !== undefined + ? watchedValues[ + config.show_if.field as keyof typeof watchedValues + ] + : externalDependentValues?.[config.show_if.field]; + + if ( + config.show_if.operator === 'eq' && + dependValue !== config.show_if.value + ) { + return null; + } + if ( + config.show_if.operator === 'neq' && + dependValue === config.show_if.value + ) { + return null; + } + if ( + config.show_if.operator === 'in' && + Array.isArray(config.show_if.value) && + !config.show_if.value.includes(dependValue) + ) { + return null; + } + } + + // All fields are disabled when editing (creation_settings are immutable) + const isFieldDisabled = !!isEditing; + return ( + ( + + + {extractI18nObject(config.label)}{' '} + {config.required && *} + + +
+ +
+
+ {config.description && ( +

+ {extractI18nObject(config.description)} +

+ )} + +
+ )} + /> + ); + })}
); diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index a4f35be4..ca2f4e72 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -22,8 +22,7 @@ import { LLMModel, Bot, KnowledgeBase, - ExternalKnowledgeBase, - ApiRespPluginSystemStatus, + EmbeddingModel, } from '@/app/infra/entities/api'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -51,16 +50,12 @@ export default function DynamicFormItemComponent({ onFileUploaded?: (fileKey: string) => void; }) { const [llmModels, setLlmModels] = useState([]); + const [embeddingModels, setEmbeddingModels] = useState([]); const [knowledgeBases, setKnowledgeBases] = useState([]); - const [externalKnowledgeBases, setExternalKnowledgeBases] = useState< - ExternalKnowledgeBase[] - >([]); const [bots, setBots] = useState([]); const [uploading, setUploading] = useState(false); const [kbDialogOpen, setKbDialogOpen] = useState(false); const [tempSelectedKBIds, setTempSelectedKBIds] = useState([]); - const [pluginSystemStatus, setPluginSystemStatus] = - useState(null); const { t } = useTranslation(); const handleFileUpload = async (file: File): Promise => { @@ -111,7 +106,20 @@ export default function DynamicFormItemComponent({ setLlmModels(models); }) .catch((err) => { - toast.error('Failed to get LLM model list: ' + err.msg); + toast.error(t('models.getModelListError') + err.msg); + }); + } + }, [config.type]); + + useEffect(() => { + if (config.type === DynamicFormItemType.EMBEDDING_MODEL_SELECTOR) { + httpClient + .getProviderEmbeddingModels() + .then((resp) => { + setEmbeddingModels(resp.models); + }) + .catch((err) => { + toast.error(t('embedding.getModelListError') + err.msg); }); } }, [config.type]); @@ -127,39 +135,11 @@ export default function DynamicFormItemComponent({ setKnowledgeBases(resp.bases); }) .catch((err) => { - toast.error('Failed to get knowledge base list: ' + err.msg); - }); - - // Fetch plugin system status - httpClient - .getPluginSystemStatus() - .then((status) => { - setPluginSystemStatus(status); - }) - .catch((err) => { - console.error('Failed to get plugin system status:', err); + toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg); }); } }, [config.type]); - useEffect(() => { - if ( - (config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR || - config.type === DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR) && - pluginSystemStatus?.is_enable && - pluginSystemStatus?.is_connected - ) { - httpClient - .getExternalKnowledgeBases() - .then((resp) => { - setExternalKnowledgeBases(resp.bases); - }) - .catch((err) => { - console.error('Failed to get external knowledge base list:', err); - }); - } - }, [config.type, pluginSystemStatus]); - useEffect(() => { if (config.type === DynamicFormItemType.BOT_SELECTOR) { httpClient @@ -168,7 +148,7 @@ export default function DynamicFormItemComponent({ setBots(resp.bots); }) .catch((err) => { - toast.error('Failed to get bot list: ' + err.msg); + toast.error(t('bots.getBotListError') + err.msg); }); } }, [config.type]); @@ -304,7 +284,56 @@ export default function DynamicFormItemComponent({ ); + case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: + // Group embedding models by provider + const groupedEmbeddingModels = embeddingModels.reduce( + (acc, model) => { + const providerName = model.provider?.name || 'Unknown'; + if (!acc[providerName]) acc[providerName] = []; + acc[providerName].push(model); + return acc; + }, + {} as Record, + ); + + return ( + + ); + case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR: + // Group KBs by Knowledge Engine name + const kbsByEngine = knowledgeBases.reduce( + (acc, kb) => { + const engineName = kb.knowledge_engine?.name + ? extractI18nObject(kb.knowledge_engine.name) + : t('knowledge.unknownEngine'); + if (!acc[engineName]) { + acc[engineName] = []; + } + acc[engineName].push(kb); + return acc; + }, + {} as Record, + ); + return ( ); case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR: + // Group KBs by Knowledge Engine name for multi-selector + const multiKbsByEngine = knowledgeBases.reduce( + (acc, kb) => { + const engineName = kb.knowledge_engine?.name + ? extractI18nObject(kb.knowledge_engine.name) + : t('knowledge.unknownEngine'); + if (!acc[engineName]) { + acc[engineName] = []; + } + acc[engineName].push(kb); + return acc; + }, + {} as Record, + ); + return ( <>
{field.value && field.value.length > 0 ? (
{field.value.map((kbId: string) => { - const kb = knowledgeBases.find((base) => base.uuid === kbId); - const externalKb = externalKnowledgeBases.find( + const currentKb = knowledgeBases.find( (base) => base.uuid === kbId, ); - const currentKb = kb || externalKb; if (!currentKb) return null; return ( @@ -370,18 +391,17 @@ export default function DynamicFormItemComponent({ className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent" >
- {externalKb && ( - plugin icon - )}
-
{currentKb.name}
+
+ {currentKb.name} + {currentKb.knowledge_engine?.name && ( + + {extractI18nObject( + currentKb.knowledge_engine.name, + )} + + )} +
{currentKb.description && (
{currentKb.description} @@ -435,13 +455,12 @@ export default function DynamicFormItemComponent({ {t('knowledge.selectKnowledgeBases')}
- {/* Built-in Knowledge Bases */} - {knowledgeBases.length > 0 && ( -
+ {Object.entries(multiKbsByEngine).map(([engineName, kbs]) => ( +
- {t('knowledge.builtIn')} + {engineName}
- {knowledgeBases.map((base) => { + {kbs.map((base) => { const isSelected = tempSelectedKBIds.includes( base.uuid ?? '', ); @@ -474,56 +493,7 @@ export default function DynamicFormItemComponent({ ); })}
- )} - - {/* External Knowledge Bases */} - {externalKnowledgeBases.length > 0 && ( -
-
- {t('knowledge.external')} -
- {externalKnowledgeBases.map((base) => { - const isSelected = tempSelectedKBIds.includes( - base.uuid ?? '', - ); - return ( -
{ - const kbId = base.uuid ?? ''; - setTempSelectedKBIds((prev) => - prev.includes(kbId) - ? prev.filter((id) => id !== kbId) - : [...prev, kbId], - ); - }} - > - - plugin icon -
-
{base.name}
- {base.description && ( -
- {base.description} -
- )} -
-
- ); - })} -
- )} + ))}
- -
- - )} + +
+ + +
+
@@ -205,7 +211,7 @@ export default function KBDetailDialog({ -
+
{activeMenu === 'metadata' @@ -216,33 +222,28 @@ export default function KBDetailDialog({
- {activeMenu === 'metadata' && - (kbType === 'builtin' ? ( - - ) : ( - onOpenChange(false)} - onKBDeleted={() => { - onKbDeleted(); - onOpenChange(false); - }} - onNewKBCreated={onNewKbCreated} - /> - ))} - {activeMenu === 'documents' && kbType === 'builtin' && ( - + {activeMenu === 'metadata' && ( + + )} + {activeMenu === 'documents' && hasDocumentCapability() && ( + + )} + {activeMenu === 'retrieve' && ( + )} - {activeMenu === 'retrieve' && - (kbType === 'builtin' ? ( - - ) : ( - - ))}
{activeMenu === 'metadata' && ( @@ -254,12 +255,7 @@ export default function KBDetailDialog({ > {t('common.delete')} - - - - - - - {/* Main Form */} -
- -
- {/* KB Name and Emoji in same row */} -
- ( - - - {t('knowledge.kbName')} - * - - - - - - - )} - /> - ( - - {t('common.icon')} - - - - - - )} - /> -
- - {/* KB Description */} - ( - - {t('knowledge.kbDescription')} - - - - - - )} - /> - - {/* Retriever Selector */} - ( - - - {t('knowledge.retriever')} - * - - - - - -

- {t('knowledge.retrieverInstallInfo')}{' '} - - {t('knowledge.retrieverMarketLink')} - -

-
- )} - /> - - {/* Selected Retriever Card */} - {currentRetrieverFullName && ( -
- plugin icon -
-
- {getRetrieverLabel(currentRetrieverFullName)} -
-
- {form.watch('plugin_author')} / {form.watch('plugin_name')} -
-
-
- )} - - {/* Dynamic Retriever Configuration Form */} - {showDynamicForm && dynamicFormConfigList.length > 0 && ( -
-
- {t('knowledge.retrieverConfiguration')} -
- { - form.setValue('retriever_config', values); - }} - /> -
- )} -
-
- -
- ); -} diff --git a/web/src/app/home/knowledge/components/kb-card/KBCard.module.css b/web/src/app/home/knowledge/components/kb-card/KBCard.module.css index df5c9cf9..aaba9f6a 100644 --- a/web/src/app/home/knowledge/components/kb-card/KBCard.module.css +++ b/web/src/app/home/knowledge/components/kb-card/KBCard.module.css @@ -169,3 +169,18 @@ width: 1.2rem; height: 1.2rem; } + +.engineBadge { + font-size: 0.75rem; + line-height: 1rem; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + background-color: #f3e8ff; + color: #7e22ce; + white-space: nowrap; +} + +:global(.dark) .engineBadge { + background-color: #581c87; + color: #d8b4fe; +} diff --git a/web/src/app/home/knowledge/components/kb-card/KBCard.tsx b/web/src/app/home/knowledge/components/kb-card/KBCard.tsx index 8e4de356..4e29af46 100644 --- a/web/src/app/home/knowledge/components/kb-card/KBCard.tsx +++ b/web/src/app/home/knowledge/components/kb-card/KBCard.tsx @@ -4,14 +4,21 @@ import styles from './KBCard.module.css'; export default function KBCard({ kbCardVO }: { kbCardVO: KnowledgeBaseVO }) { const { t } = useTranslation(); + return (
{kbCardVO.emoji || '📚'}
-
- {kbCardVO.name} +
+
+ {kbCardVO.name} +
+ {/* Engine badge */} + + {kbCardVO.getEngineName()} +
{kbCardVO.description} diff --git a/web/src/app/home/knowledge/components/kb-card/KBCardVO.ts b/web/src/app/home/knowledge/components/kb-card/KBCardVO.ts index e7c20ed9..ea4b9164 100644 --- a/web/src/app/home/knowledge/components/kb-card/KBCardVO.ts +++ b/web/src/app/home/knowledge/components/kb-card/KBCardVO.ts @@ -1,29 +1,52 @@ +import { KnowledgeEngineInfo } from '@/app/infra/entities/api'; +import { extractI18nObject } from '@/i18n/I18nProvider'; + export interface IKnowledgeBaseVO { id: string; name: string; description: string; - embeddingModelUUID: string; - top_k: number; lastUpdatedTimeAgo: string; emoji?: string; + ragEngine?: KnowledgeEngineInfo; + ragEnginePluginId?: string; } export class KnowledgeBaseVO implements IKnowledgeBaseVO { id: string; name: string; description: string; - embeddingModelUUID: string; - top_k: number; lastUpdatedTimeAgo: string; emoji?: string; + ragEngine?: KnowledgeEngineInfo; + ragEnginePluginId?: string; constructor(props: IKnowledgeBaseVO) { this.id = props.id; this.name = props.name; this.description = props.description; - this.embeddingModelUUID = props.embeddingModelUUID; - this.top_k = props.top_k; this.lastUpdatedTimeAgo = props.lastUpdatedTimeAgo; this.emoji = props.emoji; + this.ragEngine = props.ragEngine; + this.ragEnginePluginId = props.ragEnginePluginId; + } + + /** + * Check if this KB supports document management + */ + hasDocumentCapability(): boolean { + if (!this.ragEngine) { + return false; + } + return this.ragEngine.capabilities.includes('doc_ingestion'); + } + + /** + * Get display name for the Knowledge Engine + */ + getEngineName(): string { + if (!this.ragEngine) { + return 'Unknown'; + } + return extractI18nObject(this.ragEngine.name); } } diff --git a/web/src/app/home/knowledge/components/kb-docs/FileUploadZone.tsx b/web/src/app/home/knowledge/components/kb-docs/FileUploadZone.tsx index a4c9d61b..4dbbff17 100644 --- a/web/src/app/home/knowledge/components/kb-docs/FileUploadZone.tsx +++ b/web/src/app/home/knowledge/components/kb-docs/FileUploadZone.tsx @@ -1,17 +1,32 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Card, CardContent } from '@/components/ui/card'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; import { httpClient } from '@/app/infra/http/HttpClient'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; +import { ParserInfo } from '@/app/infra/entities/api'; +import { I18nObject } from '@/app/infra/entities/common'; +import { extractI18nObject } from '@/i18n/I18nProvider'; interface FileUploadZoneProps { kbId: string; + ragEngineName?: I18nObject; + ragEngineCapabilities?: string[]; onUploadSuccess: () => void; onUploadError: (error: string) => void; } export default function FileUploadZone({ kbId, + ragEngineName, + ragEngineCapabilities, onUploadSuccess, onUploadError, }: FileUploadZoneProps) { @@ -19,17 +34,49 @@ export default function FileUploadZone({ const [isDragOver, setIsDragOver] = useState(false); const [isUploading, setIsUploading] = useState(false); - const handleUpload = useCallback( - async (file: File) => { - if (isUploading) return; + // Parser selection state + const [pendingFile, setPendingFile] = useState(null); + const [availableParsers, setAvailableParsers] = useState([]); + const [selectedParser, setSelectedParser] = useState('builtin'); + const [loadingParsers, setLoadingParsers] = useState(false); - // Check file size (10MB limit) - const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - if (file.size > MAX_FILE_SIZE) { - toast.error(t('knowledge.documentsTab.fileSizeExceeded')); - return; - } + // Whether the Knowledge Engine natively supports document parsing. + // This is a coarse-grained capability check rather than per-MIME-type filtering. + // Fine-grained MIME type declaration (e.g. supported_parse_mime_types on the engine) + // would require changes across the SDK, backend, and frontend prop chain; + // using an engine-level capability flag keeps the change minimal. + const ragEngineCanParse = + ragEngineCapabilities?.includes('doc_parsing') ?? false; + // When a file is selected, check for available parsers + useEffect(() => { + if (!pendingFile) return; + + const mimeType = pendingFile.type || undefined; + setLoadingParsers(true); + httpClient + .listParsers(mimeType) + .then((resp) => { + const parsers = resp.parsers || []; + setAvailableParsers(parsers); + if (ragEngineCanParse) { + setSelectedParser('builtin'); + } else if (parsers.length > 0) { + setSelectedParser(parsers[0].plugin_id); + } else { + setSelectedParser(''); + } + }) + .catch(() => { + setAvailableParsers([]); + }) + .finally(() => { + setLoadingParsers(false); + }); + }, [pendingFile, ragEngineCanParse]); + + const doUpload = useCallback( + async (file: File, parserPluginId?: string) => { setIsUploading(true); const toastId = toast.loading(t('knowledge.documentsTab.uploadingFile')); @@ -37,8 +84,12 @@ export default function FileUploadZone({ // Step 1: Upload file to server const uploadResult = await httpClient.uploadDocumentFile(file); - // Step 2: Associate file with knowledge base - await httpClient.uploadKnowledgeBaseFile(kbId, uploadResult.file_id); + // Step 2: Associate file with knowledge base (with optional parser) + await httpClient.uploadKnowledgeBaseFile( + kbId, + uploadResult.file_id, + parserPluginId, + ); toast.success(t('knowledge.documentsTab.uploadSuccess'), { id: toastId, @@ -51,11 +102,65 @@ export default function FileUploadZone({ onUploadError(errorMessage); } finally { setIsUploading(false); + setPendingFile(null); + setAvailableParsers([]); + setSelectedParser('builtin'); } }, - [kbId, isUploading, onUploadSuccess, onUploadError, t], + [kbId, onUploadSuccess, onUploadError, t], ); + const handleFileSelected = useCallback( + async (file: File) => { + if (isUploading) return; + + // Check file size (10MB limit) + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + if (file.size > MAX_FILE_SIZE) { + toast.error(t('knowledge.documentsTab.fileSizeExceeded')); + return; + } + + // Set loadingParsers=true BEFORE pendingFile so both state updates + // batch together in the same render. This prevents the auto-upload + // effect from firing before parser fetch completes. + setLoadingParsers(true); + setPendingFile(file); + }, + [isUploading, t], + ); + + // Auto-upload if Knowledge Engine can parse and no external parsers available + useEffect(() => { + if ( + pendingFile && + !loadingParsers && + ragEngineCanParse && + availableParsers.length === 0 + ) { + doUpload(pendingFile); + } + }, [ + pendingFile, + loadingParsers, + ragEngineCanParse, + availableParsers, + doUpload, + ]); + + const handleConfirmUpload = useCallback(() => { + if (!pendingFile) return; + const parserPluginId = + selectedParser === 'builtin' ? undefined : selectedParser; + doUpload(pendingFile, parserPluginId); + }, [pendingFile, selectedParser, doUpload]); + + const handleCancelUpload = useCallback(() => { + setPendingFile(null); + setAvailableParsers([]); + setSelectedParser('builtin'); + }, []); + const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true); @@ -73,79 +178,145 @@ export default function FileUploadZone({ const files = Array.from(e.dataTransfer.files); if (files.length > 0) { - handleUpload(files[0]); + handleFileSelected(files[0]); } }, - [handleUpload], + [handleFileSelected], ); const handleFileSelect = useCallback( (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { - handleUpload(files[0]); + handleFileSelected(files[0]); } + // Reset the input so the same file can be selected again + e.target.value = ''; }, - [handleUpload], + [handleFileSelected], ); + // Show parser selection UI when there are choices to make, or when no parser is available + const showParserSelector = + pendingFile && + !loadingParsers && + (availableParsers.length > 0 || !ragEngineCanParse); + + const noParserAvailable = !ragEngineCanParse && availableParsers.length === 0; + return ( -
- - -
diff --git a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx index 530a0b03..407568bb 100644 --- a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx @@ -8,6 +8,7 @@ import { Download, ExternalLink, Book, + FileText, } from 'lucide-react'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; @@ -41,7 +42,8 @@ export default function PluginMarketCardComponent({ Tool: , EventListener: , Command: , - KnowledgeRetriever: , + KnowledgeEngine: , + Parser: , }; return ( diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index dfa3f591..a516559c 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -70,17 +70,6 @@ export interface LLMModel { extra_args?: object; } -export interface KnowledgeBase { - uuid?: string; - name: string; - description: string; - embedding_model_uuid: string; - created_at?: string; - updated_at?: string; - top_k: number; - emoji?: string; -} - export interface ApiRespProviderEmbeddingModels { models: EmbeddingModel[]; } @@ -166,31 +155,47 @@ export interface KnowledgeBase { uuid?: string; name: string; description: string; - embedding_model_uuid: string; - top_k: number; created_at?: string; updated_at?: string; emoji?: string; + // New unified fields + knowledge_engine_plugin_id?: string; + creation_settings?: Record; + retrieval_settings?: Record; + knowledge_engine?: KnowledgeEngineInfo; } -export interface ExternalKnowledgeBase { - uuid?: string; - name: string; - description: string; - created_at?: string; - plugin_author: string; - plugin_name: string; - retriever_name: string; - retriever_config?: Record; - emoji?: string; +// Knowledge Engine types +export interface KnowledgeEngineInfo { + plugin_id: string | null; + name: I18nObject; + capabilities: string[]; } -export interface ApiRespExternalKnowledgeBases { - bases: ExternalKnowledgeBase[]; +export interface KnowledgeEngine { + plugin_id: string; + name: I18nObject; + description?: I18nObject; + capabilities: string[]; + // Schema format: Array of form field definitions (IDynamicFormItemSchema-like) + // Each item: { name, label, type, required, default, description?, options? } + creation_schema?: unknown[]; + retrieval_schema?: unknown[]; } -export interface ApiRespExternalKnowledgeBase { - base: ExternalKnowledgeBase; +export interface ApiRespKnowledgeEngines { + engines: KnowledgeEngine[]; +} + +export interface ParserInfo { + plugin_id: string; + name: I18nObject; + description?: I18nObject; + supported_mime_types: string[]; +} + +export interface ApiRespParsers { + parsers: ParserInfo[]; } export interface ApiRespKnowledgeBaseFiles { diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index d4880143..b6f0a47f 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -1,5 +1,12 @@ import { I18nObject } from '@/app/infra/entities/common'; +export interface IShowIfCondition { + field: string; + operator: 'eq' | 'neq' | 'in'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; +} + export interface IDynamicFormItemSchema { id: string; default: string | number | boolean | Array; @@ -9,6 +16,7 @@ export interface IDynamicFormItemSchema { type: DynamicFormItemType; description?: I18nObject; options?: IDynamicFormItemOption[]; + show_if?: IShowIfCondition; /** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */ scopes?: string[]; @@ -26,6 +34,7 @@ export enum DynamicFormItemType { FILE_ARRAY = 'array[file]', SELECT = 'select', LLM_MODEL_SELECTOR = 'llm-model-selector', + EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector', PROMPT_EDITOR = 'prompt-editor', UNKNOWN = 'unknown', KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector', diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 908ef0d8..4f9e340f 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -35,12 +35,11 @@ import { ApiRespMCPServers, ApiRespMCPServer, MCPServer, - ExternalKnowledgeBase, - ApiRespExternalKnowledgeBases, - ApiRespExternalKnowledgeBase, ApiRespModelProviders, ApiRespModelProvider, ModelProvider, + ApiRespKnowledgeEngines, + ApiRespParsers, } from '@/app/infra/entities/api'; import { Plugin } from '@/app/infra/entities/plugin'; import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest'; @@ -435,9 +434,11 @@ export class BackendClient extends BaseHttpClient { public uploadKnowledgeBaseFile( uuid: string, file_id: string, + parserPluginId?: string, ): Promise { return this.post(`/api/v1/knowledge/bases/${uuid}/files`, { file_id, + parser_plugin_id: parserPluginId, }); } @@ -461,49 +462,23 @@ export class BackendClient extends BaseHttpClient { public retrieveKnowledgeBase( uuid: string, query: string, + retrievalSettings?: Record, ): Promise { - return this.post(`/api/v1/knowledge/bases/${uuid}/retrieve`, { query }); - } - - // ============ External Knowledge Base API ============ - public getExternalKnowledgeBases(): Promise { - return this.get('/api/v1/knowledge/external-bases'); - } - - public getExternalKnowledgeBase( - uuid: string, - ): Promise { - return this.get(`/api/v1/knowledge/external-bases/${uuid}`); - } - - public createExternalKnowledgeBase( - base: ExternalKnowledgeBase, - ): Promise<{ uuid: string }> { - return this.post('/api/v1/knowledge/external-bases', base); - } - - public updateExternalKnowledgeBase( - uuid: string, - base: ExternalKnowledgeBase, - ): Promise<{ uuid: string }> { - return this.put(`/api/v1/knowledge/external-bases/${uuid}`, base); - } - - public deleteExternalKnowledgeBase(uuid: string): Promise { - return this.delete(`/api/v1/knowledge/external-bases/${uuid}`); - } - - public retrieveExternalKnowledgeBase( - uuid: string, - query: string, - ): Promise { - return this.post(`/api/v1/knowledge/external-bases/${uuid}/retrieve`, { + return this.post(`/api/v1/knowledge/bases/${uuid}/retrieve`, { query, + retrieval_settings: retrievalSettings ?? {}, }); } - public listKnowledgeRetrievers(): Promise<{ retrievers: unknown[] }> { - return this.get('/api/v1/knowledge/external-bases/retrievers'); + // ============ Knowledge Engines API ============ + public getKnowledgeEngines(): Promise { + return this.get('/api/v1/knowledge/engines'); + } + + // ============ Parsers API ============ + public listParsers(mimeType?: string): Promise { + const params = mimeType ? `?mime_type=${encodeURIComponent(mimeType)}` : ''; + return this.get(`/api/v1/knowledge/parsers${params}`); } // ============ Plugins API ============ diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 56a5855b..d9fb1fdd 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -48,6 +48,7 @@ const enUS = { test: 'Test', forgotPassword: 'Forgot Password?', loading: 'Loading...', + fieldRequired: 'This field is required', or: 'or', loginWithSpace: 'Login with Space', spaceLoginRecommended: @@ -371,7 +372,8 @@ const enUS = { Tool: 'Tool', EventListener: 'Event Listener', Command: 'Command', - KnowledgeRetriever: 'Knowledge Retriever', + KnowledgeEngine: 'Knowledge Engine', + Parser: 'Parser', }, uploadLocal: 'Upload Local', debugging: 'Debugging', @@ -726,6 +728,12 @@ const enUS = { processing: 'Processing', completed: 'Completed', failed: 'Failed', + selectParser: 'Select Parser', + builtInParser: 'Provided by Knowledge engine', + noParserAvailable: + 'No parser supports this file type. Please install a parser plugin that can handle this format.', + confirmUpload: 'Upload', + cancelUpload: 'Cancel', }, deleteKnowledgeBaseConfirmation: 'Are you sure you want to delete this knowledge base? All documents in this knowledge base will be deleted.', @@ -738,8 +746,24 @@ const enUS = { fileName: 'File Name', noResults: 'No results', retrieveError: 'Retrieve failed', - builtIn: 'Built-in', - external: 'External', + unknownEngine: 'Unknown Engine', + knowledgeEngine: 'Knowledge Engine', + knowledgeEngineRequired: 'Knowledge engine is required', + selectKnowledgeEngine: 'Select Knowledge Engine', + builtInEngine: 'Built-in Engine', + cannotChangeKnowledgeEngine: + 'Knowledge engine cannot be changed after creation', + engineSettings: 'Engine Settings', + engineSettingsReadonly: 'read-only in edit mode', + retrievalSettings: 'Retrieval Settings', + noEnginesAvailable: 'No knowledge base engines available', + installEngineHint: 'Please install a knowledge base plugin first', + createKnowledgeBaseFailed: 'Failed to create knowledge base', + loadKnowledgeBaseFailed: 'Failed to load knowledge base', + deleteKnowledgeBaseFailed: 'Failed to delete knowledge base', + getKnowledgeBaseListError: 'Failed to get knowledge base list: ', + embeddingModel: 'Embedding Model', + embeddingModelRequired: 'Embedding model is required for this engine', addExternal: 'Add External Knowledge Base', createExternalSuccess: 'External knowledge base created successfully', updateExternalSuccess: 'External knowledge base updated successfully', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 9f076e1a..f9f5929c 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -49,6 +49,7 @@ const jaJP = { test: 'テスト', forgotPassword: 'パスワードを忘れた?', loading: '読み込み中...', + fieldRequired: 'この項目は必須です', or: 'または', loginWithSpace: 'Space でログイン', spaceLoginRecommended: @@ -371,7 +372,8 @@ const jaJP = { Tool: 'ツール', EventListener: 'イベント監視器', Command: 'コマンド', - KnowledgeRetriever: '知識検索', + KnowledgeEngine: '知識エンジン', + Parser: 'パーサー', }, uploadLocal: 'ローカルアップロード', debugging: 'デバッグ中', @@ -729,6 +731,12 @@ const jaJP = { processing: '処理中', completed: '完了', failed: '失敗', + selectParser: 'パーサーを選択', + builtInParser: '知識エンジンが提供', + noParserAvailable: + 'このファイル形式に対応するパーサーがありません。対応するパーサープラグインをインストールしてください。', + confirmUpload: 'アップロード', + cancelUpload: 'キャンセル', }, deleteKnowledgeBaseConfirmation: '本当にこの知識ベースを削除しますか?この知識ベースに紐付けられたドキュメントは削除されます。', @@ -741,8 +749,10 @@ const jaJP = { fileName: 'ファイル名', noResults: '検索結果がありません', retrieveError: '検索に失敗しました', - builtIn: '内蔵', - external: '外部ナレッジベース', + unknownEngine: '不明なエンジン', + loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました', + deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました', + getKnowledgeBaseListError: 'ナレッジベース一覧の取得に失敗しました:', addExternal: '外部ナレッジベースを追加', createExternalSuccess: '外部ナレッジベースが正常に作成されました', updateExternalSuccess: '外部ナレッジベースが正常に更新されました', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 74f0cd08..738da0e4 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -48,6 +48,7 @@ const zhHans = { test: '测试', forgotPassword: '忘记密码?', loading: '加载中...', + fieldRequired: '此字段为必填项', or: '或', loginWithSpace: '通过 Space 登录', spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务', @@ -353,7 +354,8 @@ const zhHans = { Tool: '工具', EventListener: '事件监听器', Command: '命令', - KnowledgeRetriever: '知识检索', + KnowledgeEngine: '知识引擎', + Parser: '解析器', }, uploadLocal: '本地上传', debugging: '调试中', @@ -696,6 +698,12 @@ const zhHans = { processing: '处理中', completed: '完成', failed: '失败', + selectParser: '选择解析器', + builtInParser: '由知识引擎提供', + noParserAvailable: + '没有解析器支持此文件类型,请安装支持该格式的解析器插件。', + confirmUpload: '上传', + cancelUpload: '取消', }, deleteKnowledgeBaseConfirmation: '你确定要删除这个知识库吗?此知识库下的所有文档将被删除。', @@ -708,8 +716,23 @@ const zhHans = { fileName: '文件名', noResults: '暂无结果', retrieveError: '检索失败', - builtIn: '内置', - external: '外部知识库', + unknownEngine: '未知引擎', + knowledgeEngine: '知识引擎', + knowledgeEngineRequired: '知识引擎不能为空', + selectKnowledgeEngine: '选择知识引擎', + builtInEngine: '内置引擎', + cannotChangeKnowledgeEngine: '知识库创建后不可修改知识引擎', + engineSettings: '引擎设置', + engineSettingsReadonly: '编辑模式下不可修改', + retrievalSettings: '检索设置', + noEnginesAvailable: '没有可用的知识库引擎', + installEngineHint: '请先安装知识库插件', + createKnowledgeBaseFailed: '知识库创建失败', + loadKnowledgeBaseFailed: '知识库加载失败', + deleteKnowledgeBaseFailed: '知识库删除失败', + getKnowledgeBaseListError: '获取知识库列表失败:', + embeddingModel: '嵌入模型', + embeddingModelRequired: '此引擎需要选择嵌入模型', addExternal: '添加外部知识库', createExternalSuccess: '外部知识库创建成功', updateExternalSuccess: '外部知识库更新成功', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 1f937264..92079b84 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -48,6 +48,7 @@ const zhHant = { test: '測試', forgotPassword: '忘記密碼?', loading: '載入中...', + fieldRequired: '此欄位為必填', or: '或', loginWithSpace: '透過 Space 登入', spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務', @@ -347,7 +348,8 @@ const zhHant = { Tool: '工具', EventListener: '事件監聽器', Command: '命令', - KnowledgeRetriever: '知識檢索', + KnowledgeEngine: '知識引擎', + Parser: '解析器', }, uploadLocal: '本地上傳', debugging: '調試中', @@ -689,6 +691,12 @@ const zhHant = { processing: '處理中', completed: '完成', failed: '失敗', + selectParser: '選擇解析器', + builtInParser: '由知識引擎提供', + noParserAvailable: + '沒有解析器支援此檔案類型,請安裝支援該格式的解析器插件。', + confirmUpload: '上傳', + cancelUpload: '取消', }, deleteKnowledgeBaseConfirmation: '您確定要刪除這個知識庫嗎?此知識庫下的所有文檔將被刪除。', @@ -701,8 +709,10 @@ const zhHant = { fileName: '文檔名稱', noResults: '暫無結果', retrieveError: '檢索失敗', - builtIn: '內置', - external: '外部知識庫', + unknownEngine: '未知引擎', + loadKnowledgeBaseFailed: '知識庫載入失敗', + deleteKnowledgeBaseFailed: '知識庫刪除失敗', + getKnowledgeBaseListError: '取得知識庫列表失敗:', addExternal: '添加外部知識庫', createExternalSuccess: '外部知識庫創建成功', updateExternalSuccess: '外部知識庫更新成功',