mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
* [issue:1933] RAG engine plugin architecture (#1967) * refactor: migrate RAG knowledge services to a plugin-oriented host service architecture. * feat(rag): phase 2 core refactor with RPC Action handlers * feat: 为 RAG 插件添加知识库创建和删除事件通知,并优化了 RAG 动作的参数传递和枚举使用。 * feat: 统一知识库管理为RAG引擎,支持动态配置并移除旧的外部知识库组件。 * refactor(rag): remove plugin_adapter, inline logic into RuntimeKnowledgeBase BREAKING CHANGE: RAGPluginAdapter has been removed. All plugin communication is now handled directly by RuntimeKnowledgeBase. Architecture change: - Before: RuntimeKnowledgeBase → RAGPluginAdapter → plugin_connector - After: RuntimeKnowledgeBase → plugin_connector (direct) Changes to kbmgr.py (RuntimeKnowledgeBase): - Remove RAGPluginAdapter import and usage - Inline plugin communication methods: - _on_kb_create(): Notify plugin when KB is created - _on_kb_delete(): Notify plugin when KB is deleted - _ingest_document(): Call plugin for document ingestion - _retrieve(): Call plugin for retrieval - _delete_document(): Call plugin to delete document - Simplify dispose(): Only notify plugin, no built-in VDB assumption Changes to base.py (KnowledgeBaseInterface): - Remove get_type() abstract method (outdated internal/external concept) - Add get_rag_engine_plugin_id() abstract method Changes to localagent.py: - Remove get_type() call - Simplify top_k retrieval from KB entity Deleted files: - pkg/rag/knowledge/plugin_adapter.py Benefits: - Reduced abstraction layer, simpler code - Plugin communication logic centralized in RuntimeKnowledgeBase - Easier to understand and maintain 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(api): remove ExternalKnowledgeBase infrastructure BREAKING CHANGE: ExternalKnowledgeBase has been completely removed. All knowledge bases are now unified under the single KnowledgeBase model, differentiated by their rag_engine_plugin_id. Deleted files: - pkg/api/http/controller/groups/knowledge/external.py (ExternalKBController with /external-bases routes) - pkg/api/http/service/external_kb.py (ExternalKnowledgeBaseService) - pkg/rag/knowledge/external.py (ExternalKnowledgeBase implementation) Modified files: - pkg/entity/persistence/rag.py: Remove ExternalKnowledgeBase SQLAlchemy table definition - pkg/core/app.py: Remove external_kb_service attribute from LangBotApplication - pkg/core/stages/build_app.py: Remove external_kb_service initialization Migration notes: - Existing external knowledge base data should be migrated manually - API consumers should use /api/v1/knowledge/bases for all KB operations - Use /api/v1/knowledge/engines to discover available RAG engines 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(plugin): remove list_knowledge_retrievers from connector Remove deprecated list_knowledge_retrievers functionality from the plugin communication layer. This aligns with the SDK change that removed the LIST_KNOWLEDGE_RETRIEVERS action. Changes: - connector.py: Remove list_knowledge_retrievers() method - handler.py: Remove list_knowledge_retrievers() handler The functionality is replaced by the new /api/v1/knowledge/engines endpoint which lists available RAGEngine components with their capabilities and configuration schemas. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(service): update knowledge service with capability-based checks Replace type-based checks with capability-based checks for file operations, aligning with the unified knowledge base architecture. Changes to knowledge.py: - store_file(): Replace get_type() check with doc_ingestion capability check - delete_file(): Replace get_type() check with doc_ingestion capability check - list_rag_engines(): Remove list_knowledge_retrievers call, simplify to only list RAGEngine components (KnowledgeRetriever type removed) Changes to pipelines.py: - Minor cleanup related to knowledge base references The capability-based approach allows RAG engines to declare their supported features (doc_ingestion, chunking_config, rerank, hybrid_search) and the system responds accordingly, rather than hardcoding behavior based on internal/external type distinction. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(web): unify knowledge base UI, remove external KB components BREAKING CHANGE: The internal/external knowledge base distinction has been removed from the frontend. All knowledge bases are now displayed in a unified list, differentiated by their RAG engine. Changes to page.tsx: - Remove Tab component (内置/外置 tabs) - Remove selectedKbType state - Unified knowledge base list display - Single "Create Knowledge Base" button for all types Changes to KBDetailDialog.tsx: - Remove kbType prop - Simplify dialog logic for unified KB handling - Documents menu item conditionally shown based on doc_ingestion capability Changes to KBForm.tsx: - Remove retriever type handling code - Simplify form for unified KB creation - Dynamic form rendering based on RAG engine's creation_schema Changes to KBCardVO.ts: - Remove 'type' field from KBCardVO interface Changes to BackendClient.ts: - Remove all external KB related methods: - getExternalKnowledgeBases() - getExternalKnowledgeBase() - createExternalKnowledgeBase() - updateExternalKnowledgeBase() - deleteExternalKnowledgeBase() - retrieveFromExternalKnowledgeBase() Changes to api/index.ts: - Remove ExternalKnowledgeBase interface definition UI/UX improvements: - Users no longer need to understand internal vs external distinction - RAG engine selection is now the primary differentiator - Documents panel visibility is capability-driven (doc_ingestion) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(plugin): code review improvements for RAG handlers - Unify embed_model field naming to embedding_model_uuid only - Add structured error responses with error_type for RAG actions - Fix file_size and mime_type detection in _store_file_task - Improve error handling with detailed error context (error_type, original_error) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(rag): refactor KB dynamic form and vector manager - Frontend: Refactor Knowledge Base form using DynamicForm components. - Frontend: Remove obsolete jsonSchemaConverter utility. - Backend: Update VectorManager and PluginHandler to support new RAG architecture. - Chore: Update dependencies in pyproject.toml. * fix: code review fixes for RAG refactor - Remove DEBUG stderr outputs in handler.py - Move repeated `import json` to file top - Add warning log for unimplemented delete_by_filter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(rag): consolidate valid_fields into entity constants Define MUTABLE_FIELDS, CREATE_FIELDS, ALL_DB_FIELDS as class constants in KnowledgeBase entity to eliminate duplication. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: 将知识库获取和RAG引擎信息丰富逻辑移至知识库管理器。 * refactor(rag): introduce RAGRuntimeService and clean up plugin handler - Create RAGRuntimeService to encapsulate RAG capability implementation (Embedding, VectorOps). - Refactor PluginHandler to delegate RAG actions to RAGRuntimeService. - Move KnowledgeService enrichment and creation logic to RAGManager. - Register RAGRuntimeService in Application and BuildAppStage. - Clean up legacy code in KnowledgeService. * refactor(rag): standardize logger and fix type hints - Use self.ap.logger consistently in kbmgr.py and runtime.py, removing module-level loggers. - Fix type hints for retrieve_knowledge in handler.py and connector.py to match implementation returning dict. * refactor: 将引擎徽章的样式从 Tailwind CSS 类迁移到 CSS 模块。 * fix(web): resolve React rendering errors in plugins page - Fix missing key prop in PluginComponentList by using ternary instead of Fragment - Fix RAGEngine.name type to I18nObject and use extractI18nObject() for rendering - Preserves multi-language support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(rag): update runtime service and web components * refactor: 优化知识库设置结构并增强前端距离显示健壮性。 * fix: 处理前端距离显示中的空值。 * fix(rag): document retrieve ui and kbmgr top_k validation * 更新 uv.lock 中的 PyPI 镜像源为官方地址。 * fix: address code review issues for RAG engine plugin architecture P0 fixes: - Fix ALL_DB_FIELDS missing collection_id and emoji fields - Move rag_engine_plugin_id to CREATE_FIELDS (immutable after creation) - Fix creation_settings mutable default value (dict -> None) - Rename vector delete method to delete_by_file_id for correct semantics - Fix delete_by_filter to raise NotImplementedError instead of silent no-op - Add database migration script (dbm019) for new columns and table cleanup P1 fixes: - Clean up design-hesitation comments in connector.py - Add _parse_plugin_id() with format validation for all RAG methods - Make _retrieve() raise exceptions instead of silently returning empty results - Extract _make_rag_error_response() helper for clean error formatting - Remove unused imports from handler.py P2 fixes: - Fix runtime.py indentation inconsistencies - Simplify get_file_stream to use storage abstraction uniformly - Reduce redundant DB queries in knowledge service (extract _check_doc_capability) - Fix engines.py URL encoding: use <path:plugin_id> instead of __ replacement - Add read-only mode for engine settings in KBForm edit mode - Simplify page.tsx handleKBCardClick to pass only kbId string Co-authored-by: Cursor <cursoragent@cursor.com> * fix: address code review findings for RAG plugin architecture - Frontend: add retrieval_settings param to retrieveKnowledgeBase API call - Backend: return {uuid} from PUT knowledge base to match frontend expectation - Backend: validate query is non-empty in retrieve endpoint (400 on empty) - Backend: rename vector_delete ids→file_ids for semantic clarity, keep backward compat by accepting both 'file_ids' and 'ids' in RPC handler - Backend: ensure rag_engine.name fallback is always I18nObject-compatible dict, preventing frontend extractI18nObject from receiving plain strings - Migration: fix misleading docstring about external_kb data migration Co-authored-by: Cursor <cursoragent@cursor.com> * Update langbot-plugin version to 0.2.6 * chore: update required database version from 18 to 19 * refactor: remove unused polymorphic component framework * chore: fix lint and format issues for python and frontend * fix(plugin): remove legacy `ids` fallback in rag_vector_delete handler SDK now sends `file_ids` directly, the `ids` backward-compat fallback is no longer needed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): deep review fixes for critical bugs, security and quality Critical: - Fix StorageMgr.load() -> storage_provider.load() (C1, AttributeError) - Update required_database_version 18 -> 19 (C2, migration never runs) Security: - Add path traversal validation in get_file_stream (C11) - Add vectors/ids/metadata length validation in rag_vector_upsert (C12) Logic fixes: - Legacy KBs: set capabilities to [] instead of ['doc_ingestion'] (C4) - Fix store_file return type int -> str (C5) - Fix retrieve_knowledge return [] -> {'results': []} when disabled (C6) - Re-raise exception in _on_kb_create instead of silently swallowing (C7) - Log warning when KB not found in memory during delete (C8) API fixes: - Catch ValueError as 400 in create_knowledge_base endpoint (C15) - Validate plugin_id format in engines endpoints (C16) Quality: - Remove dead if/else in migration with identical branches (C17) - Fix variable shadowing: rag_context -> rag_context_text (C18) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove unused os import to fix ruff lint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(plugin): remove PolymorphicComponent sync from LangBot side Remove sync_polymorphic_component_instances() from connector and handler, and the post-connection sync call in initialize(). This dead code synced an always-empty list of polymorphic instances that were never created. Companion change to langbot-plugin-sdk PolymorphicComponent removal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): fix vector_delete count bug and remove vestigial instance_id parameter 1. vector_delete: assign return value from delete_by_filter to count instead of silently returning 0 for filter-based deletion. 2. Remove instance_id parameter from the entire retrieve_knowledge call chain (kbmgr → connector → handler → runtime). This parameter was a remnant of the PolymorphicComponent mechanism and is no longer used — RAGEngine operates as a stateless singleton. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(web): 支持 creation_schema 字段级别的 editable 属性控制编辑模式可修改性 - IDynamicFormItemSchema 添加 editable 可选属性 - DynamicFormItemConfig 透传 editable 属性 - DynamicFormComponent 接收 isEditing prop,按字段 editable 值控制禁用 - KBForm 解析 editable 并传递 isEditing 给动态表单组件 - editable 未指定时默认可编辑,editable: false 时编辑模式下禁用该字段 * feat(storage): 添加 size() 抽象方法及 LocalStorage/S3 实现 支持获取存储对象大小,S3 使用 head_object 避免下载整个文件 * fix(migration): 删除 external_knowledge_bases 表前记录日志警告 - 迁移时如果表中存在数据,先 warning 日志记录避免无感数据丢失 - 添加 chunk 清理注释说明:仅对旧版非插件架构 KB 有效 * fix(web): 修复检索结果长文本撑大容器导致查询按钮不可见 KBDetailDialog 的 main 容器添加 min-w-0 overflow-x-hidden, 限制 flex-1 子容器宽度,防止 Dify RAG 长文本撑出 Dialog 边界 * fix(rag): address code review issues for plugin architecture PR - Fix SQL injection in migration helpers by using bind parameters - Move numpy import to module level in vector/mgr.py - Improve path traversal validation using posixpath.normpath - Add call_rag_retrieve to connector, eliminating duplicate plugin_id parsing in kbmgr.py _retrieve - Normalize typing style to modern dict/list/None syntax Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(web): fix prettier formatting errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(rag): update embedding handling in RuntimeConnectionHandler - Renamed RAG_EMBED_DOCUMENTS and RAG_EMBED_QUERY actions to INVOKE_EMBEDDING for clarity. - Removed embed_documents and embed_query methods from RuntimeEmbeddingModel and RAGRuntimeService. - Integrated embedding model retrieval directly in the invoke_embedding method, improving error handling for missing models. - Updated the embedding invocation logic to streamline the process and enhance error reporting. * refactor(web): replace KnowledgeRetriever with RAGEngine across frontend and tests KnowledgeRetriever component type has been removed in favor of the new RAGEngine architecture. Update all remaining references in i18n locales, plugin component icon mappings, marketplace filter, and unit tests. Addresses reviewer notes from RockChinQ on PR #1967. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): address critical bugs found in deep review - Fix path traversal bypass in runtime.py (check all path components for '..') - Use normalized path for file loading instead of raw user input - Change knowledge_bases from list to dict for O(1) lookup and race safety - Add rollback on KB creation failure (clean up DB + runtime on plugin error) - Add null check after KB update in knowledge service - Fix file extension parsing to use os.path.splitext instead of split('.') (handles multi-dot filenames like 'report.v2.pdf' correctly) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): address remaining review issues across frontend and backend Frontend: - Fix KB delete: use async/await with error handling instead of fire-and-forget - Fix capabilities null check: add optional chaining to prevent crash - Add toast.error on KB info load failure instead of silent console.error - Replace hard-coded Chinese validation message with i18n key - Replace hard-coded English error messages in DynamicFormItemComponent with i18n - Optimize document polling: stop when all documents reach terminal state - Add i18n keys (fieldRequired, loadKnowledgeBaseFailed, deleteKnowledgeBaseFailed, getKnowledgeBaseListError) to all 4 locales Backend: - Fix KB delete atomicity: delete from DB first, then notify plugin - Add RAG engine plugin existence validation before creating KB Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(rag): fix ruff formatting in kbmgr.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Junyan Qin <rockchinq@gmail.com> * chore: bump langbot-plugin to 0.3.0 (#1992) * chore: correct sdk version to 0.3.0a1 * feat: normalize rag related actions' names * refactor(rag): align IngestionContext fields with SDK changes Remove redundant `chunking_strategy` field and rename `custom_settings` to `creation_settings` to match the updated SDK entity definitions (langbot-plugin-sdk#36). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: fix ruff formatting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): enforce immutability of embedding_model_uuid and non-editable creation_settings fields Remove embedding_model_uuid from MUTABLE_FIELDS to prevent post-creation modification via API. Add backend validation for creation_settings to preserve fields marked editable:false in the plugin's creation schema. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(rag): fix ruff formatting in knowledge service Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(rag): split settings into immutable creation_settings and mutable retrieval_settings - Remove standalone embedding_model_uuid and top_k columns from KB entity - Add retrieval_settings column; update MUTABLE_FIELDS/CREATE_FIELDS accordingly - Merge migration logic into dbm019 (add retrieval_settings, migrate top_k and embedding_model_uuid into JSON settings, drop old columns on PostgreSQL) - Remove _filter_creation_settings and per-field editable concept - Frontend: creation_settings fields are all disabled when editing, retrieval_settings fields are always editable via a second DynamicFormComponent - Remove editable from IDynamicFormItemSchema, DynamicFormItemConfig - Clean up KBCardVO, KnowledgeBase API type, and localagent runner Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * bugfix: if ingest_document failed,not raise exep * fix: ruff lint * refactor(rag): remove unused _get_kb_entity method from RAGRuntimeService * feat(vector): implement metadata filters for vector_search and vector_delete (#1997) Add functional metadata filter support across all 5 VDB backends using Chroma-style where syntax as the canonical format. Previously the filters parameter existed throughout the stack but was entirely ignored. - Add filter_utils.py with normalize_filter() and strip_unsupported_fields() - Implement filter in search() and add delete_by_filter() for all backends: Chroma/SeekDB (native passthrough), Qdrant (translated to models.Filter), Milvus (translated to expr string), pgvector (translated to SQLAlchemy conditions) - Milvus/pgvector limited to {text, file_id, chunk_uuid}; other fields logged and ignored - Replace delete_by_filter() NotImplementedError with backend delegation in mgr.py - Populate retrieval_context['filters'] from settings in kbmgr._retrieve() - Pass search_type/query_text/documents through handler and runtime service Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(vector): fix ruff formatting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(vector): remove numpy dependency and fix SeekDB search modes - Remove numpy array conversion for query vectors; all VDB backends accept list[float] directly - Remove redundant get_or_create_collection call from upsert; backends handle collection creation internally in add_embeddings - Fix SeekDB to raise ValueError when vector dimension is unknown instead of defaulting to 384 - Use hybrid_search() for full-text and hybrid search modes in SeekDB, since pyseekdb's query() always requires embeddings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(vector): escape single quotes in SeekDB documents and metadata Document text containing apostrophes (e.g. "don't", "it's") causes SQL syntax errors in OceanBase because single quotes were not in the escape table. Add single-quote escaping and apply the escape table to the documents parameter in add_embeddings(), not just metadata. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(vector): use standard SQL escaping for single quotes in SeekDB Change single quote escaping from MySQL-style \' to standard SQL '' (doubled quote). The backslash escape is not recognized by OceanBase in NO_BACKSLASH_ESCAPES mode, causing SQL syntax errors when metadata text contains apostrophes (e.g. O'Shea in academic citations). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): persist retrieval_settings on knowledge base creation retrieval_settings was not being passed from the service layer to RAGManager.create_knowledge_base(), causing retrieval schema fields (e.g. query_rewrite) to be lost on initial KB creation. They only took effect after a subsequent edit/update. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(web): add show_if conditional rendering for dynamic forms Support conditional field visibility in plugin-defined forms via show_if rules (eq, neq, in operators). Fields can depend on values from the same form or cross-reference between creation and retrieval settings via externalDependentValues. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(rag): replace base64 with chunked file transfer for get_rag_file_stream Use send_file() instead of base64 encoding for returning file content in the GET_RAG_FILE_STREAM handler, avoiding memory issues with large files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(parser): add parser plugin integration and capability-aware upload UI (#2000) * feat(parser): add parser plugin integration and capability-aware upload UI Backend: add parser plugin API endpoints (list/invoke), connector and handler support for parser actions, and KB manager passthrough. Frontend: thread ragEngineCapabilities prop to FileUploadZone and use doc_parsing capability to conditionally show the RAG engine option in the parser selector. When no parser is available, show a warning prompting users to install a parser plugin. Update i18n: rename builtInParser to "Provided by RAG engine" and add noParserAvailable warning message in all 4 locales. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(parser): replace base64 with chunked file transfer and remove stale cache - Remove @alru_cache from list_parsers() and list_rag_engines() - Replace inline base64 file content with send_file/read_local_file chunked transfer pattern in parse_document and invoke_parser flows - Remove unused base64 import from kbmgr.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * feat(web): add Parser component kind to plugin market UI and i18n Add Parser to kindIconMap, market filter toggle, and all 4 locale files so parser plugins are properly displayed and filterable in the plugin market, matching the existing RAGEngine treatment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(web): fix prettier formatting from merge Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: rename RAGEngine to KnowledgeEngine across frontend and backend * fix(web): fix I18nObject import path in FileUploadZone and KBDoc * chore: format files involved in RAGEngine to KnowledgeEngine refactor * refactor: change rag engine to knowledge engine * fix: update langbot-plugin version to 0.3.0rc1 * chore: disable migration 20 for now --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Junyan Qin <rockchinq@gmail.com>
565 lines
24 KiB
Python
565 lines
24 KiB
Python
# For connect to plugin runtime.
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
import typing
|
|
import os
|
|
import sys
|
|
import httpx
|
|
import sqlalchemy
|
|
from async_lru import alru_cache
|
|
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
|
|
|
from ..core import app
|
|
from . import handler
|
|
from ..utils import platform
|
|
from langbot_plugin.runtime.io.controllers.stdio import (
|
|
client as stdio_client_controller,
|
|
)
|
|
from langbot_plugin.runtime.io.controllers.ws import client as ws_client_controller
|
|
from langbot_plugin.api.entities import events
|
|
from langbot_plugin.api.entities import context
|
|
import langbot_plugin.runtime.io.connection as base_connection
|
|
from langbot_plugin.api.definition.components.manifest import ComponentManifest
|
|
from langbot_plugin.api.entities.builtin.command import (
|
|
context as command_context,
|
|
errors as command_errors,
|
|
)
|
|
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
|
from ..core import taskmgr
|
|
from ..entity.persistence import plugin as persistence_plugin
|
|
|
|
|
|
class PluginRuntimeConnector:
|
|
"""Plugin runtime connector"""
|
|
|
|
ap: app.Application
|
|
|
|
handler: handler.RuntimeConnectionHandler
|
|
|
|
handler_task: asyncio.Task
|
|
|
|
heartbeat_task: asyncio.Task | None = None
|
|
|
|
stdio_client_controller: stdio_client_controller.StdioClientController
|
|
|
|
ctrl: stdio_client_controller.StdioClientController | ws_client_controller.WebSocketClientController
|
|
|
|
runtime_subprocess_on_windows: asyncio.subprocess.Process | None = None
|
|
|
|
runtime_subprocess_on_windows_task: asyncio.Task | None = None
|
|
|
|
runtime_disconnect_callback: typing.Callable[
|
|
[PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]
|
|
]
|
|
|
|
is_enable_plugin: bool = True
|
|
"""Mark if the plugin system is enabled"""
|
|
|
|
def __init__(
|
|
self,
|
|
ap: app.Application,
|
|
runtime_disconnect_callback: typing.Callable[
|
|
[PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]
|
|
],
|
|
):
|
|
self.ap = ap
|
|
self.runtime_disconnect_callback = runtime_disconnect_callback
|
|
self.is_enable_plugin = self.ap.instance_config.data.get('plugin', {}).get('enable', True)
|
|
|
|
async def heartbeat_loop(self):
|
|
while True:
|
|
await asyncio.sleep(20)
|
|
try:
|
|
await self.ping_plugin_runtime()
|
|
self.ap.logger.debug('Heartbeat to plugin runtime success.')
|
|
except Exception as e:
|
|
self.ap.logger.debug(f'Failed to heartbeat to plugin runtime: {e}')
|
|
|
|
async def initialize(self):
|
|
if not self.is_enable_plugin:
|
|
self.ap.logger.info('Plugin system is disabled.')
|
|
return
|
|
|
|
async def new_connection_callback(connection: base_connection.Connection):
|
|
async def disconnect_callback(
|
|
rchandler: handler.RuntimeConnectionHandler,
|
|
) -> bool:
|
|
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
|
|
self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...')
|
|
await self.runtime_disconnect_callback(self)
|
|
return False
|
|
else:
|
|
self.ap.logger.error(
|
|
'Disconnected from plugin runtime, cannot automatically reconnect while LangBot connects to plugin runtime via stdio, please restart LangBot.'
|
|
)
|
|
return False
|
|
|
|
self.handler = handler.RuntimeConnectionHandler(connection, disconnect_callback, self.ap)
|
|
|
|
self.handler_task = asyncio.create_task(self.handler.run())
|
|
_ = await self.handler.ping()
|
|
self.ap.logger.info('Connected to plugin runtime.')
|
|
await self.handler_task
|
|
|
|
task: asyncio.Task | None = None
|
|
|
|
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime(): # use websocket
|
|
self.ap.logger.info('use websocket to connect to plugin runtime')
|
|
ws_url = self.ap.instance_config.data.get('plugin', {}).get(
|
|
'runtime_ws_url', 'ws://langbot_plugin_runtime:5400/control/ws'
|
|
)
|
|
|
|
async def make_connection_failed_callback(
|
|
ctrl: ws_client_controller.WebSocketClientController,
|
|
exc: Exception = None,
|
|
) -> None:
|
|
if exc is not None:
|
|
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}')
|
|
else:
|
|
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}), trying to reconnect...')
|
|
await self.runtime_disconnect_callback(self)
|
|
|
|
self.ctrl = ws_client_controller.WebSocketClientController(
|
|
ws_url=ws_url,
|
|
make_connection_failed_callback=make_connection_failed_callback,
|
|
)
|
|
task = self.ctrl.run(new_connection_callback)
|
|
elif platform.get_platform() == 'win32':
|
|
# Due to Windows's lack of supports for both stdio and subprocess:
|
|
# See also: https://docs.python.org/zh-cn/3.13/library/asyncio-platforms.html
|
|
# We have to launch runtime via cmd but communicate via ws.
|
|
self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
|
|
|
|
if self.runtime_subprocess_on_windows is None: # only launch once
|
|
python_path = sys.executable
|
|
env = os.environ.copy()
|
|
self.runtime_subprocess_on_windows = await asyncio.create_subprocess_exec(
|
|
python_path,
|
|
'-m',
|
|
'langbot_plugin.cli.__init__',
|
|
'rt',
|
|
env=env,
|
|
)
|
|
|
|
# hold the process
|
|
self.runtime_subprocess_on_windows_task = asyncio.create_task(self.runtime_subprocess_on_windows.wait())
|
|
|
|
ws_url = 'ws://localhost:5400/control/ws'
|
|
|
|
async def make_connection_failed_callback(
|
|
ctrl: ws_client_controller.WebSocketClientController,
|
|
exc: Exception = None,
|
|
) -> None:
|
|
if exc is not None:
|
|
self.ap.logger.error(f'(windows) Failed to connect to plugin runtime({ws_url}): {exc}')
|
|
else:
|
|
self.ap.logger.error(
|
|
f'(windows) Failed to connect to plugin runtime({ws_url}), trying to reconnect...'
|
|
)
|
|
await self.runtime_disconnect_callback(self)
|
|
|
|
self.ctrl = ws_client_controller.WebSocketClientController(
|
|
ws_url=ws_url,
|
|
make_connection_failed_callback=make_connection_failed_callback,
|
|
)
|
|
task = self.ctrl.run(new_connection_callback)
|
|
|
|
else: # stdio
|
|
self.ap.logger.info('use stdio to connect to plugin runtime')
|
|
# cmd: lbp rt -s
|
|
python_path = sys.executable
|
|
env = os.environ.copy()
|
|
self.ctrl = stdio_client_controller.StdioClientController(
|
|
command=python_path,
|
|
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
|
|
env=env,
|
|
)
|
|
task = self.ctrl.run(new_connection_callback)
|
|
|
|
if self.heartbeat_task is None:
|
|
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
|
|
|
|
asyncio.create_task(task)
|
|
|
|
async def initialize_plugins(self):
|
|
pass
|
|
|
|
async def ping_plugin_runtime(self):
|
|
if not hasattr(self, 'handler'):
|
|
raise Exception('Plugin runtime is not connected')
|
|
|
|
return await self.handler.ping()
|
|
|
|
async def install_plugin(
|
|
self,
|
|
install_source: PluginInstallSource,
|
|
install_info: dict[str, Any],
|
|
task_context: taskmgr.TaskContext | None = None,
|
|
):
|
|
if install_source == PluginInstallSource.LOCAL:
|
|
# transfer file before install
|
|
file_bytes = install_info['plugin_file']
|
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
|
install_info['plugin_file_key'] = file_key
|
|
del install_info['plugin_file']
|
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
|
elif install_source == PluginInstallSource.GITHUB:
|
|
# download and transfer file
|
|
try:
|
|
async with httpx.AsyncClient(
|
|
trust_env=True,
|
|
follow_redirects=True,
|
|
timeout=20,
|
|
) as client:
|
|
response = await client.get(
|
|
install_info['asset_url'],
|
|
)
|
|
response.raise_for_status()
|
|
file_bytes = response.content
|
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
|
install_info['plugin_file_key'] = file_key
|
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
|
except Exception as e:
|
|
self.ap.logger.error(f'Failed to download file from GitHub: {e}')
|
|
raise Exception(f'Failed to download file from GitHub: {e}')
|
|
|
|
async for ret in self.handler.install_plugin(install_source.value, install_info):
|
|
current_action = ret.get('current_action', None)
|
|
if current_action is not None:
|
|
if task_context is not None:
|
|
task_context.set_current_action(current_action)
|
|
|
|
trace = ret.get('trace', None)
|
|
if trace is not None:
|
|
if task_context is not None:
|
|
task_context.trace(trace)
|
|
|
|
async def upgrade_plugin(
|
|
self,
|
|
plugin_author: str,
|
|
plugin_name: str,
|
|
task_context: taskmgr.TaskContext | None = None,
|
|
) -> dict[str, Any]:
|
|
async for ret in self.handler.upgrade_plugin(plugin_author, plugin_name):
|
|
current_action = ret.get('current_action', None)
|
|
if current_action is not None:
|
|
if task_context is not None:
|
|
task_context.set_current_action(current_action)
|
|
|
|
trace = ret.get('trace', None)
|
|
if trace is not None:
|
|
if task_context is not None:
|
|
task_context.trace(trace)
|
|
|
|
async def delete_plugin(
|
|
self,
|
|
plugin_author: str,
|
|
plugin_name: str,
|
|
delete_data: bool = False,
|
|
task_context: taskmgr.TaskContext | None = None,
|
|
) -> dict[str, Any]:
|
|
async for ret in self.handler.delete_plugin(plugin_author, plugin_name):
|
|
current_action = ret.get('current_action', None)
|
|
if current_action is not None:
|
|
if task_context is not None:
|
|
task_context.set_current_action(current_action)
|
|
|
|
trace = ret.get('trace', None)
|
|
if trace is not None:
|
|
if task_context is not None:
|
|
task_context.trace(trace)
|
|
|
|
# Clean up plugin settings and binary storage if requested
|
|
if delete_data:
|
|
if task_context is not None:
|
|
task_context.trace('Cleaning up plugin configuration and storage...')
|
|
await self.handler.cleanup_plugin_data(plugin_author, plugin_name)
|
|
|
|
async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]:
|
|
"""List plugins, optionally filtered by component kinds.
|
|
|
|
Args:
|
|
component_kinds: Optional list of component kinds to filter by.
|
|
If provided, only plugins that contain at least one
|
|
component of the specified kinds will be returned.
|
|
E.g., ['Command', 'EventListener', 'Tool'] for pipeline-related plugins.
|
|
"""
|
|
if not self.is_enable_plugin:
|
|
return []
|
|
|
|
plugins = await self.handler.list_plugins()
|
|
|
|
# Filter plugins by component kinds if specified
|
|
if component_kinds is not None:
|
|
filtered_plugins = []
|
|
for plugin in plugins:
|
|
components = plugin.get('components', [])
|
|
has_matching_component = False
|
|
for component in components:
|
|
component_kind = component.get('manifest', {}).get('manifest', {}).get('kind', '')
|
|
if component_kind in component_kinds:
|
|
has_matching_component = True
|
|
break
|
|
if has_matching_component:
|
|
filtered_plugins.append(plugin)
|
|
plugins = filtered_plugins
|
|
|
|
# Sort plugins: debug plugins first, then by installation time (newest first)
|
|
# Get installation timestamps from database in a single query
|
|
plugin_timestamps = {}
|
|
|
|
if plugins:
|
|
# Build list of (author, name) tuples for all plugins
|
|
plugin_ids = []
|
|
for plugin in plugins:
|
|
author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '')
|
|
name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '')
|
|
if author and name:
|
|
plugin_ids.append((author, name))
|
|
|
|
# Fetch all timestamps in a single query using OR conditions
|
|
if plugin_ids:
|
|
conditions = [
|
|
sqlalchemy.and_(
|
|
persistence_plugin.PluginSetting.plugin_author == author,
|
|
persistence_plugin.PluginSetting.plugin_name == name,
|
|
)
|
|
for author, name in plugin_ids
|
|
]
|
|
|
|
result = await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.select(
|
|
persistence_plugin.PluginSetting.plugin_author,
|
|
persistence_plugin.PluginSetting.plugin_name,
|
|
persistence_plugin.PluginSetting.created_at,
|
|
).where(sqlalchemy.or_(*conditions))
|
|
)
|
|
|
|
for row in result:
|
|
plugin_id = f'{row.plugin_author}/{row.plugin_name}'
|
|
plugin_timestamps[plugin_id] = row.created_at
|
|
|
|
# Sort: debug plugins first (descending), then by created_at (descending)
|
|
def sort_key(plugin):
|
|
author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '')
|
|
name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '')
|
|
plugin_id = f'{author}/{name}'
|
|
|
|
is_debug = plugin.get('debug', False)
|
|
created_at = plugin_timestamps.get(plugin_id)
|
|
|
|
# Return tuple: (not is_debug, -timestamp)
|
|
# not is_debug: False (0) for debug plugins, True (1) for non-debug
|
|
# -timestamp: to sort newest first (will be None for plugins without timestamp)
|
|
timestamp_value = -created_at.timestamp() if created_at else 0
|
|
return (not is_debug, timestamp_value)
|
|
|
|
plugins.sort(key=sort_key)
|
|
|
|
return plugins
|
|
|
|
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
|
|
return await self.handler.get_plugin_info(author, plugin_name)
|
|
|
|
async def set_plugin_config(self, plugin_author: str, plugin_name: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
return await self.handler.set_plugin_config(plugin_author, plugin_name, config)
|
|
|
|
@alru_cache(ttl=5 * 60) # 5 minutes
|
|
async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:
|
|
return await self.handler.get_plugin_icon(plugin_author, plugin_name)
|
|
|
|
@alru_cache(ttl=5 * 60) # 5 minutes
|
|
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
|
|
return await self.handler.get_plugin_readme(plugin_author, plugin_name, language)
|
|
|
|
@alru_cache(ttl=5 * 60)
|
|
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
|
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
|
|
|
|
async def get_debug_info(self) -> dict[str, Any]:
|
|
"""Get debug information including debug key and WS URL"""
|
|
if not self.is_enable_plugin:
|
|
return {}
|
|
return await self.handler.get_debug_info()
|
|
|
|
async def emit_event(
|
|
self,
|
|
event: events.BaseEventModel,
|
|
bound_plugins: list[str] | None = None,
|
|
) -> context.EventContext:
|
|
event_ctx = context.EventContext.from_event(event)
|
|
|
|
if not self.is_enable_plugin:
|
|
return event_ctx
|
|
|
|
# Pass include_plugins to runtime for filtering
|
|
event_ctx_result = await self.handler.emit_event(
|
|
event_ctx.model_dump(serialize_as_any=False), include_plugins=bound_plugins
|
|
)
|
|
|
|
event_ctx = context.EventContext.model_validate(event_ctx_result['event_context'])
|
|
|
|
return event_ctx
|
|
|
|
async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
|
|
if not self.is_enable_plugin:
|
|
return []
|
|
|
|
# Pass include_plugins to runtime for filtering
|
|
list_tools_data = await self.handler.list_tools(include_plugins=bound_plugins)
|
|
|
|
tools = [ComponentManifest.model_validate(tool) for tool in list_tools_data]
|
|
|
|
return tools
|
|
|
|
async def call_tool(
|
|
self,
|
|
tool_name: str,
|
|
parameters: dict[str, Any],
|
|
session: provider_session.Session,
|
|
query_id: int,
|
|
bound_plugins: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
if not self.is_enable_plugin:
|
|
return {'error': 'Tool not found: plugin system is disabled'}
|
|
|
|
# Pass include_plugins to runtime for validation
|
|
return await self.handler.call_tool(
|
|
tool_name, parameters, session.model_dump(serialize_as_any=True), query_id, include_plugins=bound_plugins
|
|
)
|
|
|
|
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
|
|
if not self.is_enable_plugin:
|
|
return []
|
|
|
|
# Pass include_plugins to runtime for filtering
|
|
list_commands_data = await self.handler.list_commands(include_plugins=bound_plugins)
|
|
|
|
commands = [ComponentManifest.model_validate(command) for command in list_commands_data]
|
|
|
|
return commands
|
|
|
|
async def execute_command(
|
|
self, command_ctx: command_context.ExecuteContext, bound_plugins: list[str] | None = None
|
|
) -> typing.AsyncGenerator[command_context.CommandReturn, None]:
|
|
if not self.is_enable_plugin:
|
|
yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command))
|
|
return
|
|
|
|
# Pass include_plugins to runtime for validation
|
|
gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True), include_plugins=bound_plugins)
|
|
|
|
async for ret in gen:
|
|
cmd_ret = command_context.CommandReturn.model_validate(ret)
|
|
|
|
yield cmd_ret
|
|
|
|
async def retrieve_knowledge(
|
|
self,
|
|
plugin_author: str,
|
|
plugin_name: str,
|
|
retriever_name: str,
|
|
retrieval_context: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Retrieve knowledge using a KnowledgeEngine instance."""
|
|
if not self.is_enable_plugin:
|
|
return {'results': []}
|
|
|
|
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
|
|
# for Windows can kill processes and subprocesses chainly
|
|
|
|
if self.is_enable_plugin and isinstance(self.ctrl, stdio_client_controller.StdioClientController):
|
|
self.ap.logger.info('Terminating plugin runtime process...')
|
|
self.ctrl.process.terminate()
|
|
|
|
if self.heartbeat_task is not None:
|
|
self.heartbeat_task.cancel()
|
|
self.heartbeat_task = None
|
|
|
|
@staticmethod
|
|
def _parse_plugin_id(plugin_id: str) -> tuple[str, str]:
|
|
"""Parse a plugin ID string into (author, name).
|
|
|
|
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 await self.handler.list_knowledge_engines()
|
|
|
|
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()
|
|
|
|
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)
|