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 + + + + + + + + +

My Page

+

+ + + + + +``` + +> **注意**: SDK 的 ` + * + * + * i18n: Place JSON files in an i18n/ directory next to your HTML: + * pages/dashboard/ + * index.html + * i18n/ + * en_US.json (fallback) + * zh_Hans.json + * + * Elements with data-i18n="key" will be auto-translated. + */ +(function () { + 'use strict'; + + var _theme = 'light'; + var _language = 'en-US'; + var _ready = false; + var _readyCallbacks = []; + var _themeCallbacks = []; + var _pendingRequests = {}; + var _requestIdCounter = 0; + var _translations = {}; + var _i18nLoaded = false; + var _languageCallbacks = []; + var _i18nPromise = null; + + // Apply theme to document + function applyTheme(theme) { + _theme = theme; + var root = document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(theme); + root.setAttribute('data-theme', theme); + + // Set CSS custom properties for common dark/light patterns + if (theme === 'dark') { + root.style.setProperty('--langbot-bg', '#0a0a0a'); + root.style.setProperty('--langbot-bg-card', '#171717'); + root.style.setProperty('--langbot-text', '#fafafa'); + root.style.setProperty('--langbot-text-muted', '#a1a1aa'); + root.style.setProperty('--langbot-border', '#27272a'); + root.style.setProperty('--langbot-accent', '#3b82f6'); + } else { + root.style.setProperty('--langbot-bg', '#ffffff'); + root.style.setProperty('--langbot-bg-card', '#f8fafc'); + root.style.setProperty('--langbot-text', '#0a0a0a'); + root.style.setProperty('--langbot-text-muted', '#71717a'); + root.style.setProperty('--langbot-border', '#e4e4e7'); + root.style.setProperty('--langbot-accent', '#2563eb'); + } + + for (var i = 0; i < _themeCallbacks.length; i++) { + try { _themeCallbacks[i](theme); } catch (e) { console.error(e); } + } + } + + // Convert language code for i18n filenames: "zh-Hans" → "zh_Hans" + function langToFileName(lang) { + return lang.replace(/-/g, '_'); + } + + // Load i18n JSON from ./i18n/{locale}.json relative to the page + function loadI18n(lang) { + var fileName = langToFileName(lang); + return fetch('./i18n/' + fileName + '.json') + .then(function (resp) { + if (resp.ok) return resp.json(); + // Fallback to en_US + if (fileName !== 'en_US') { + return fetch('./i18n/en_US.json').then(function (r) { + return r.ok ? r.json() : {}; + }); + } + return {}; + }) + .then(function (data) { + _translations = data || {}; + _i18nLoaded = true; + applyTranslations(); + return _translations; + }) + .catch(function () { + _translations = {}; + _i18nLoaded = true; + }); + } + + // Allowed attributes for data-i18n-attr (whitelist to prevent XSS) + var SAFE_I18N_ATTRS = ['placeholder', 'title', 'alt', 'aria-label', 'aria-description']; + + // Apply translations to elements with data-i18n attribute + function applyTranslations() { + document.querySelectorAll('[data-i18n]').forEach(function (el) { + var key = el.getAttribute('data-i18n'); + if (_translations[key] != null) { + // Support data-i18n-attr for safe attribute translation (e.g. placeholder) + var attr = el.getAttribute('data-i18n-attr'); + if (attr && SAFE_I18N_ATTRS.indexOf(attr) !== -1) { + el.setAttribute(attr, _translations[key]); + } else { + el.textContent = _translations[key]; + } + } + }); + } + + // Listen for messages from the parent LangBot page + window.addEventListener('message', function (event) { + var data = event.data; + if (!data || typeof data !== 'object') return; + + if (data.type === 'langbot:context') { + var oldTheme = _theme; + var oldLang = _language; + _language = data.language || _language; + + applyTheme(data.theme || 'light'); + + if (!_ready) { + // Load i18n before marking ready and firing callbacks + _i18nPromise = loadI18n(_language).then(function () { + _ready = true; + var ctx = { theme: _theme, language: _language }; + for (var i = 0; i < _readyCallbacks.length; i++) { + try { _readyCallbacks[i](ctx); } catch (e) { console.error(e); } + } + }); + } else { + // Language changed — reload translations + if (oldLang !== _language) { + loadI18n(_language).then(function () { + for (var i = 0; i < _languageCallbacks.length; i++) { + try { _languageCallbacks[i](_language); } catch (e) { console.error(e); } + } + }); + } + } + } + + if (data.type === 'langbot:api:response') { + var requestId = data.requestId; + if (_pendingRequests[requestId]) { + if (data.error) { + _pendingRequests[requestId].reject(new Error(data.error)); + } else { + _pendingRequests[requestId].resolve(data.data); + } + delete _pendingRequests[requestId]; + } + } + }); + + // Public API + window.langbot = { + /** Current theme: 'light' or 'dark' */ + get theme() { return _theme; }, + + /** Current language: e.g. 'zh-Hans', 'en-US' */ + get language() { return _language; }, + + /** Whether the SDK has received initial context and i18n is loaded */ + get ready() { return _ready && _i18nLoaded; }, + + /** + * Register a callback for when context is first received. + * If already ready, fires immediately. If context received but i18n + * still loading, waits for i18n to complete. + * @param {function({theme: string, language: string}): void} callback + */ + onReady: function (callback) { + if (_ready && _i18nLoaded) { + try { callback({ theme: _theme, language: _language }); } catch (e) { console.error(e); } + } else if (_i18nPromise) { + _i18nPromise.then(function () { + try { callback({ theme: _theme, language: _language }); } catch (e) { console.error(e); } + }); + } else { + _readyCallbacks.push(callback); + } + }, + + /** + * Register a callback for theme changes. + * @param {function(string): void} callback + */ + onThemeChange: function (callback) { + _themeCallbacks.push(callback); + }, + + /** + * Register a callback for language changes (after initial load). + * Translations are already reloaded when this fires. + * @param {function(string): void} callback + */ + onLanguageChange: function (callback) { + _languageCallbacks.push(callback); + }, + + /** + * Get a translated string by key. + * Falls back to the provided fallback or the key itself. + * @param {string} key - The translation key + * @param {string} [fallback] - Fallback if key not found + * @returns {string} Translated string + */ + t: function (key, fallback) { + return _translations[key] != null ? _translations[key] : (fallback || key); + }, + + /** + * Manually re-apply translations to all data-i18n elements. + * Useful after dynamically adding new elements to the DOM. + */ + applyI18n: function () { + applyTranslations(); + }, + + /** + * Call the plugin's handle_page_api method. + * @param {string} endpoint - The API endpoint + * @param {*} [body] - Request body + * @param {string} [method='POST'] - HTTP method + * @returns {Promise<*>} Response data + */ + api: function (endpoint, body, method) { + return new Promise(function (resolve, reject) { + var requestId = 'req_' + (++_requestIdCounter) + '_' + Date.now(); + _pendingRequests[requestId] = { resolve: resolve, reject: reject }; + + window.parent.postMessage({ + type: 'langbot:api', + requestId: requestId, + endpoint: endpoint, + method: method || 'POST', + body: body, + }, '*'); + + // Timeout after 30s + setTimeout(function () { + if (_pendingRequests[requestId]) { + _pendingRequests[requestId].reject(new Error('Request timeout')); + delete _pendingRequests[requestId]; + } + }, 30000); + }); + }, + }; +})(); diff --git a/src/langbot_plugin/assets/templates/components/pages/{page_name}.html.example b/src/langbot_plugin/assets/templates/components/pages/{page_name}.html.example new file mode 100644 index 0000000..b98296b --- /dev/null +++ b/src/langbot_plugin/assets/templates/components/pages/{page_name}.html.example @@ -0,0 +1,31 @@ + + + + + + {{ page_label }} + + + +

{{ page_label }}

+

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]: