diff --git a/docs/PluginPages.md b/docs/PluginPages.md new file mode 100644 index 0000000..d5bdb77 --- /dev/null +++ b/docs/PluginPages.md @@ -0,0 +1,466 @@ +# 插件页面 (Plugin Pages) + +插件可以注册自定义 HTML 页面,嵌入到 LangBot WebUI 的侧边栏中。页面运行在 iframe 内,通过 `postMessage` 与宿主通信,并可调用插件后端 API。 + +## 目录 + +- [注册页面](#注册页面) +- [页面 HTML 开发](#页面-html-开发) +- [JS SDK 参考](#js-sdk-参考) +- [后端 API 处理](#后端-api-处理) +- [通信协议](#通信协议) +- [暗黑模式适配](#暗黑模式适配) +- [i18n 国际化](#i18n-国际化) + +--- + +## 注册页面 + +页面作为组件注册在 `components/pages/` 目录下,与 EventListener、Tool 等组件遵循相同的规范。 + +### manifest.yaml 配置 + +在 `spec.components` 中添加 `Page` 组件类型: + +```yaml +spec: + config: [] + components: + EventListener: + fromDirs: + - path: components/event_listener/ + Page: + fromDirs: + - path: components/pages/ + maxDepth: 2 +``` + +### 页面组件 YAML + +每个页面有自己的 YAML 元数据文件(如 `dashboard.yaml`),格式与其他组件一致: + +```yaml +apiVersion: v1 +kind: Page +metadata: + name: dashboard + label: + en_US: Demo Dashboard + zh_Hans: 演示仪表盘 +spec: + path: index.html +``` + +- `kind` — 固定为 `Page` +- `metadata.name` — 页面唯一 ID,同一插件内不可重复 +- `metadata.label` — 多语言显示名称,显示在 WebUI 侧边栏「插件扩展页」分组中 +- `spec.path` — HTML 入口文件,相对于该 YAML 所在目录的路径 + +### 目录结构 + +``` +MyPlugin/ +├── manifest.yaml +├── main.py +├── README.md +├── assets/ +│ └── icon.svg +├── components/ +│ ├── event_listener/ # 事件监听器组件 +│ │ ├── default.yaml +│ │ └── default.py +│ └── pages/ # 页面组件 +│ ├── dashboard/ # 第一个页面 +│ │ ├── dashboard.yaml # 页面元数据 +│ │ ├── index.html # 页面入口 +│ │ └── i18n/ # 翻译文件 +│ │ ├── en_US.json +│ │ └── zh_Hans.json +│ └── settings/ # 第二个页面 +│ ├── settings.yaml +│ ├── index.html +│ └── i18n/ +│ ├── en_US.json +│ └── zh_Hans.json +├── requirements.txt +└── config/ +``` + +语言代码使用下划线分隔(如 `zh_Hans`、`en_US`、`ja_JP`),与 LangBot 多语言 README 规范一致。 + +当没有任何插件注册页面时,侧边栏的「插件扩展页」分组会自动隐藏。 + +--- + +## 页面 HTML 开发 + +在 HTML 文件中引入 JS SDK: + +```html + + +
+ + + + + +This is the {{ page_label }} page.
+ + + + + + diff --git a/src/langbot_plugin/assets/templates/components/pages/{page_name}.yaml.example b/src/langbot_plugin/assets/templates/components/pages/{page_name}.yaml.example new file mode 100644 index 0000000..b4e5e9c --- /dev/null +++ b/src/langbot_plugin/assets/templates/components/pages/{page_name}.yaml.example @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Page +metadata: + name: {{ page_name }} + label: + en_US: {{ page_label }} + zh_Hans: {{ page_label }} + th_TH: {{ page_label }} + vi_VN: {{ page_label }} + es_ES: {{ page_label }} +spec: + path: {{ page_name }}.html diff --git a/src/langbot_plugin/cli/commands/gencomponent.py b/src/langbot_plugin/cli/commands/gencomponent.py index 69d38e2..c3cb13c 100644 --- a/src/langbot_plugin/cli/commands/gencomponent.py +++ b/src/langbot_plugin/cli/commands/gencomponent.py @@ -38,17 +38,19 @@ def generate_component_process(component_type: str) -> None: if not os.path.exists("components"): os.makedirs("components") - with open("components/__init__.py", "w", encoding="utf-8") as f: - f.write("") + if component_type_obj.type_name != "Page": + with open("components/__init__.py", "w", encoding="utf-8") as f: + f.write("") if not os.path.exists(component_type_obj.target_dir): os.makedirs(component_type_obj.target_dir) - if not os.path.exists(f"{component_type_obj.target_dir}/__init__.py"): - with open( - f"{component_type_obj.target_dir}/__init__.py", "w", encoding="utf-8" - ) as f: - f.write("") + if component_type_obj.type_name != "Page": + if not os.path.exists(f"{component_type_obj.target_dir}/__init__.py"): + with open( + f"{component_type_obj.target_dir}/__init__.py", "w", encoding="utf-8" + ) as f: + f.write("") # render templates for file in component_type_obj.template_files: diff --git a/src/langbot_plugin/cli/commands/runplugin.py b/src/langbot_plugin/cli/commands/runplugin.py index c028417..ee0b451 100644 --- a/src/langbot_plugin/cli/commands/runplugin.py +++ b/src/langbot_plugin/cli/commands/runplugin.py @@ -55,6 +55,24 @@ async def arun_plugin_process( ) component_manifests.extend(manifests) + # Auto-populate spec.pages from discovered Page components + pages = plugin_manifest.spec.get("pages", []) + for cm in component_manifests: + if cm.kind == "Page": + # rel_path is e.g. "components/pages/default.yaml" + # HTML is relative to the YAML dir + yaml_dir = os.path.dirname(cm.rel_path) + html_rel = cm.spec.get("path", "index.html") + page_entry = { + "id": cm.metadata.name, + "label": cm.manifest["metadata"].get("label", {}), + "path": os.path.join(yaml_dir, html_rel), + } + if cm.metadata.icon: + page_entry["icon"] = cm.metadata.icon + pages.append(page_entry) + plugin_manifest.manifest["spec"]["pages"] = pages + controller = PluginRuntimeController( plugin_manifest, component_manifests, diff --git a/src/langbot_plugin/cli/gen/renderer.py b/src/langbot_plugin/cli/gen/renderer.py index e6afec1..3d2a0eb 100644 --- a/src/langbot_plugin/cli/gen/renderer.py +++ b/src/langbot_plugin/cli/gen/renderer.py @@ -133,6 +133,19 @@ def parser_component_input_post_process(values: dict[str, Any]) -> dict[str, Any return result +def page_component_input_post_process(values: dict[str, Any]) -> dict[str, Any]: + result = { + "page_name": values["page_name"], + "page_label": values["page_name"], + } + + python_attr_valid_name = "".join( + word.capitalize() for word in values["page_name"].split("_") + ) + result["page_label"] = python_attr_valid_name + return result + + component_types = [ ComponentType( type_name="EventListener", @@ -339,4 +352,40 @@ def parser_component_input_post_process(values: dict[str, Any]) -> dict[str, Any ], input_post_process=parser_component_input_post_process, ), + ComponentType( + type_name="Page", + target_dir="components/pages", + template_files=[ + "{page_name}.yaml", + "{page_name}.html", + ], + form_fields=[ + { + "name": "page_name", + "label": { + "en_US": "Page name", + "zh_Hans": "页面名称", + "zh_Hant": "頁面名稱", + "ja_JP": "ページ名", + "th_TH": "ชื่อหน้า", + "vi_VN": "Tên trang", + "es_ES": "Nombre de página", + }, + "required": True, + "format": { + "regexp": NUMBER_LOWER_UNDERSCORE_REGEXP, + "error": { + "en_US": "Invalid Page name, please use a valid name, which only contains lowercase letters, numbers, underscores and hyphens, and start with a letter.", + "zh_Hans": "无效的页面名称,请使用一个有效的名称,只能包含小写字母、数字、下划线和连字符,且以字母开头。", + "zh_Hant": "無效的頁面名稱,請使用一個有效的名稱,只能包含小寫字母、數字、下劃線和連字符,且以字母開頭。", + "ja_JP": "無効なページ名です。有効な名前を使用してください。小文字、数字、アンダースコア、ハイフンのみを使用し、先頭は文字でなければなりません。", + "th_TH": "ชื่อหน้าไม่ถูกต้อง กรุณาใช้ชื่อที่ถูกต้อง ซึ่งประกอบด้วยตัวอักษรพิมพ์เล็ก ตัวเลข ขีดล่าง และขีดกลาง และขึ้นต้นด้วยตัวอักษร", + "vi_VN": "Tên trang không hợp lệ, vui lòng sử dụng tên hợp lệ, chỉ chứa chữ thường, số, dấu gạch dưới và dấu gạch ngang, bắt đầu bằng chữ cái.", + "es_ES": "Nombre de página no válido, por favor use un nombre válido que solo contenga letras minúsculas, números, guiones bajos y guiones, comenzando con una letra.", + }, + }, + }, + ], + input_post_process=page_component_input_post_process, + ), ] diff --git a/src/langbot_plugin/cli/run/handler.py b/src/langbot_plugin/cli/run/handler.py index 52c3f1f..c7a433b 100644 --- a/src/langbot_plugin/cli/run/handler.py +++ b/src/langbot_plugin/cli/run/handler.py @@ -104,9 +104,12 @@ async def get_plugin_readme(data: dict[str, typing.Any]) -> ActionResponse: @self.action(RuntimeToPluginAction.GET_PLUGIN_ASSETS_FILE) async def get_plugin_assets_file(data: dict[str, typing.Any]) -> ActionResponse: file_key = data["file_key"] + # Search order: assets/{key}, {key} (direct from plugin root) file_path = os.path.join("assets", file_key) - if not os.path.exists(file_path): - return ActionResponse.success({"file_file_key": "", "mime_type": ""}) + if not os.path.exists(file_path) or os.path.isdir(file_path): + file_path = file_key + if not os.path.exists(file_path) or os.path.isdir(file_path): + return ActionResponse.success({"file_file_key": None, "mime_type": None}) async with aiofiles.open(file_path, "rb") as f: file_bytes = await f.read() @@ -117,6 +120,35 @@ async def get_plugin_assets_file(data: dict[str, typing.Any]) -> ActionResponse: {"file_file_key": file_file_key, "mime_type": mime_type} ) + @self.action(RuntimeToPluginAction.PAGE_API) + async def page_api(data: dict[str, typing.Any]) -> ActionResponse: + """Handle a page API call from the frontend. + + { + "page_id": str, + "endpoint": str, + "method": str, + "body": Any, + } + """ + page_id = data["page_id"] + endpoint = data["endpoint"] + method = data.get("method", "POST") + body = data.get("body") + + plugin_instance = self.plugin_container.plugin_instance + if hasattr(plugin_instance, "handle_page_api"): + result = await plugin_instance.handle_page_api( + page_id=page_id, + endpoint=endpoint, + method=method, + body=body, + ) + return ActionResponse.success({"result": result}) + return ActionResponse.success( + {"result": None, "error": "Plugin does not implement handle_page_api"} + ) + @self.action(RuntimeToPluginAction.EMIT_EVENT) async def emit_event(data: dict[str, typing.Any]) -> ActionResponse: """Emit an event to the plugin. diff --git a/src/langbot_plugin/entities/io/actions/enums.py b/src/langbot_plugin/entities/io/actions/enums.py index 468f8e3..d34c0a6 100644 --- a/src/langbot_plugin/entities/io/actions/enums.py +++ b/src/langbot_plugin/entities/io/actions/enums.py @@ -101,6 +101,8 @@ class RuntimeToPluginAction(ActionType): PARSE_DOCUMENT = "parse_document" + PAGE_API = "page_api" + class LangBotToRuntimeAction(ActionType): """The action from langbot to runtime.""" @@ -139,6 +141,9 @@ class LangBotToRuntimeAction(ActionType): # Debug info GET_DEBUG_INFO = "get_debug_info" + # Page API + PAGE_API = "page_api" + class RuntimeToLangBotAction(ActionType): """The action from runtime to langbot.""" diff --git a/src/langbot_plugin/runtime/io/handlers/control.py b/src/langbot_plugin/runtime/io/handlers/control.py index 8a58ad4..6e4aa4d 100644 --- a/src/langbot_plugin/runtime/io/handlers/control.py +++ b/src/langbot_plugin/runtime/io/handlers/control.py @@ -112,6 +112,22 @@ async def get_plugin_assets_file( {"file_file_key": None, "mime_type": None} ) + @self.action(LangBotToRuntimeAction.PAGE_API) + async def page_api( + data: dict[str, Any], + ) -> handler.ActionResponse: + author = data["plugin_author"] + plugin_name = data["plugin_name"] + result = await self.context.plugin_mgr.handle_page_api( + author, + plugin_name, + data["page_id"], + data["endpoint"], + data["method"], + data.get("body"), + ) + return handler.ActionResponse.success(result) + @self.action(LangBotToRuntimeAction.INSTALL_PLUGIN) async def install_plugin( data: dict[str, Any], diff --git a/src/langbot_plugin/runtime/io/handlers/plugin.py b/src/langbot_plugin/runtime/io/handlers/plugin.py index ac9ad40..ce1f448 100644 --- a/src/langbot_plugin/runtime/io/handlers/plugin.py +++ b/src/langbot_plugin/runtime/io/handlers/plugin.py @@ -591,6 +591,25 @@ async def get_plugin_assets_file(self, file_key: str) -> dict[str, Any]: ) return resp + async def call_page_api( + self, + page_id: str, + endpoint: str, + method: str, + body: Any = None, + ) -> dict[str, Any]: + resp = await self.call_action( + RuntimeToPluginAction.PAGE_API, + { + "page_id": page_id, + "endpoint": endpoint, + "method": method, + "body": body, + }, + timeout=30, + ) + return resp + async def emit_event(self, event_context: dict[str, Any]) -> dict[str, Any]: resp = await self.call_action( RuntimeToPluginAction.EMIT_EVENT, diff --git a/src/langbot_plugin/runtime/plugin/mgr.py b/src/langbot_plugin/runtime/plugin/mgr.py index bf63a15..46feb57 100644 --- a/src/langbot_plugin/runtime/plugin/mgr.py +++ b/src/langbot_plugin/runtime/plugin/mgr.py @@ -658,6 +658,8 @@ async def get_plugin_assets_file( file_key=file_key ) file_file_key = resp["file_file_key"] + if not file_file_key: + return b"", "" file_bytes = await plugin._runtime_plugin_handler.read_local_file( file_file_key ) @@ -665,6 +667,26 @@ async def get_plugin_assets_file( return file_bytes, resp["mime_type"] return b"", "" + async def handle_page_api( + self, + plugin_author: str, + plugin_name: str, + page_id: str, + endpoint: str, + method: str, + body: typing.Any = None, + ) -> dict[str, typing.Any]: + plugin = self.find_plugin(plugin_author, plugin_name) + if plugin is not None: + resp = await plugin._runtime_plugin_handler.call_page_api( + page_id=page_id, + endpoint=endpoint, + method=method, + body=body, + ) + return resp + return {"error": "Plugin not found"} + async def list_tools( self, include_plugins: list[str] | None = None ) -> list[ComponentManifest]: