Files
Fu-Jie_openwebui-extensions/.github/copilot-instructions.md
fujie 377534e6c9 feat(project): sync engineering standards and finalize markdown-normalizer v1.2.7
- Update .github/copilot-instructions.md with latest i18n and naming standards
- Add docs/development/issue-reply-guide.md for professional community engagement
- Sync all documentation (MKDocs, READMEs, Docs) to v1.2.7
- Include CI/CD and Agent instruction templates for better automation
2026-02-24 15:13:52 +08:00

45 KiB
Raw Blame History

Copilot Instructions for openwebui-extensions

本文档定义了 OpenWebUI 插件开发的标准规范和最佳实践。Copilot 在生成代码或文档时应遵循这些准则。

This document defines the standard conventions and best practices for OpenWebUI plugin development. Copilot should follow these guidelines when generating code or documentation.


🏗️ 项目结构与命名 (Project Structure & Naming)

1. 语言与代码规范 (Language & Code Requirements)

插件代码 (Plugin Code)

每个插件必须采用单文件国际化 (i18n) 设计。严禁为不同语言创建独立的源代码文件(如 _cn.py)。

  1. 单代码文件: plugins/{type}/{name}/{name}.py
  2. 内置 i18n: 必须在代码中根据前端传来的用户语言(如 __user__ 中的 language 或通过 get_user_language 脚本读取)动态切换界面显示、提示词和状态日志。

示例目录结构:

plugins/actions/export_to_docx/
├── export_to_word.py      # 单个代码文件,内置多语言支持
├── README.md               # 英文文档 (English documentation)
└── README_CN.md            # 中文文档

文档 (Documentation)

尽管代码是合一的,但为了市场展示和 SEO每个插件目录仍必须包含双语 README 文件:

  • README.md - English documentation
  • README_CN.md - 中文文档

README 结构规范 (README Structure Standard)

