Files
Fu-Jie_openwebui-extensions/plugins/actions/PLUGIN_DEVELOPMENT_GUIDE.md

9.3 KiB
Raw Permalink Blame History

OpenWebUI HTML Action 插件开发指南

本文档定义了开发 OpenWebUI Action 插件的标准规范和最佳实践。

📐 核心技术规范

1. Valves 配置规范 (Pydantic BaseModel)

命名规则: 所有字段必须使用 大写+下划线 (UPPER_CASE)。

class Valves(BaseModel):
    SHOW_STATUS: bool = Field(default=True, description="是否显示状态更新")
    MODEL_ID: str = Field(default="", description="指定模型 ID留空则使用当前对话模型")
    MIN_TEXT_LENGTH: int = Field(default=50, description="最小字符限制")
    CLEAR_PREVIOUS_HTML: bool = Field(default=False, description="是否清除旧结果")
    # 根据需要添加更多配置...

常用字段参考:

字段名 类型 说明
SHOW_STATUS bool 控制是否显示状态更新
MODEL_ID str 允许用户指定 LLM 模型
MIN_TEXT_LENGTH int 设置触发分析的最小字符数
MAX_TEXT_LENGTH int 设置推荐的最大字符数
CLEAR_PREVIOUS_HTML bool 控制是覆盖还是合并旧的插件输出
LANGUAGE str 目标语言 (如 'zh', 'en')

2. 事件发送规范 (Event Emission)

禁止直接调用 await __event_emitter__。必须实现并使用以下辅助方法:

async def _emit_status(self, emitter, description: str, done: bool = False):
    """发送状态更新事件。"""
    if self.valves.SHOW_STATUS and emitter:
        await emitter(
            {"type": "status", "data": {"description": description, "done": done}}
        )

async def _emit_notification(self, emitter, content: str, ntype: str = "info"):
    """发送通知事件 (info/success/warning/error)。"""
    if emitter:
        await emitter(
            {"type": "notification", "data": {"type": ntype, "content": content}}
        )

使用示例:

# 开始处理
await self._emit_status(__event_emitter__, "正在分析文本...", done=False)

# 处理完成
await self._emit_status(__event_emitter__, "分析完成!", done=True)
await self._emit_notification(__event_emitter__, "报告已生成", "success")

# 发生错误
await self._emit_status(__event_emitter__, "处理失败。", done=True)
await self._emit_notification(__event_emitter__, f"错误: {str(e)}", "error")

3. 日志与调试

  • 严禁使用 print()
  • 必须使用 Python 标准库 logging
import logging

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

# 记录关键路径
logger.info(f"Action: {__name__} started")

# 记录异常 (包含堆栈信息)
logger.error(f"处理失败: {str(e)}", exc_info=True)

4. HTML 注入逻辑

4.1 HTML 包装器

使用统一的注释标记插件内容:

<!-- OPENWEBUI_PLUGIN_OUTPUT -->
<div class="plugin-container">
    <!-- STYLE_INSERTION_POINT -->
    <!-- CONTENT_INSERTION_POINT -->
    <!-- SCRIPT_INSERTION_POINT -->
</div>

4.2 合并机制

必须实现以下方法,支持在同一条消息中多次运行插件:

def _remove_existing_html(self, content: str) -> str:
    """移除已存在的插件 HTML 块。"""
    pattern = r"```html\s*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?```"
    return re.sub(pattern, "", content).strip()

def _merge_html(
    self,
    existing_html: str,
    new_content: str,
    new_styles: str = "",
    new_scripts: str = "",
    user_language: str = "en-US",
) -> str:
    """合并新内容到已有 HTML 容器,或创建新容器。"""
    # 实现逻辑...

4.3 注入流程

# 1. 提取已有 HTML
existing_html_block = ""
match = re.search(
    r"```html\s*(<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?)```",
    original_content,
)
if match:
    existing_html_block = match.group(1)

# 2. 根据配置决定是否清除旧内容
if self.valves.CLEAR_PREVIOUS_HTML:
    original_content = self._remove_existing_html(original_content)
    final_html = self._merge_html("", new_content, new_styles, "", user_language)
else:
    # 合并到已有 HTML
    final_html = self._merge_html(existing_html_block, new_content, new_styles, "", user_language)

# 3. 注入到消息
html_embed_tag = f"```html\n{final_html}\n```"
body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"

📝 完整代码结构模板

"""
title: 插件名称
author: 作者名
version: 0.1.0
description: 插件描述
"""

