diff --git a/pkg/api/http/controller/groups/pipelines/pipelines.py b/pkg/api/http/controller/groups/pipelines/pipelines.py index e3d08e28..c143b9d6 100644 --- a/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -46,3 +46,28 @@ class PipelinesRouterGroup(group.RouterGroup): await self.ap.pipeline_service.delete_pipeline(pipeline_uuid) return self.success() + + @self.route('//extensions', methods=['GET', 'PUT']) + async def _(pipeline_uuid: str) -> str: + if quart.request.method == 'GET': + # Get current extensions and available plugins + pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) + if pipeline is None: + return self.http_status(404, -1, 'pipeline not found') + + plugins = await self.ap.plugin_connector.list_plugins() + + return self.success( + data={ + 'bound_plugins': pipeline.get('extensions_preferences', {}).get('plugins', []), + 'available_plugins': plugins, + } + ) + elif quart.request.method == 'PUT': + # Update bound plugins for this pipeline + json_data = await quart.request.json + bound_plugins = json_data.get('bound_plugins', []) + + await self.ap.pipeline_service.update_pipeline_extensions(pipeline_uuid, bound_plugins) + + return self.success() diff --git a/pkg/api/http/service/pipeline.py b/pkg/api/http/service/pipeline.py index d3d0bfa7..01a8b29f 100644 --- a/pkg/api/http/service/pipeline.py +++ b/pkg/api/http/service/pipeline.py @@ -136,3 +136,31 @@ class PipelineService: ) ) await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) + + async def update_pipeline_extensions(self, pipeline_uuid: str, bound_plugins: list[dict]) -> None: + """Update the bound plugins for a pipeline""" + # Get current pipeline + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid + ) + ) + + pipeline = result.first() + if pipeline is None: + raise ValueError(f'Pipeline {pipeline_uuid} not found') + + # Update extensions_preferences + extensions_preferences = pipeline.extensions_preferences or {} + extensions_preferences['plugins'] = bound_plugins + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_pipeline.LegacyPipeline) + .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid) + .values(extensions_preferences=extensions_preferences) + ) + + # Reload pipeline to apply changes + await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) + pipeline = await self.get_pipeline(pipeline_uuid) + await self.ap.pipeline_mgr.load_pipeline(pipeline) diff --git a/pkg/command/cmdmgr.py b/pkg/command/cmdmgr.py index 51dda81e..a1d7e009 100644 --- a/pkg/command/cmdmgr.py +++ b/pkg/command/cmdmgr.py @@ -59,14 +59,15 @@ class CommandManager: context: command_context.ExecuteContext, operator_list: list[operator.CommandOperator], operator: operator.CommandOperator = None, + bound_plugins: list[str] | None = None, ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: """执行命令""" - command_list = await self.ap.plugin_connector.list_commands() + command_list = await self.ap.plugin_connector.list_commands(bound_plugins) for command in command_list: if command.metadata.name == context.command: - async for ret in self.ap.plugin_connector.execute_command(context): + async for ret in self.ap.plugin_connector.execute_command(context, bound_plugins): yield ret break else: @@ -102,5 +103,8 @@ class CommandManager: ctx.shift() - async for ret in self._execute(ctx, self.cmd_list): + # Get bound plugins from query + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + + async for ret in self._execute(ctx, self.cmd_list, bound_plugins=bound_plugins): yield ret diff --git a/pkg/entity/persistence/metadata.py b/pkg/entity/persistence/metadata.py index 4db732b9..ac3b4602 100644 --- a/pkg/entity/persistence/metadata.py +++ b/pkg/entity/persistence/metadata.py @@ -1,12 +1,13 @@ import sqlalchemy from .base import Base +from ...utils import constants initial_metadata = [ { 'key': 'database_version', - 'value': '0', + 'value': str(constants.required_database_version), }, ] diff --git a/pkg/entity/persistence/pipeline.py b/pkg/entity/persistence/pipeline.py index 3a21dbf2..8c0093aa 100644 --- a/pkg/entity/persistence/pipeline.py +++ b/pkg/entity/persistence/pipeline.py @@ -22,6 +22,7 @@ class LegacyPipeline(Base): is_default = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) stages = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) + extensions_preferences = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) class PipelineRunRecord(Base): diff --git a/pkg/persistence/mgr.py b/pkg/persistence/mgr.py index bab2cde5..bd292ca7 100644 --- a/pkg/persistence/mgr.py +++ b/pkg/persistence/mgr.py @@ -78,6 +78,8 @@ class PersistenceManager: self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.') + await self.write_default_pipeline() + async def create_tables(self): # create tables async with self.get_db_engine().connect() as conn: @@ -98,6 +100,7 @@ class PersistenceManager: if row is None: await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item)) + async def write_default_pipeline(self): # write default pipeline result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline)) default_pipeline_uuid = None diff --git a/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py b/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py new file mode 100644 index 00000000..927da4bf --- /dev/null +++ b/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py @@ -0,0 +1,20 @@ +import sqlalchemy +from .. import migration + + +@migration.migration_class(9) +class DBMigratePipelineExtensionPreferences(migration.DBMigration): + """Pipeline extension preferences""" + + async def upgrade(self): + """Upgrade""" + + sql_text = sqlalchemy.text( + "ALTER TABLE legacy_pipelines ADD COLUMN extensions_preferences JSON NOT NULL DEFAULT '{}'" + ) + await self.ap.persistence_mgr.execute_async(sql_text) + + async def downgrade(self): + """Downgrade""" + sql_text = sqlalchemy.text('ALTER TABLE legacy_pipelines DROP COLUMN extensions_preferences') + await self.ap.persistence_mgr.execute_async(sql_text) diff --git a/pkg/pipeline/pipelinemgr.py b/pkg/pipeline/pipelinemgr.py index ab663293..37979131 100644 --- a/pkg/pipeline/pipelinemgr.py +++ b/pkg/pipeline/pipelinemgr.py @@ -68,6 +68,9 @@ class RuntimePipeline: stage_containers: list[StageInstContainer] """阶段实例容器""" + + bound_plugins: list[str] + """绑定到此流水线的插件列表(格式:author/plugin_name)""" def __init__( self, @@ -78,9 +81,16 @@ class RuntimePipeline: self.ap = ap self.pipeline_entity = pipeline_entity self.stage_containers = stage_containers + + # Extract bound plugins from extensions_preferences + extensions_prefs = pipeline_entity.extensions_preferences or {} + plugin_list = extensions_prefs.get('plugins', []) + self.bound_plugins = [f"{p['author']}/{p['name']}" for p in plugin_list] if plugin_list else [] async def run(self, query: pipeline_query.Query): query.pipeline_config = self.pipeline_entity.config + # Store bound plugins in query for filtering + query.variables['_pipeline_bound_plugins'] = self.bound_plugins await self.process_query(query) async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult): @@ -188,6 +198,9 @@ class RuntimePipeline: async def process_query(self, query: pipeline_query.Query): """处理请求""" try: + # Get bound plugins for this pipeline + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + # ======== 触发 MessageReceived 事件 ======== event_type = ( events.PersonMessageReceived @@ -203,7 +216,7 @@ class RuntimePipeline: message_chain=query.message_chain, ) - event_ctx = await self.ap.plugin_connector.emit_event(event_obj) + event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins) if event_ctx.is_prevented_default(): return diff --git a/pkg/pipeline/preproc/preproc.py b/pkg/pipeline/preproc/preproc.py index 8e8e1755..8211f692 100644 --- a/pkg/pipeline/preproc/preproc.py +++ b/pkg/pipeline/preproc/preproc.py @@ -65,7 +65,9 @@ class PreProcessor(stage.PipelineStage): query.use_llm_model_uuid = llm_model.model_entity.uuid if llm_model.model_entity.abilities.__contains__('func_call'): - query.use_funcs = await self.ap.tool_mgr.get_all_tools() + # Get bound plugins for filtering tools + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins) variables = { 'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}', @@ -130,7 +132,9 @@ class PreProcessor(stage.PipelineStage): query=query, ) - event_ctx = await self.ap.plugin_connector.emit_event(event) + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) query.prompt.messages = event_ctx.event.default_prompt query.messages = event_ctx.event.prompt diff --git a/pkg/pipeline/process/handlers/chat.py b/pkg/pipeline/process/handlers/chat.py index 6e133cc9..66ab5a01 100644 --- a/pkg/pipeline/process/handlers/chat.py +++ b/pkg/pipeline/process/handlers/chat.py @@ -43,7 +43,9 @@ class ChatMessageHandler(handler.MessageHandler): query=query, ) - event_ctx = await self.ap.plugin_connector.emit_event(event) + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) is_create_card = False # 判断下是否需要创建流式卡片 diff --git a/pkg/pipeline/process/handlers/command.py b/pkg/pipeline/process/handlers/command.py index 52bcdb6f..6d686acd 100644 --- a/pkg/pipeline/process/handlers/command.py +++ b/pkg/pipeline/process/handlers/command.py @@ -45,7 +45,9 @@ class CommandHandler(handler.MessageHandler): query=query, ) - event_ctx = await self.ap.plugin_connector.emit_event(event) + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) if event_ctx.is_prevented_default(): if event_ctx.event.reply_message_chain is not None: diff --git a/pkg/pipeline/wrapper/wrapper.py b/pkg/pipeline/wrapper/wrapper.py index 6267c864..4af5fb00 100644 --- a/pkg/pipeline/wrapper/wrapper.py +++ b/pkg/pipeline/wrapper/wrapper.py @@ -72,7 +72,9 @@ class ResponseWrapper(stage.PipelineStage): query=query, ) - event_ctx = await self.ap.plugin_connector.emit_event(event) + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) if event_ctx.is_prevented_default(): yield entities.StageProcessResult( @@ -115,7 +117,9 @@ class ResponseWrapper(stage.PipelineStage): query=query, ) - event_ctx = await self.ap.plugin_connector.emit_event(event) + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) if event_ctx.is_prevented_default(): yield entities.StageProcessResult( diff --git a/pkg/plugin/connector.py b/pkg/plugin/connector.py index 45aeb8a8..6191adeb 100644 --- a/pkg/plugin/connector.py +++ b/pkg/plugin/connector.py @@ -249,47 +249,62 @@ class PluginRuntimeConnector: 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 - event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=False)) + # 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) -> list[ComponentManifest]: + async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]: if not self.is_enable_plugin: return [] - list_tools_data = await self.handler.list_tools() + # Pass include_plugins to runtime for filtering + list_tools_data = await self.handler.list_tools(include_plugins=bound_plugins) - return [ComponentManifest.model_validate(tool) for tool in list_tools_data] + tools = [ComponentManifest.model_validate(tool) for tool in list_tools_data] - async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]: + return tools + + async def call_tool( + self, tool_name: str, parameters: dict[str, Any], bound_plugins: list[str] | None = None + ) -> dict[str, Any]: if not self.is_enable_plugin: return {'error': 'Tool not found: plugin system is disabled'} - return await self.handler.call_tool(tool_name, parameters) + # Pass include_plugins to runtime for validation + return await self.handler.call_tool(tool_name, parameters, include_plugins=bound_plugins) - async def list_commands(self) -> list[ComponentManifest]: + async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]: if not self.is_enable_plugin: return [] - list_commands_data = await self.handler.list_commands() + # Pass include_plugins to runtime for filtering + list_commands_data = await self.handler.list_commands(include_plugins=bound_plugins) - return [ComponentManifest.model_validate(command) for command in list_commands_data] + commands = [ComponentManifest.model_validate(command) for command in list_commands_data] + + return commands async def execute_command( - self, command_ctx: command_context.ExecuteContext + 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 - gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True)) + # 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) diff --git a/pkg/plugin/handler.py b/pkg/plugin/handler.py index 22d09d20..ef75fac7 100644 --- a/pkg/plugin/handler.py +++ b/pkg/plugin/handler.py @@ -554,23 +554,27 @@ class RuntimeConnectionHandler(handler.Handler): async def emit_event( self, event_context: dict[str, Any], + include_plugins: list[str] | None = None, ) -> dict[str, Any]: """Emit event""" result = await self.call_action( LangBotToRuntimeAction.EMIT_EVENT, { 'event_context': event_context, + 'include_plugins': include_plugins, }, timeout=60, ) return result - async def list_tools(self) -> list[dict[str, Any]]: + async def list_tools(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]: """List tools""" result = await self.call_action( LangBotToRuntimeAction.LIST_TOOLS, - {}, + { + 'include_plugins': include_plugins, + }, timeout=20, ) @@ -615,34 +619,42 @@ class RuntimeConnectionHandler(handler.Handler): .where(persistence_bstorage.BinaryStorage.owner == owner) ) - async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]: + async def call_tool( + self, tool_name: str, parameters: dict[str, Any], include_plugins: list[str] | None = None + ) -> dict[str, Any]: """Call tool""" result = await self.call_action( LangBotToRuntimeAction.CALL_TOOL, { 'tool_name': tool_name, 'tool_parameters': parameters, + 'include_plugins': include_plugins, }, timeout=60, ) return result['tool_response'] - async def list_commands(self) -> list[dict[str, Any]]: + async def list_commands(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]: """List commands""" result = await self.call_action( LangBotToRuntimeAction.LIST_COMMANDS, - {}, + { + 'include_plugins': include_plugins, + }, timeout=10, ) return result['commands'] - async def execute_command(self, command_context: dict[str, Any]) -> typing.AsyncGenerator[dict[str, Any], None]: + async def execute_command( + self, command_context: dict[str, Any], include_plugins: list[str] | None = None + ) -> typing.AsyncGenerator[dict[str, Any], None]: """Execute command""" gen = self.call_action_generator( LangBotToRuntimeAction.EXECUTE_COMMAND, { 'command_context': command_context, + 'include_plugins': include_plugins, }, timeout=60, ) diff --git a/pkg/provider/tools/loader.py b/pkg/provider/tools/loader.py index f3d65fd2..12bb8eb6 100644 --- a/pkg/provider/tools/loader.py +++ b/pkg/provider/tools/loader.py @@ -35,7 +35,7 @@ class ToolLoader(abc.ABC): pass @abc.abstractmethod - async def get_tools(self) -> list[resource_tool.LLMTool]: + async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]: """获取所有工具""" pass diff --git a/pkg/provider/tools/loaders/mcp.py b/pkg/provider/tools/loaders/mcp.py index 99e3021d..65130438 100644 --- a/pkg/provider/tools/loaders/mcp.py +++ b/pkg/provider/tools/loaders/mcp.py @@ -301,7 +301,7 @@ class MCPLoader(loader.ToolLoader): return session - async def get_tools(self) -> list[resource_tool.LLMTool]: + async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]: all_functions = [] for session in self.sessions.values(): diff --git a/pkg/provider/tools/loaders/plugin.py b/pkg/provider/tools/loaders/plugin.py index 5c702d10..bd020626 100644 --- a/pkg/provider/tools/loaders/plugin.py +++ b/pkg/provider/tools/loaders/plugin.py @@ -14,11 +14,11 @@ class PluginToolLoader(loader.ToolLoader): 本加载器中不存储工具信息,仅负责从插件系统中获取工具信息。 """ - async def get_tools(self) -> list[resource_tool.LLMTool]: + async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]: # 从插件系统获取工具(内容函数) all_functions: list[resource_tool.LLMTool] = [] - for tool in await self.ap.plugin_connector.list_tools(): + for tool in await self.ap.plugin_connector.list_tools(bound_plugins): tool_obj = resource_tool.LLMTool( name=tool.metadata.name, human_desc=tool.metadata.description.en_US, diff --git a/pkg/provider/tools/toolmgr.py b/pkg/provider/tools/toolmgr.py index 2e918331..dea1a36a 100644 --- a/pkg/provider/tools/toolmgr.py +++ b/pkg/provider/tools/toolmgr.py @@ -28,12 +28,12 @@ class ToolManager: self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap) await self.mcp_tool_loader.initialize() - async def get_all_tools(self) -> list[resource_tool.LLMTool]: + async def get_all_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]: """获取所有函数""" all_functions: list[resource_tool.LLMTool] = [] - all_functions.extend(await self.plugin_tool_loader.get_tools()) - all_functions.extend(await self.mcp_tool_loader.get_tools()) + all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins)) + all_functions.extend(await self.mcp_tool_loader.get_tools(bound_plugins)) return all_functions diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index de320d0c..ee0fe9c9 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,6 +1,6 @@ semantic_version = 'v4.4.1' -required_database_version = 8 +required_database_version = 9 """Tag the version of the database schema, used to check if the database needs to be migrated""" debug_mode = False diff --git a/pyproject.toml b/pyproject.toml index 65a4418a..307d2821 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ dependencies = [ "langchain-text-splitters>=0.0.1", "chromadb>=0.4.24", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.1.8", + "langbot-plugin==0.1.9b1", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "tboxsdk>=0.0.10", diff --git a/tests/unit_tests/pipeline/test_pipelinemgr.py b/tests/unit_tests/pipeline/test_pipelinemgr.py index b7ba2675..c500c1c1 100644 --- a/tests/unit_tests/pipeline/test_pipelinemgr.py +++ b/tests/unit_tests/pipeline/test_pipelinemgr.py @@ -5,7 +5,6 @@ PipelineManager unit tests import pytest from unittest.mock import AsyncMock, Mock from importlib import import_module -import sqlalchemy def get_pipelinemgr_module(): @@ -54,6 +53,7 @@ async def test_load_pipeline(mock_app): pipeline_entity.uuid = 'test-uuid' pipeline_entity.stages = [] pipeline_entity.config = {'test': 'config'} + pipeline_entity.extensions_preferences = {'plugins': []} await manager.load_pipeline(pipeline_entity) @@ -77,6 +77,7 @@ async def test_get_pipeline_by_uuid(mock_app): pipeline_entity.uuid = 'test-uuid' pipeline_entity.stages = [] pipeline_entity.config = {} + pipeline_entity.extensions_preferences = {'plugins': []} await manager.load_pipeline(pipeline_entity) @@ -106,6 +107,7 @@ async def test_remove_pipeline(mock_app): pipeline_entity.uuid = 'test-uuid' pipeline_entity.stages = [] pipeline_entity.config = {} + pipeline_entity.extensions_preferences = {'plugins': []} await manager.load_pipeline(pipeline_entity) assert len(manager.pipelines) == 1 @@ -134,6 +136,7 @@ async def test_runtime_pipeline_execute(mock_app, sample_query): # Make it look like ResultType.CONTINUE from unittest.mock import MagicMock + CONTINUE = MagicMock() CONTINUE.__eq__ = lambda self, other: True # Always equal for comparison mock_result.result_type = CONTINUE @@ -147,6 +150,7 @@ async def test_runtime_pipeline_execute(mock_app, sample_query): # Create pipeline entity pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) pipeline_entity.config = sample_query.pipeline_config + pipeline_entity.extensions_preferences = {'plugins': []} # Create runtime pipeline runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [stage_container]) diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index 4f53323d..011122e9 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -83,7 +83,7 @@ export default function DynamicFormItemComponent({ setLlmModels(resp.models); }) .catch((err) => { - toast.error('获取 LLM 模型列表失败:' + err.message); + toast.error('Failed to get LLM model list: ' + err.message); }); } }, [config.type]); @@ -96,7 +96,7 @@ export default function DynamicFormItemComponent({ setKnowledgeBases(resp.bases); }) .catch((err) => { - toast.error('获取知识库列表失败:' + err.message); + toast.error('Failed to get knowledge base list: ' + err.message); }); } }, [config.type]); diff --git a/web/src/app/home/pipelines/PipelineDetailDialog.tsx b/web/src/app/home/pipelines/PipelineDetailDialog.tsx index 91b8b192..c12bf768 100644 --- a/web/src/app/home/pipelines/PipelineDetailDialog.tsx +++ b/web/src/app/home/pipelines/PipelineDetailDialog.tsx @@ -18,6 +18,7 @@ import { } from '@/components/ui/sidebar'; import PipelineFormComponent from './components/pipeline-form/PipelineFormComponent'; import DebugDialog from './components/debug-dialog/DebugDialog'; +import PipelineExtension from './components/pipeline-extensions/PipelineExtension'; interface PipelineDialogProps { open: boolean; @@ -31,7 +32,7 @@ interface PipelineDialogProps { onCancel: () => void; } -type DialogMode = 'config' | 'debug'; +type DialogMode = 'config' | 'debug' | 'extensions'; export default function PipelineDialog({ open, @@ -81,6 +82,19 @@ export default function PipelineDialog({ ), }, + { + key: 'extensions', + label: t('pipelines.extensions.title'), + icon: ( + + + + ), + }, { key: 'debug', label: t('pipelines.debugChat'), @@ -102,6 +116,9 @@ export default function PipelineDialog({ ? t('pipelines.editPipeline') : t('pipelines.createPipeline'); } + if (currentMode === 'extensions') { + return t('pipelines.extensions.title'); + } return t('pipelines.debugDialog.title'); }; @@ -193,6 +210,11 @@ export default function PipelineDialog({ }} /> )} + + {currentMode === 'extensions' && pipelineId && ( + + )} + {currentMode === 'debug' && pipelineId && ( ([]); + const [allPlugins, setAllPlugins] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const [tempSelectedIds, setTempSelectedIds] = useState([]); + + useEffect(() => { + loadExtensions(); + }, [pipelineId]); + + const getPluginId = (plugin: Plugin): string => { + const author = plugin.manifest.manifest.metadata.author; + const name = plugin.manifest.manifest.metadata.name; + return `${author}/${name}`; + }; + + const loadExtensions = async () => { + try { + setLoading(true); + const data = await backendClient.getPipelineExtensions(pipelineId); + + const boundPluginIds = new Set( + data.bound_plugins.map((p) => `${p.author}/${p.name}`), + ); + + const selected = data.available_plugins.filter((plugin) => + boundPluginIds.has(getPluginId(plugin)), + ); + + setSelectedPlugins(selected); + setAllPlugins(data.available_plugins); + } catch (error) { + console.error('Failed to load extensions:', error); + toast.error(t('pipelines.extensions.loadError')); + } finally { + setLoading(false); + } + }; + + const saveToBackend = async (plugins: Plugin[]) => { + try { + const boundPluginsArray = plugins.map((plugin) => { + const metadata = plugin.manifest.manifest.metadata; + return { + author: metadata.author || '', + name: metadata.name, + }; + }); + + await backendClient.updatePipelineExtensions( + pipelineId, + boundPluginsArray, + ); + toast.success(t('pipelines.extensions.saveSuccess')); + } catch (error) { + console.error('Failed to save extensions:', error); + toast.error(t('pipelines.extensions.saveError')); + // Reload on error to restore correct state + loadExtensions(); + } + }; + + const handleRemovePlugin = async (pluginId: string) => { + const newPlugins = selectedPlugins.filter( + (p) => getPluginId(p) !== pluginId, + ); + setSelectedPlugins(newPlugins); + await saveToBackend(newPlugins); + }; + + const handleOpenDialog = () => { + setTempSelectedIds(selectedPlugins.map((p) => getPluginId(p))); + setDialogOpen(true); + }; + + const handleTogglePlugin = (pluginId: string) => { + setTempSelectedIds((prev) => + prev.includes(pluginId) + ? prev.filter((id) => id !== pluginId) + : [...prev, pluginId], + ); + }; + + const handleConfirmSelection = async () => { + const newSelected = allPlugins.filter((p) => + tempSelectedIds.includes(getPluginId(p)), + ); + setSelectedPlugins(newSelected); + setDialogOpen(false); + await saveToBackend(newSelected); + }; + + if (loading) { + return ( +
+ + + +
+ ); + } + + return ( +
+
+ {selectedPlugins.length === 0 ? ( +
+

+ {t('pipelines.extensions.noPluginsSelected')} +

+
+ ) : ( +
+ {selectedPlugins.map((plugin) => { + const pluginId = getPluginId(plugin); + const metadata = plugin.manifest.manifest.metadata; + return ( +
+
+ {metadata.name} +
+
{metadata.name}
+
+ {metadata.author} • v{metadata.version} +
+
+ +
+
+ {!plugin.enabled && ( + + {t('pipelines.extensions.disabled')} + + )} +
+ +
+ ); + })} +
+ )} +
+ + + + + + + {t('pipelines.extensions.selectPlugins')} + +
+ {allPlugins.map((plugin) => { + const pluginId = getPluginId(plugin); + const metadata = plugin.manifest.manifest.metadata; + const isSelected = tempSelectedIds.includes(pluginId); + return ( +
handleTogglePlugin(pluginId)} + > + + {metadata.name} +
+
{metadata.name}
+
+ {metadata.author} • v{metadata.version} +
+
+ +
+
+ {!plugin.enabled && ( + + {t('pipelines.extensions.disabled')} + + )} +
+ ); + })} +
+ + + + +
+
+
+ ); +} diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index fde3b6db..2f993939 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -9,6 +9,9 @@ export interface IDynamicFormItemSchema { type: DynamicFormItemType; description?: I18nObject; options?: IDynamicFormItemOption[]; + + /** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */ + scopes?: string[]; accept?: string; // For file type: accepted MIME types } @@ -26,6 +29,7 @@ export enum DynamicFormItemType { PROMPT_EDITOR = 'prompt-editor', UNKNOWN = 'unknown', KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector', + PLUGIN_SELECTOR = 'plugin-selector', } export interface IFileConfig { diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 0921052d..28f9c668 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -37,6 +37,7 @@ import { ApiRespMCPServer, MCPServer, } from '@/app/infra/entities/api'; +import { Plugin } from '@/app/infra/entities/plugin'; import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest'; import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; @@ -169,6 +170,22 @@ export class BackendClient extends BaseHttpClient { return this.delete(`/api/v1/pipelines/${uuid}`); } + public getPipelineExtensions(uuid: string): Promise<{ + bound_plugins: Array<{ author: string; name: string }>; + available_plugins: Plugin[]; + }> { + return this.get(`/api/v1/pipelines/${uuid}/extensions`); + } + + public updatePipelineExtensions( + uuid: string, + bound_plugins: Array<{ author: string; name: string }>, + ): Promise { + return this.put(`/api/v1/pipelines/${uuid}/extensions`, { + bound_plugins, + }); + } + // ============ Debug WebChat API ============ // ============ Debug WebChat API ============ diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index b10c78b5..de6b25e2 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -427,6 +427,17 @@ const enUS = { defaultPipelineCannotDelete: 'Default pipeline cannot be deleted', deleteSuccess: 'Deleted successfully', deleteError: 'Delete failed: ', + extensions: { + title: 'Plugins', + loadError: 'Failed to load plugins', + saveSuccess: 'Saved successfully', + saveError: 'Save failed', + noPluginsAvailable: 'No plugins available', + disabled: 'Disabled', + noPluginsSelected: 'No plugins selected', + addPlugin: 'Add Plugin', + selectPlugins: 'Select Plugins', + }, debugDialog: { title: 'Pipeline Chat', selectPipeline: 'Select Pipeline', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index d7bfbb29..447e6696 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -429,6 +429,17 @@ const jaJP = { defaultPipelineCannotDelete: 'デフォルトパイプラインは削除できません', deleteSuccess: '削除に成功しました', deleteError: '削除に失敗しました:', + extensions: { + title: 'プラグイン統合', + loadError: 'プラグインリストの読み込みに失敗しました', + saveSuccess: '保存に成功しました', + saveError: '保存に失敗しました', + noPluginsAvailable: '利用可能なプラグインがありません', + disabled: '無効', + noPluginsSelected: 'プラグインが選択されていません', + addPlugin: 'プラグインを追加', + selectPlugins: 'プラグインを選択', + }, debugDialog: { title: 'パイプラインのチャット', selectPipeline: 'パイプラインを選択', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 5565437b..93e8d1b4 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -411,6 +411,17 @@ const zhHans = { defaultPipelineCannotDelete: '默认流水线不可删除', deleteSuccess: '删除成功', deleteError: '删除失败:', + extensions: { + title: '插件集成', + loadError: '加载插件列表失败', + saveSuccess: '保存成功', + saveError: '保存失败', + noPluginsAvailable: '暂无可用插件', + disabled: '已禁用', + noPluginsSelected: '未选择任何插件', + addPlugin: '添加插件', + selectPlugins: '选择插件', + }, debugDialog: { title: '流水线对话', selectPipeline: '选择流水线', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 94d3892b..c1b252be 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -409,6 +409,17 @@ const zhHant = { defaultPipelineCannotDelete: '預設流程線不可刪除', deleteSuccess: '刪除成功', deleteError: '刪除失敗:', + extensions: { + title: '插件集成', + loadError: '載入插件清單失敗', + saveSuccess: '儲存成功', + saveError: '儲存失敗', + noPluginsAvailable: '暫無可用插件', + disabled: '已停用', + noPluginsSelected: '未選擇任何插件', + addPlugin: '新增插件', + selectPlugins: '選擇插件', + }, debugDialog: { title: '流程線對話', selectPipeline: '選擇流程線',