所有插件 README 必须遵循以下统一结构顺序:

  1. 标题 (Title): 插件名称,带 Emoji 图标
  2. 元数据 (Metadata): 作者、版本、项目链接 (一行显示)
    • 格式: **Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** x.x.x | **Project:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions)
    • 注意: Author 和 Project 为固定值,仅需更新 Version 版本号
  3. 描述 (Description): 一句话功能介绍
  4. 最新更新 (What's New): 必须放在描述之后,仅展示最近 1 次更新
  5. 核心特性 (Key Features): 使用 Emoji + 粗体标题 + 描述格式
  6. 使用方法 (How to Use): 按步骤说明
  7. 配置参数 (Configuration/Valves): 使用表格格式,包含参数名、默认值、描述
  8. 支持 (Support): 必须包含,放在配置参数之后、故障排除之前
    • English: If this plugin has been useful, a star on [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) is a big motivation for me. Thank you for the support.
    • 中文: 如果这个插件对你有帮助,欢迎到 [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) 点个 Star这将是我持续改进的动力感谢支持。
  9. 其他 (Others): 支持的模板类型、语法示例、故障排除等
    • Changelog: 统一指向 GitHub 项目历史,不在 README 中列出具体变更

2. 插件目录结构 (Plugin Directory Structure)

plugins/
├── actions/           # Action 插件 (用户触发的功能)
│   ├── my_action/
│   │   ├── my_action.py          # 单文件,内置 i18n
│   │   ├── README.md              # English documentation
│   │   └── README_CN.md           # Chinese documentation
│   ├── ACTION_PLUGIN_TEMPLATE.py      # 通用 i18n 模板
│   └── README.md
├── filters/           # Filter 插件 (输入处理)
│   └── ...
├── pipes/             # Pipe 插件 (输出处理)
│   └── ...
├── pipelines/         # Pipeline 插件
    └── ...
├── debug/             # 调试与开发工具 (Debug & Development Tools)
│   ├── my_debug_tool/
│   │   ├── debug_script.py
│   │   └── notes.md
│   └── ...

调试目录规范 (Debug Directory Standards)

plugins/debug/ 目录用于存放调试用的脚本、临时验证代码或开发笔记。

目录结构 (Directory Structure): 应根据调试工具所属的插件或功能模块进行子目录分类,而非将文件散落在根目录。

plugins/debug/
├── my_plugin_name/      # 特定插件的调试文件 (Debug files for specific plugin)
│   ├── debug_script.py
│   └── guides/
├── common_tools/        # 通用调试工具 (General debug tools)
│   └── ...
└── ...

规范说明 (Guidelines):

  • 不强制要求 README: 该目录下的子项目不需要包含 README.md
  • 发布豁免: 该目录下的内容绝不会被发布脚本处理。
  • 内容灵活性: 可以包含 Python 脚本、Markdown 文档、JSON 数据等。
  • 分类存放: 任何调试产物(如 test_*.py, inspect_*.py)都不应直接存放在项目根目录,必须移动到此目录下相应的子文件夹中。

3. 文档字符串规范 (Docstring Standard)

每个插件文件必须以标准化的文档字符串开头:

"""
title: 插件名称 (Plugin Name)
author: Fu-Jie
author_url: https://github.com/Fu-Jie/openwebui-extensions
funding_url: https://github.com/open-webui
version: 0.1.0
icon_url: data:image/svg+xml;base64,<base64-encoded-svg>
requirements: dependency1==1.0.0, dependency2>=2.0.0
description: 插件功能的简短描述。Brief description of plugin functionality.
"""

字段说明 (Field Descriptions)

字段 (Field) 说明 (Description) 示例 (Example)
title 插件显示名称 Export to Word / 导出为 Word
author 作者名称 Fu-Jie
author_url 作者主页链接 https://github.com/Fu-Jie/openwebui-extensions
funding_url 赞助/项目链接 https://github.com/open-webui
version 语义化版本号 0.1.0, 1.2.3
icon_url 图标 (Base64 编码的 SVG) 仅 Action 插件必须提供。其他类型可选。
requirements 额外依赖 (仅 OpenWebUI 环境未安装的) python-docx==1.1.2
description 功能描述 将对话导出为 Word 文档

图标规范 (Icon Guidelines)

  • 图标来源:从 Lucide Icons 获取符合插件功能的图标
  • 适用范围Action 插件必须提供,其他插件可选
  • 格式Base64 编码的 SVG
  • 获取方法:从 Lucide 下载 SVG然后使用 Base64 编码
  • 示例格式:
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0i...(完整的 Base64 编码字符串)

4. 依赖管理 (Dependencies)

requirements 字段规则

  • 仅列出 OpenWebUI 环境中未安装的依赖
  • 使用精确版本号
  • 多个依赖用逗号分隔
"""
requirements: python-docx==1.1.2, openpyxl==3.1.2
"""

常见 OpenWebUI 已安装依赖(无需在 requirements 中声明):

  • pydantic
  • fastapi
  • logging
  • re, json, datetime, io, base64

💻 核心开发规范 (Core Development Standards)

1. Valves 配置规范 (Valves Configuration)

使用 Pydantic BaseModel 定义可配置参数:

from pydantic import BaseModel, Field

class Action:
    class Valves(BaseModel):
        SHOW_STATUS: bool = Field(
            default=True,
            description="Whether to show operation status updates."
        )
        SHOW_DEBUG_LOG: bool = Field(
            default=False,
            description="Whether to print debug logs in the browser console."
        )
        MODEL_ID: str = Field(
            default="",
            description="Built-in LLM Model ID. If empty, uses current conversation model."
        )
        MIN_TEXT_LENGTH: int = Field(
            default=50,
            description="Minimum text length required for processing (characters)."
        )

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

命名规则 (Naming Convention)

  • 所有 Valves 字段使用 大写下划线 (UPPER_SNAKE_CASE)
  • 示例:SHOW_STATUS, MODEL_ID, MIN_TEXT_LENGTH

2. 上下文获取规范 (Context Access)

所有插件必须使用 _get_user_context_get_chat_context 方法来安全获取信息,而不是直接访问 __user__body

用户上下文 (User Context)

async def _get_user_context(
    self,
    __user__: Optional[dict],
    __event_call__: Optional[callable] = None,
    __request__: Optional[Request] = None,
) -> dict:
    """
    Robust extraction of user context with multi-level fallback for language detection.
    Priority: localStorage (via JS) > HTTP headers > User profile > en-US
    """
    user_data = __user__ if isinstance(__user__, dict) else {}
    user_id = user_data.get("id", "unknown_user")
    user_name = user_data.get("name", "User")
    user_language = user_data.get("language", "en-US")

    # 1. Fallback: HTTP Accept-Language header
    if __request__ and hasattr(__request__, "headers"):
        accept_lang = __request__.headers.get("accept-language", "")
        if accept_lang:
            user_language = accept_lang.split(",")[0].split(";")[0]

    # 2. Priority: Frontend localStorage via JS (requires timeout protection)
    if __event_call__:
        try:
            js_code = """
                try {
                    return (
                        document.documentElement.lang ||
                        localStorage.getItem('locale') || 
                        navigator.language || 
                        'en-US'
                    );
                } catch (e) {
                    return 'en-US';
                }
            """
            # MUST use wait_for with timeout (e.g., 2.0s) to prevent backend deadlock
            frontend_lang = await asyncio.wait_for(
                __event_call__({"type": "execute", "data": {"code": js_code}}),
                timeout=2.0
            )
            if frontend_lang and isinstance(frontend_lang, str):
                user_language = frontend_lang
        except Exception:
            pass # Fallback to existing language

    return {
        "user_id": user_id,
        "user_name": user_name,
        "user_language": user_language,
    }

聊天上下文 (Chat Context)

def _get_chat_context(self, body: dict, __metadata__: Optional[dict] = None) -> Dict[str, str]:
    """
    统一提取聊天上下文信息 (chat_id, message_id)。
    优先从 body 中提取,其次从 metadata 中提取。
    """
    chat_id = ""
    message_id = ""

    # 1. 尝试从 body 获取
    if isinstance(body, dict):
        chat_id = body.get("chat_id", "")
        message_id = body.get("id", "") # message_id 在 body 中通常是 id
        
        # 再次检查 body.metadata
        if not chat_id or not message_id:
            body_metadata = body.get("metadata", {})
            if isinstance(body_metadata, dict):
                if not chat_id:
                    chat_id = body_metadata.get("chat_id", "")
                if not message_id:
                    message_id = body_metadata.get("message_id", "")

    # 2. 尝试从 __metadata__ 获取 (作为补充)
    if (__metadata__ and isinstance(__metadata__, dict)):
        if not chat_id:
            chat_id = __metadata__.get("chat_id", "")
        if not message_id:
            message_id = __metadata__.get("message_id", "")

    return {
        "chat_id": str(chat_id).strip(),
        "message_id": str(message_id).strip(),
    }

使用示例

async def action(self, body: dict, __user__: Optional[Dict[str, Any]] = None, __metadata__: Optional[dict] = None, ...):
    user_ctx = self._get_user_context(__user__)
    chat_ctx = self._get_chat_context(body, __metadata__)
    
    user_id = user_ctx["user_id"]
    chat_id = chat_ctx["chat_id"]
    message_id = chat_ctx["message_id"]

3. 事件发送与日志规范 (Event Emission & Logging)

事件发送 (Event Emission)

必须实现以下辅助方法:

async def _emit_status(
    self,
    emitter: Optional[Callable[[Any], Awaitable[None]]],
    description: str,
    done: bool = False,
):
    """Emits a status update event."""
    if self.valves.SHOW_STATUS and emitter:
        await emitter(
            {"type": "status", "data": {"description": description, "done": done}}
        )

async def _emit_notification(
    self,
    emitter: Optional[Callable[[Any], Awaitable[None]]],
    content: str,
    ntype: str = "info",
):
    """Emits a notification event (info, success, warning, error)."""
    if emitter:
        await emitter(
            {"type": "notification", "data": {"type": ntype, "content": content}}
        )

前端控制台调试 (Frontend Console Debugging) - 优先推荐

对于需要实时查看数据流、排查 UI 交互或内容变更的场景,优先使用前端控制台日志。

async def _emit_debug_log(
    self,
    emitter: Optional[Callable[[Any], Awaitable[None]]],
    title: str,
    data: dict,
):
    """Print structured debug logs in the browser console."""
    if not self.valves.SHOW_DEBUG_LOG or not emitter:
        return

    try:
        js_code = f"""
            (async function() {{
                console.group("🛠️ {title}");
                console.log({json.dumps(data, ensure_ascii=False)});
                console.groupEnd();
            }})();
        """

        await emitter({"type": "execute", "data": {"code": js_code}})
    except Exception as e:
        print(f"Error emitting debug log: {e}")

服务端日志 (Server-side Logging)

用于记录系统级错误、异常堆栈或无需前端感知的后台任务。

  • 禁止使用 print() 语句 (除非用于简单的脚本调试)
  • 必须使用 Python 标准库 logging
import logging

logging.basicConfig(
    level=logging.INFO, 
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)

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

# 记录异常 (包含堆栈信息)
logger.error(f"Processing failed: {e}", exc_info=True)

4. 数据库连接规范 (Database Connection)

当插件需要持久化存储时,必须复用 Open WebUI 的内部数据库连接,而不是创建新的数据库引擎。

# Open WebUI internal database (re-use shared connection)
from open_webui.internal.db import engine as owui_engine
from open_webui.internal.db import Session as owui_Session
from open_webui.internal.db import Base as owui_Base

class PluginTable(owui_Base):
    # ... definition ...
    pass

class Filter:
    def __init__(self):
        self._db_engine = owui_engine
        self._SessionLocal = owui_Session
        # ...

5. 文件存储访问规范 (File Storage Access)

插件在访问用户上传的文件或生成的图片时必须实现多级回退机制以兼容所有存储配置本地磁盘、S3/MinIO 等)。