import logging
import re
import json
import time
from typing import Optional, Dict, Any, Callable, Awaitable
from pydantic import BaseModel, Field
from starlette.requests import Request

from open_webui.apps.webui.models.users import Users
from open_webui.main import generate_chat_completion

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

# ============ 提示词模板 ============
SYSTEM_PROMPT = """你是一个专业的..."""
USER_PROMPT_TEMPLATE = """请分析以下内容: {content}"""

# ============ HTML 模板 ============
HTML_WRAPPER_TEMPLATE = """<!-- OPENWEBUI_PLUGIN_OUTPUT -->
<div class="plugin-container" lang="{user_language}">
<style>/* 样式 */</style>
<!-- CONTENT_INSERTION_POINT -->
</div>
"""

class Action:
    class Valves(BaseModel):
        SHOW_STATUS: bool = Field(default=True, description="是否显示状态更新")
        MODEL_ID: str = Field(default="", description="指定模型 ID")
        MIN_TEXT_LENGTH: int = Field(default=50, description="最小字符限制")
        CLEAR_PREVIOUS_HTML: bool = Field(default=False, description="是否清除旧结果")

    def __init__(self):
        self.valves = self.Valves()

    # ========== 事件发送辅助方法 ==========
    async def _emit_status(self, emitter, description: str, done: bool = False):
        if self.valves.SHOW_STATUS and emitter:
            await emitter({"type": "status", "data": {"description": description, "done": done}})

    async def _emit_notification(self, emitter, content: str, ntype: str = "info"):
        if emitter:
            await emitter({"type": "notification", "data": {"type": ntype, "content": content}})

    # ========== HTML 处理辅助方法 ==========
    def _remove_existing_html(self, content: str) -> str:
        pattern = r"```html\s*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?```"
        return re.sub(pattern, "", content).strip()

    def _merge_html(self, existing_html: str, new_content: str, new_styles: str = "", new_scripts: str = "", user_language: str = "en-US") -> str:
        # 实现合并逻辑...
        pass

    # ========== 主入口 ==========
    async def action(
        self,
        body: dict,
        __user__: Optional[Dict[str, Any]] = None,
        __event_emitter__: Optional[Callable[[Any], Awaitable[None]]] = None,
        __request__: Optional[Request] = None,
    ) -> Optional[dict]:
        logger.info(f"Action: {__name__} started")

        # 1. 输入校验
        messages = body.get("messages", [])
        if not messages or not messages[-1].get("content"):
            return body

        original_content = messages[-1]["content"]

        if len(original_content) < self.valves.MIN_TEXT_LENGTH:
            await self._emit_notification(__event_emitter__, "文本过短", "warning")
            return body

        # 2. 发送开始状态
        await self._emit_status(__event_emitter__, "正在处理...", done=False)

        try:
            # 3. 调用 LLM
            user_id = __user__.get("id") if __user__ else "default"
            user_obj = Users.get_user_by_id(user_id)

            target_model = self.valves.MODEL_ID or body.get("model")

            payload = {
                "model": target_model,
                "messages": [
                    {"role": "system", "content": SYSTEM_PROMPT},
                    {"role": "user", "content": USER_PROMPT_TEMPLATE.format(content=original_content)},
                ],
                "stream": False,
            }

            llm_response = await generate_chat_completion(__request__, payload, user_obj)
            result = llm_response["choices"][0]["message"]["content"]

            # 4. 生成 HTML
            # ...

            # 5. 注入到消息
            html_embed_tag = f"```html\n{final_html}\n```"
            body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"

            # 6. 发送成功通知
            await self._emit_status(__event_emitter__, "处理完成!", done=True)
            await self._emit_notification(__event_emitter__, "操作成功", "success")

        except Exception as e:
            logger.error(f"处理失败: {e}", exc_info=True)
            await self._emit_status(__event_emitter__, "处理失败", done=True)
            await self._emit_notification(__event_emitter__, f"错误: {str(e)}", "error")

        return body

🎨 UI 设计原则

  1. 响应式: 确保 HTML 在移动端和桌面端都能完美显示
  2. 交互性: 适当添加 JavaScript 交互(如点击展开、切换视图、复制内容)
  3. 本地化: 根据 __user__.get("language") 自动适配中英文界面
  4. 美学设计: 优先使用现代 UI 设计
    • 毛玻璃效果 (backdrop-filter)
    • 渐变色 (linear-gradient)
    • 圆角卡片 (border-radius)
    • Google Fonts 字体

📚 参考模板