推荐实现以下优先级的文件获取策略:

  1. 数据库直接存储 (小文件)
  2. S3 直连 (对象存储 - 最快)
  3. 本地文件系统 (磁盘存储)
  4. 公共 URL 下载
  5. 内部 API 回调 (通用兜底方案)

(详细实现参考 plugins/actions/export_to_docx/export_to_word.py 中的 _image_bytes_from_owui_file_id 方法)

6. 长时间运行任务通知 (Long-running Task Notifications)

如果一个前台任务的运行时间预计超过 3秒,必须实现用户通知机制。

import asyncio

async def long_running_task_with_notification(self, event_emitter, ...):
    # 定义实际任务
    async def actual_task():
        # ... 执行耗时操作 ...
        return result

    # 定义通知任务
    async def notification_task():
        # 立即发送首次通知
        if event_emitter:
            await self._emit_notification(event_emitter, "正在使用 AI 生成中...", "info")
        
        # 之后每5秒通知一次
        while True:
            await asyncio.sleep(5)
            if event_emitter:
                await self._emit_notification(event_emitter, "仍在处理中,请耐心等待...", "info")

    # 并发运行任务
    task_future = asyncio.ensure_future(actual_task())
    notify_future = asyncio.ensure_future(notification_task())

    # 等待任务完成
    done, pending = await asyncio.wait(
        [task_future, notify_future], 
        return_when=asyncio.FIRST_COMPLETED
    )

    # 取消通知任务
    if not notify_future.done():
        notify_future.cancel()

    # 获取结果
    if task_future in done:
        return task_future.result()

7. 前端数据获取与交互规范 (Frontend Data Access & Interaction)

获取前端信息 (Retrieving Frontend Info)

当需要获取用户浏览器的上下文信息如语言、时区、LocalStorage必须使用 __event_call__execute 类型,而不是通过文件上传或复杂的 API 请求。

async def _get_frontend_value(self, js_code: str) -> str:
    """Helper to execute JS and get return value."""
    try:
        response = await __event_call__(
            {
                "type": "execute",
                "data": {
                    "code": js_code,
                },
            }
        )
        return str(response)
    except Exception as e:
        logger.error(f"Failed to execute JS: {e}")
        return ""

# 示例:获取界面语言 (Get UI Language)
async def get_user_language(self):
    js_code = """
        return (
            localStorage.getItem('locale') || 
            localStorage.getItem('language') || 
            navigator.language || 
            'en-US'
        );
    """
    return await self._get_frontend_value(js_code)

适用场景与引导 (Usage Guidelines)

  • 语言适配: 动态获取界面语言 (ru-RU, zh-CN) 自动切换输出语言和 UI 翻译。这对于单文件 i18n 插件至关重要。
  • 时区处理: 获取 Intl.DateTimeFormat().resolvedOptions().timeZone 处理时间。
  • 客户端存储: 读取 localStorage 中的用户偏好设置。
  • 硬件能力: 获取 navigator.clipboardnavigator.geolocation (需授权)。

注意: 即使插件有 Valves 配置,也应优先尝试自动探测,提升用户体验。

8. 国际化 (i18n) 适配规范 (Internationalization Standards)

开发供全球用户使用的插件时,必须预置多语言支持(如中文、英文等)。

i18n 字典定义

在文件顶部定义 TRANSLATIONS 字典存储多语言字符串:

TRANSLATIONS = {
    "en-US": {
        "status_starting": "Smart Mind Map is starting...",
    },
    "zh-CN": {
        "status_starting": "智能思维导图正在启动...",
    },
    # ... 其他语言
}

# 语言回退映射 (Fallback Map)
FALLBACK_MAP = {
    "zh": "zh-CN",
    "zh-TW": "zh-CN",
    "zh-HK": "zh-CN",
    "en": "en-US",
    "en-GB": "en-US"
}

获取当前用户真实语言 (Robust Language Detection)

Open WebUI 的前端localStorage并未自动同步语言设置到后端数据库或通过标准 API 参数传递。为了获取精准的用户偏好语言,必须使用多层级回退机制Multi-level Fallback JS 动态探测 (localStorage) > HTTP 浏览器头 (Accept-Language) > 用户 Profile 默认设置 > en-US

注意!防卡死指南 (Anti-Deadlock Guide) 在通过 __event_call__ 执行前端 JS 脚本时,如果前端脚本不慎抛出异常 (Exception) 会导致回调函数 cb() 永不执行,这会让后端的 asyncio 永远阻塞并卡死整个请求队列! 必须做两重防护:

  1. JS 内部包裹 try...catch 保证必须有 return
  2. 后端使用 asyncio.wait_for 设置强制超时(建议 2 秒)。
import asyncio
from fastapi import Request

async def _get_user_context(
    self,
    __user__: Optional[dict],
    __event_call__: Optional[callable] = None,
    __request__: Optional[Request] = None,
) -> dict:
    user_language = __user__.get("language", "en-US") if __user__ else "en-US"
    
    # 1st Fallback: HTTP Accept-Language header
    if __request__ and hasattr(__request__, "headers") and "accept-language" in __request__.headers:
        raw_lang = __request__.headers.get("accept-language", "")
        if raw_lang:
            user_language = raw_lang.split(",")[0].split(";")[0]

    # 2nd Fallback (Best): Execute JS in frontend to read localStorage
    if __event_call__:
        try:
            js_code = """
                try {
                    return (
                        document.documentElement.lang ||
                        localStorage.getItem('locale') || 
                        navigator.language || 
                        'en-US'
                    );
                } catch (e) {
                    return 'en-US';
                }
            """
            # 【致命!】必须设置 wait_for 防止前端无响应卡死后端
            frontend_lang = await asyncio.wait_for(
                __event_call__({"type": "execute", "data": {"code": js_code}}),
                timeout=2.0
            )
            if frontend_lang and isinstance(frontend_lang, str):
                user_language = frontend_lang
        except Exception as e:
            pass # fallback to accept-language or en-US

    return {
        "user_language": user_language,
        # ... user_name, user_id etc.
    }

实际使用 (Usage in Action/Filter)

async def action(self, body: dict, __user__: Optional[dict] = None, __event_call__: Optional[callable] = None, ...):
    user_ctx = await self._get_user_context(__user__, __event_call__)
    lang = user_ctx["user_language"]
    
    # Use helper to get localized string with optional formatting
    msg = self._get_translation(lang, "status_starting", name=user_ctx["user_name"])
    await self._emit_status(emitter, msg)

提示词中的语言一致性 (Language Consistency in Prompts)

在为 LLM 生成系统提示词时,必须包含“输出语言与输入保持一致”的指令,以确保 i18n 逻辑在 AI 生成环节也不断裂。

标准指令示例:

  • Language: All output must be in the exact same language as the input text provided by the user.
  • Format Consistency: Even if this system prompt is in English, if the user input is in Chinese, your output must be in Chinese.

CJK 脚本的特殊考量 (CJK Script Considerations)

当涉及字符长度限制(如思维导图标题、卡片摘要)时,应区分 CJK中日韩和拉丁脚本。

  • CJK (zh, ja, ko): 通常应设置更小的字符数限制(如 10 字以内)。
  • Latin (en, es, fr): 可设置较长的字符限制(如 5-8 个词或 35 字符)。

9. 智能代理文件交付规范 (Agent File Delivery Standards)

在开发具备文件生成能力的智能代理插件(如 GitHub Copilot SDK 集成)时,必须遵循以下标准流程,以确保文件在不同存储后端(本地/S3下的可用性并绕过不必要的 RAG 处理。

核心协议:三步交付法 (The 3-Step Delivery Protocol)

  1. 本地写入 (Write Local):
    • 代理必须在当前执行目录 (.) 下创建文件。
    • 严禁使用系统临时目录(如 /tmp)存放待发布的文件,因为这些路径在隔离的工作空间外不可见。
  2. 显式发布 (Publish):
    • 必须调用内建工具 publish_file_from_workspace(filename='name.ext')
    • 该工具负责将文件迁移至 Open WebUI 正式存储(自动适配 S3并注入 skip_rag 元数据以防止触发向量化流程RAG Bypass
  3. 呈现链接 (Display Link):
    • 获取工具返回的 download_url(正确格式为 /api/v1/files/{id}/content)。
    • 必须以 Markdown 链接形式(如 [点击下载报告](url))展示给用户。

路径语义 (Path Semantics)

  • 代理应始终将“当前目录”视为其受保护所在的私有工作空间。
  • publish_file_from_workspace 的参数 filename 仅需传入相对于当前目录的文件名。

10. Copilot SDK 插件工具定义规范 (Copilot SDK Tool Definition Standards)

在为 GitHub Copilot SDK 开发自定义工具时,为了确保大模型能正确识别参数(避免生成空的 properties Schema必须遵循以下定义模式

显式参数模型 (Explicit Parameter Schema)

禁止仅依赖函数签名和类型提示。必须定义一个继承自 pydantic.BaseModel 的类来描述参数,并在 define_tool 中通过 params_type 显式引用。

from pydantic import BaseModel, Field
from copilot import define_tool

# 1. 定义参数模型
class MyToolParams(BaseModel):
    query: str = Field(..., description="搜索关键词")
    limit: int = Field(default=10, description="返回结果数量限制")

# 2. 实现工具逻辑
async def my_custom_search(query: str, limit: int) -> dict:
    # ... 逻辑实现 ...
    return {"results": []}

# 3. 注册工具(关键:使用 params_type
my_tool = define_tool(
    name="my_custom_search",
    description="在特定数据源中执行搜索",
    params_type=MyToolParams,  # 显式传递参数模型以生成正确的 JSON Schema
)(my_custom_search)

关键要点 (Key Requirements)

  1. params_type: 必须在 define_tool 中使用此参数。这是防止大模型幻觉认为工具“无参数”的唯一可靠方法。
  2. Field 描述: 在 BaseModel 中使用 Field(..., description="...") 为每个参数提供详细的描述信息。
  3. Required vs Optional: 明确标注必填项(无默认值)和可选项(带 default)。

11. Copilot SDK 流式渲染与工具卡片规范 (Streaming & Tool Card Standards)

在处理大模型的思维链Reasoning输出和工具调用Tool Calls为了确保能完美兼容 OpenWebUI 0.8.x 前端的 Markdown 解析器及原生折叠 UI 组件,必须遵循以下极度严格的输出格式规范。

思维链流式渲染 (Reasoning Streaming)

为了让前端能够正确显示“Thinking...”的折叠框和 Spinner 动画,必须使用原生的 <think> 标签。

  • 正确的标签包裹:
    <think>
    这里是思考过程...
    </think>
    
  • 关键细节:
    • 标签闭合检测: 必须在代码内部维护状态(如 state["thinking_started"]。当1正文内容即将开始输出2工具调用触发 (tool.execution_start) 时,必须优先输出 \n</think>\n 强制闭合标签。如果不闭合,后续的正文或工具面板会被全部吞进思考框内,导致页面完全崩坏!
    • 不要手动拼装: 严禁通过手动输出 <details type="reasoning"> 等大段 HTML 来模拟思考过程,这种方式极易在流式片段发送中破坏前端 DOM 树并导致错位。

工具调用原生卡片 (Native Tool Calls Block)

为了在对话界面中生成标准、原生的下拉折叠“工具调用”卡片,当 event_type == "tool.execution_complete" 时,必须向队列输出如下严格格式的 HTML

# 必须转义属性中的双引号为 &quot;
args_for_attr = args_json_str.replace('"', "&quot;")
result_for_attr = result_content.replace('"', "&quot;")

tool_block = (
    f'\\n<details type="tool_calls"'
    f' id="{tool_call_id}"'
    f' name="{tool_name}"'
    f' arguments="{args_for_attr}"'
    f' result="{result_for_attr}"'
    f' done="true">\\n'
    f"<summary>Tool Executed</summary>\\n"
    f"</details>\\n\\n"
)
queue.put_nowait(tool_block)
  • 致命避坑点 (Critical Pitfalls):
    1. 属性转义 (Extremely Important): <details> 内的 argumentsresult 属性必须将内部的所有双引号 " 替换为 &quot;。因为 OpenWebUI 前端提取这些数据的 Regex 是严格的 ="([^"]*)",一旦内容中出现原生双引号,就会被瞬间截断,导致参数被渲染为空并引发解析错误!
    2. 换行符要求: <details ...> 尖括号闭合后紧接着的内容必须换行(即 >\\n),否则 Markdown 扩展引擎无法将其识别为独立的 UI Block。
    3. 去除冗余通知: 不要在 tool.execution_start 事件中提前向对话流输出普通的 🔧 Executing... 纯文本块,这会导致最终页面上同时出现两块工具提示(一个文本,一个折叠卡片)。

Debug 信息的解耦 (Decoupling Debug Logs)

对于连接建立、运行环境、缓存加载等属于 脚本自身运行状态 的 Debug 信息:

  • 禁止: 不要将这些内容 yield 到最终的回答数据流(或塞进 <think> 标签内),这会污染回答的纯粹性。
  • 推荐: 统一使用 OpenWebUI 顶部的原生状态反馈气泡Status Events
    await __event_emitter__({
        "type": "status",
        "data": {"description": "连接建立,正在等待响应...", "done": True}
    })
    

Action 插件规范 (Action Plugin Standards)

1. HTML 注入规范 (HTML Injection)

使用统一的标记和结构:

# HTML 包装器标记
HTML_WRAPPER_TEMPLATE = """
<!-- OPENWEBUI_PLUGIN_OUTPUT -->
<!DOCTYPE html>
<html lang="{user_language}">
<head>
    <meta charset="UTF-8">
    <style>
        /* STYLES_INSERTION_POINT */
    </style>
</head>
<body>
    <div id="main-container">
        <!-- CONTENT_INSERTION_POINT -->
    </div>
    <!-- SCRIPTS_INSERTION_POINT -->
</body>
</html>
"""

必须实现 HTML 合并方法 _remove_existing_html_merge_html 以支持多次运行插件。

2. HTML 生成插件的完整模板 (Complete Template)

以下是生成 HTML 输出的 Action 插件需要包含的完整公共代码:

import re
import json
import logging
from typing import Optional, Dict, Any, Callable, Awaitable
from pydantic import BaseModel, Field

# Logging setup
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)

# HTML Template with insertion points
HTML_WRAPPER_TEMPLATE = """
<!-- OPENWEBUI_PLUGIN_OUTPUT -->
<!DOCTYPE html>
<html lang="{user_language}">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>
        body { 
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 
            margin: 0; 
            padding: 10px; 
            background-color: transparent; 
        }
        #main-container { 
            display: flex; 
            flex-direction: column;
            gap: 16px; 
            width: 100%;
            max-width: 100%;
        }
        /* STYLES_INSERTION_POINT */
    </style>
</head>
<body>
    <div id="main-container">
        <!-- CONTENT_INSERTION_POINT -->
    </div>
    <!-- SCRIPTS_INSERTION_POINT -->
</body>
</html>
"""

class Action:
    class Valves(BaseModel):
        SHOW_STATUS: bool = Field(
            default=True,
            description="Whether to show operation status updates."
        )
        SHOW_DEBUG_LOG: bool = Field(
            default=False,
            description="Whether to print debug logs in the browser console."
        )
        # ... other valves ...

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

    # ==================== Common Helper Methods ====================
    
    def _get_user_context(self, __user__: Optional[Dict[str, Any]]) -> Dict[str, str]:
        """Safely extracts user context information."""
        if isinstance(__user__, (list, tuple)):
            user_data = __user__[0] if __user__ else {}
        elif isinstance(__user__, dict):
            user_data = __user__
        else:
            user_data = {}

        return {
            "user_id": user_data.get("id", "unknown_user"),
            "user_name": user_data.get("name", "User"),
            "user_language": user_data.get("language", "en-US"),
        }

    def _get_chat_context(
        self, body: dict, __metadata__: Optional[dict] = None
    ) -> Dict[str, str]:
        """
        Unified extraction of chat context information (chat_id, message_id).
        Prioritizes extraction from body, then metadata.
        """
        chat_id = ""
        message_id = ""

        if isinstance(body, dict):
            chat_id = body.get("chat_id", "")
            message_id = body.get("id", "")

            if not chat_id or not message_id:
                body_metadata = body.get("metadata", {})
                if isinstance(body_metadata, dict):
                    if not chat_id:
                        chat_id = body_metadata.get("chat_id", "")
                    if not message_id:
                        message_id = body_metadata.get("message_id", "")

        if __metadata__ and isinstance(__metadata__, dict):
            if not chat_id:
                chat_id = __metadata__.get("chat_id", "")
            if not message_id:
                message_id = __metadata__.get("message_id", "")

        return {
            "chat_id": str(chat_id).strip(),
            "message_id": str(message_id).strip(),
        }

    async def _emit_status(
        self,
        emitter: Optional[Callable[[Any], Awaitable[None]]],
        description: str,
        done: bool = False,
    ):
        """Emits a status update event."""
        if self.valves.SHOW_STATUS and emitter:
            await emitter(
                {"type": "status", "data": {"description": description, "done": done}}
            )

    async def _emit_notification(
        self,
        emitter: Optional[Callable[[Any], Awaitable[None]]],
        content: str,
        ntype: str = "info",
    ):
        """Emits a notification event (info, success, warning, error)."""
        if emitter:
            await emitter(
                {"type": "notification", "data": {"type": ntype, "content": content}}
            )

    async def _emit_debug_log(
        self,
        emitter: Optional[Callable[[Any], Awaitable[None]]],
        title: str,
        data: dict,
    ):
        """Print structured debug logs in the browser console."""
        if not self.valves.SHOW_DEBUG_LOG or not emitter:
            return

        try:
            js_code = f"""
                (async function() {{
                    console.group("🛠️ {title}");
                    console.log({json.dumps(data, ensure_ascii=False)});
                    console.groupEnd();
                }})();
            """

            await emitter({"type": "execute", "data": {"code": js_code}})
        except Exception as e:
            print(f"Error emitting debug log: {e}")

    # ==================== HTML Helper Methods ====================

    def _remove_existing_html(self, content: str) -> str:
        """Removes existing plugin-generated HTML code blocks."""
        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:
        """Merges new content into existing HTML container."""
        if not existing_html:
            base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language)
        else:
            base_html = existing_html

        if "<!-- CONTENT_INSERTION_POINT -->" in base_html:
            base_html = base_html.replace(
                "<!-- CONTENT_INSERTION_POINT -->",
                f"{new_content}\n        <!-- CONTENT_INSERTION_POINT -->"
            )

        if new_styles and "/* STYLES_INSERTION_POINT */" in base_html:
            base_html = base_html.replace(
                "/* STYLES_INSERTION_POINT */",
                f"{new_styles}\n        /* STYLES_INSERTION_POINT */"
            )

        if new_scripts and "<!-- SCRIPTS_INSERTION_POINT -->" in base_html:
            base_html = base_html.replace(
                "<!-- SCRIPTS_INSERTION_POINT -->",
                f"{new_scripts}\n    <!-- SCRIPTS_INSERTION_POINT -->"
            )

        return base_html

3. 文件导出与命名规范 (File Export and Naming)

对于涉及文件导出的插件,必须提供灵活的标题生成策略。

Valves 配置

class Valves(BaseModel):
    TITLE_SOURCE: str = Field(
        default="chat_title",
        description="Title Source: 'chat_title', 'ai_generated', 'markdown_title'",
    )

优先级与回退 (Priority & Fallback)

chat_title -> markdown_title -> user_name + date

实现示例 (Implementation Example)

    async def _get_filename(
        self,
        body: dict,
        content: str,
        user_id: str,
        request: Optional[Any] = None,
    ) -> str:
        """
        Generate filename based on priority:
        1. TITLE_SOURCE (chat_title / markdown_title / ai_generated)
        2. Fallback: chat_title -> markdown_title -> user_name + date
        """
        title = ""
        chat_title = ""
        
        # 1. Get Chat Title
        chat_ctx = self._get_chat_context(body)
        chat_id = chat_ctx["chat_id"]
        if chat_id:
            chat_title = await self._fetch_chat_title(chat_id, user_id)

        # 2. Determine Title based on Valve
        source = self.valves.TITLE_SOURCE
        if source == "chat_title":
            title = chat_title
        elif source == "markdown_title":
            title = self._extract_title(content)
        elif source == "ai_generated":
            # Optional: Implement AI title generation
            # title = await self._generate_title_using_ai(body, content, user_id, request)
            pass

        # 3. Fallback Logic
        if not title:
            # Fallback to chat_title if not already tried
            if source != "chat_title" and chat_title:
                title = chat_title
            # Fallback to markdown_title if not already tried
            elif source != "markdown_title":
                title = self._extract_title(content)

        # 4. Final Fallback: User + Date
        if not title:
            user_ctx = self._get_user_context(body.get("user"))
            user_name = user_ctx["user_name"]
            date_str = datetime.datetime.now().strftime("%Y%m%d")
            title = f"{user_name}_{date_str}"

        return self._clean_filename(title)

    async def _fetch_chat_title(self, chat_id: str, user_id: str) -> str:
        try:
            from open_webui.apps.webui.models.chats import Chats
            chat = Chats.get_chat_by_id_and_user_id(chat_id, user_id)
            return chat.title if chat else ""
        except Exception:
            return ""

    def _extract_title(self, content: str) -> str:
        """Extract title from Markdown h1 (# Title)"""
        match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
        return match.group(1).strip() if match else ""

    def _clean_filename(self, filename: str) -> str:
        """Remove invalid characters for filenames"""
        return re.sub(r'[\\/*?:"<>|]', "", filename).strip()

4. iframe 主题检测规范 (iframe Theme Detection)

当插件在 iframe 中运行(特别是使用 srcdoc 属性)时,需要检测应用程序的主题以保持视觉一致性。

优先级:

  1. 显式切换 (Explicit Toggle)
  2. 父文档 Meta 标签 (Parent Meta Theme-Color)
  3. 父文档 Class/Data-Theme (Parent HTML/Body Class)
  4. 系统偏好 (System Preference)

5. 高级开发模式 (Advanced Development Patterns)

混合服务端-客户端生成 (Hybrid Server-Client Generation)

服务端生成半成品(如 ZIP客户端渲染复杂组件如 Mermaid并回填。

原生 Word 公式支持 (Native Word Math Support)

使用 latex2mathml + mathml2omml

JS 渲染并嵌入 Markdown (JS Render to Markdown)

利用浏览器渲染图表,导出为 Data URL 图片,回写到 Markdown 中。

OpenWebUI Chat API 更新规范 (Chat API Update Specification)

当插件需要修改消息内容并持久化到数据库时,必须遵循 OpenWebUI 的 Backend-Controlled API 流程。

  1. Event API: 即时更新前端显示。
  2. Chat Persistence API: 持久化到数据库(必须同时更新 messages[]history.messages)。

🛡️ Filter 插件规范 (Filter Plugin Standards)

1. 状态管理 (State Management) - 关键

Filter 实例是单例 (Singleton)

  • 禁止: 使用 self 存储请求级别的临时状态。
  • 推荐: 无状态设计,或使用 body 传递临时数据。

2. 摘要注入角色 (Summary Injection Role)

  • 推荐: 使用 assistant 角色。

3. 模型默认值 (Model Defaults)

  • 禁止: 硬编码特定模型 ID。
  • 推荐: 默认值为 None,优先使用当前对话模型。

4. 异步处理 (Async Processing)

  • 推荐: 在 outlet 中使用 asyncio.create_task() 启动后台任务。

🧪 测试规范 (Testing Standards)

1. Copilot SDK 测试模型 (Copilot SDK Test Models)

在编写 Copilot SDK 相关的测试脚本时 (如 test_injection.py, test_capabilities.py 等)必须优先使用以下免费/低成本模型之一,严禁使用高昂费用的模型进行常规测试,除非用户明确要求:

  • gpt-5-mini (首选 / Preferred)
  • gpt-4.1

此规则适用于所有自动化测试脚本和临时验证脚本。


🔄 工作流与流程 (Workflow & Process)

1. 开发检查清单 (Development Checklist)

  • 代码实现了内置 i18n 逻辑 (.py)
  • 编写英文 README (README.md)
  • 编写中文 README (README_CN.md)
  • 包含标准化文档字符串
  • 添加 Author 和 License 信息
  • 使用 Lucide 图标
  • 实现 Valves 配置
  • 使用 logging 而非 print
  • 测试 i18n 界面适配
  • 一致性检查: 确保文档、代码、README 同步
  • README 结构:
    • Key Capabilities (英文) / 核心功能 (中文): 必须包含所有核心功能
    • What's New (英文) / 最新更新 (中文): 仅包含最新版本的变更信息

2. 🔄 一致性维护 (Consistency Maintenance)

任何插件的新增、修改或移除,必须同时更新:

  1. 插件代码 (version)
  2. 插件自述文件 (plugins/{type}/{name}/README.md & README_CN.md)
  3. 项目文档 (docs/plugins/{type}/{name}.md & .zh.md)
  4. 项目文档索引 (docs/plugins/{type}/index.md & index.zh.md — 版本号)
  5. 项目根 README (README.md & README_CN.md — 更新日期徽章 ![updated](https://img.shields.io/badge/YYYY--MM--DD-gray?style=flat) 必须同步为发布当天日期)

3. 发布工作流 (Release Workflow)

自动发布 (Automatic Release)

推送到 main 分支会自动触发发布。

发布前必须完成

  • 更新版本号(中英文同步)
  • 遵循语义化版本 (SemVer)

Commit Message 规范

使用 Conventional Commits 格式 (feat, fix, docs, etc.)。 必须在提交标题与正文中清晰描述变更内容,确保在 Release 页面可读且可追踪。

要求:

  • 标题必须包含“做了什么”与影响范围(避免含糊词)。
  • 正文必须列出关键变更点1-3 条),与实际改动一一对应。
  • 若影响用户或插件行为,必须在正文标明影响与迁移说明。

推荐格式:

  • feat(actions): add export settings panel
  • fix(filters): handle empty metadata to avoid crash
  • docs(plugins): update bilingual README structure

正文示例:

  • Add valves for export format selection
  • Update README/README_CN to include What's New section
  • Migration: default TITLE_SOURCE changed to chat_title

发布信息生成准则 (Release Summary Generation)

当准备提交时,必须向用户展示以下格式的“发布草案”:

  1. Commit Message: 符合 Conventional Commits 的英文标题及摘要。
  2. 变更列表 (Bilingual Changes):
    • 英文: Clear descriptions of technical/functional changes.
    • 中文: 清晰描述用户可见的功能改进或修复。
  3. 核查状态 (Verification): 确认版本号已在相关 7+ 处位置同步更新1 个代码文件 + 2 个 README + 4 个 Docs 文件)。

4. 🤖 Git 提交与推送规范 (Git Operations & Push Rules)

  • 核心原则: 默认仅进行本地文件准备更新代码、READMEs、Docs、版本号严禁在未获用户明确许可的情况下自动执行 git commitgit push
  • 允许 (需确认): 只有在用户明确表示“发布”、“Commit it”、“Release”或“提交”后才允许直接推送到 main 分支或创建 PR。
  • 功能分支: 推荐在进行大规模重构或实验性功能开发时,创建功能分支 (feature/xxx) 进行隔离。
  • PR 提交: 必须使用 GitHub CLI (gh) 创建 Pull Request。示例gh pr create --title "feat: ..." --body "..."

5. 🤝 贡献者认可规范 (Contributor Recognition)

使用 @all-contributors please add @username for <type> 指令。


📚 参考资源 (Reference Resources)


Author

Fu-Jie
GitHub: Fu-Jie/openwebui-extensions

License

MIT License