From fe06e88669041768bdbcf3e6c47bfb3fd9dc10a0 Mon Sep 17 00:00:00 2001 From: Keldos Date: Mon, 18 May 2026 00:26:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E4=B8=8E=E9=BB=98=E8=AE=A4=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + ChuanhuChatbot.py | 15 +- config_example.json | 1 + docs/extensions.md | 384 ++++++++++++++ extensions/auto_notes/metadata.json | 16 + extensions/auto_notes/scripts/main.py | 177 +++++++ extensions/auto_notes/style.css | 6 + .../prompt_tools/javascript/prompt_tools.js | 64 +++ extensions/prompt_tools/metadata.json | 16 + extensions/prompt_tools/scripts/main.py | 107 ++++ extensions/prompt_tools/style.css | 54 ++ locale/en_US.json | 31 +- modules/config.py | 2 + modules/extensions.py | 472 ++++++++++++++++++ modules/models/base_model.py | 50 ++ modules/plugin_callbacks.py | 167 +++++++ modules/plugin_context.py | 30 ++ modules/webui.py | 11 +- web_assets/javascript/ChuanhuChat.js | 62 +++ web_assets/javascript/extensions.js | 117 +++++ web_assets/stylesheet/custom-components.css | 177 +++++++ 21 files changed, 1955 insertions(+), 6 deletions(-) create mode 100644 docs/extensions.md create mode 100644 extensions/auto_notes/metadata.json create mode 100644 extensions/auto_notes/scripts/main.py create mode 100644 extensions/auto_notes/style.css create mode 100644 extensions/prompt_tools/javascript/prompt_tools.js create mode 100644 extensions/prompt_tools/metadata.json create mode 100644 extensions/prompt_tools/scripts/main.py create mode 100644 extensions/prompt_tools/style.css create mode 100644 modules/extensions.py create mode 100644 modules/plugin_callbacks.py create mode 100644 modules/plugin_context.py create mode 100644 web_assets/javascript/extensions.js diff --git a/.gitignore b/.gitignore index c74736ce..bad8e558 100644 --- a/.gitignore +++ b/.gitignore @@ -149,6 +149,8 @@ files/ tmp/ scripts/ +!extensions/**/scripts/ +!extensions/**/scripts/*.py include/ pyvenv.cfg diff --git a/ChuanhuChatbot.py b/ChuanhuChatbot.py index 0e545fd8..d823fa42 100644 --- a/ChuanhuChatbot.py +++ b/ChuanhuChatbot.py @@ -14,12 +14,14 @@ from modules.utils import * from modules.config import * from modules import config +from modules import extensions import gradio as gr import colorama logging.getLogger("httpx").setLevel(logging.WARNING) patch_gradio() +extensions.load_extensions(disabled_extensions=disabled_extensions) # with open("web_assets/css/ChuanhuChat.css", "r", encoding="utf-8") as f: # ChuanhuChatCSS = f.read() @@ -344,9 +346,7 @@ def create_new_model(): value=user_name.value, lines=1, ) - with gr.Tab(label=i18n("拓展")): - gr.Markdown( - "Will be here soon...\n(We hope)\n\nAnd we hope you can help us to make more extensions!") + extensions.render_extension_tabs() # changeAPIURLBtn = gr.Button(i18n("🔄 切换API地址")) @@ -357,6 +357,9 @@ def create_new_model(): gr.Markdown("## "+i18n("设置")) gr.HTML(get_html("close_btn.html").format( obj="box"), elem_classes="close-btn") + gr.HTML( + f'' + ) with gr.Tabs(elem_id="chuanhu-setting-tabs"): # with gr.Tab(label=i18n("模型")): @@ -425,6 +428,9 @@ def create_new_model(): elem_classes="view-only-textbox no-container", ) + with gr.Tab(label=i18n("插件")): + extensions.render_extension_manager() + with gr.Tab(label=i18n("关于"), elem_id="about-tab"): gr.Markdown( 'Chuanhu Chat logo') @@ -433,6 +439,8 @@ def create_new_model(): versions=versions_html()), elem_id="footer") gr.Markdown(CHUANHU_DESCRIPTION, elem_id="description") + extensions.render_extension_settings() + with gr.Group(elem_id="chuanhu-training"): with gr.Row(): gr.Markdown("## "+i18n("训练")) @@ -803,6 +811,7 @@ def create_greeting(request: gr.Request): reload_javascript() setup_wizard() _allowed_paths = ["web_assets"] + _allowed_paths.append("extensions") if config.midjourney_temp_folder: _allowed_paths.append(config.midjourney_temp_folder) demo.queue().launch( diff --git a/config_example.json b/config_example.json index 79d30f2a..4e73697e 100644 --- a/config_example.json +++ b/config_example.json @@ -69,6 +69,7 @@ // 是否多个API Key轮换使用 "multi_api_key": false, "hide_my_key": false, // 如果你想在UI中隐藏 API 密钥输入框,将此值设置为 true + "disabled_extensions": [], // 禁用的插件 ID 列表,例如 ["prompt_prefix_demo"] // "available_models": ["GPT3.5 Turbo", "GPT4 Turbo", "GPT4 Vision"], // 可用的模型列表,将覆盖默认的可用模型列表 // "extra_models": ["模型名称3", "模型名称4", ...], // 额外的模型,将添加到可用的模型列表之后 // "extra_model_metadata": { diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 00000000..8904849e --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,384 @@ +# 插件开发指南 + +这份文档面向第三方插件开发者,也面向协助开发插件的 AI。插件系统的目标是让扩展代码在不改动主程序核心文件的情况下,为侧边栏、设置页和对话流程增加轻量能力。 + +当前文档以仓库内可见的插件 API 为准:插件通过 `metadata.json` 声明基本信息,通过 Python 脚本注册回调,通过 CSS / JavaScript 静态资源增强界面。 + +## 快速开始 + +一个最小插件目录如下: + +```text +extensions/ +└── my_extension/ + ├── metadata.json + └── scripts/ + └── main.py +``` + +`metadata.json`: + +```json +{ + "id": "my_extension", + "name": "我的插件", + "version": "0.1.0", + "description": "演示如何注册一个最小插件。", + "enabled": true, + "priority": 100 +} +``` + +`scripts/main.py`: + +```python +from modules.plugin_callbacks import on_before_chat +from modules.plugin_context import ChatContext + + +@on_before_chat +def add_marker(context: ChatContext): + if isinstance(context.user_input, str): + context.user_input = f"[来自插件] {context.user_input}" +``` + +插件脚本被加载时会执行模块顶层代码。使用装饰器注册回调即可,不需要手动调用加载器。 + +## 目录结构 + +推荐结构: + +```text +extensions// +├── metadata.json +├── extension.py # 可选:根级插件脚本 +├── scripts/ # 可选:多个 Python 脚本 +│ ├── main.py +│ └── other.py +├── style.css # 可选:根级样式文件,会自动加载 +├── stylesheet/ # 可选:多个 CSS 文件 +│ └── panel.css +└── javascript/ # 可选:多个 JS / MJS 文件 + └── main.js +``` + +当前加载约定: + +- 插件根目录位于 `extensions//`。 +- 加载器会读取每个插件目录下的 `metadata.json`。 +- Python 脚本支持根级 `extension.py`,以及 `scripts/*.py`。 +- 根级 `style.css` 会自动收集,`stylesheet/*.css` 也会自动收集。 +- `javascript/*.js` 和 `javascript/*.mjs` 会自动收集。 +- 插件按 `priority` 从小到大加载;相同优先级下按插件 ID 排序。 +- 插件可以向侧边栏工具箱注册自己的功能 Tab,也可以向设置页注册自己的设置 Tab。 + +建议把主要逻辑放在 `scripts/main.py`,把界面样式放在 `style.css`,把少量前端增强放在 `javascript/main.js`。不要依赖插件之间的加载顺序,除非你明确控制了 `priority` 且能接受耦合。 + +## metadata.json + +推荐字段: + +```json +{ + "id": "prompt_tools", + "name": "提示词工具箱", + "name_i18n": { + "en_US": "Prompt toolbox" + }, + "version": "0.1.0", + "description": "演示侧边栏功能 Tab,点选模板后把提示词插入当前输入框。", + "description_i18n": { + "en_US": "Demonstrates a toolbox tab that inserts selected prompt templates into the current input box." + }, + "author": "ChuanhuChatGPT", + "enabled": true, + "priority": 100, + "tags": ["prompt", "productivity"] +} +``` + +字段说明: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `id` | string | 推荐 | 插件 ID。未提供时通常会使用目录名。建议只使用小写字母、数字、下划线或短横线。 | +| `name` | string | 推荐 | 展示给用户的插件名称。 | +| `name_i18n` | object | 可选 | 插件自己提供的名称翻译表,例如 `{"en_US": "Prompt toolbox"}`。 | +| `version` | string | 推荐 | 插件版本,例如 `0.1.0`。 | +| `description` | string | 推荐 | 一句话说明插件用途。 | +| `description_i18n` | object | 可选 | 插件自己提供的描述翻译表。 | +| `author` | string | 可选 | 作者或组织。 | +| `enabled` | boolean | 可选 | 是否默认启用。默认通常视为启用。 | +| `priority` | number | 可选 | 加载优先级,数字越小越早加载。 | +| `tags` | array | 可选 | 便于后续管理和检索的标签。 | + +`metadata.json` 必须是合法 JSON。不要写注释,不要使用尾随逗号。 + +## 前端资源自动加载 + +插件可以提供 CSS 和 JavaScript 资源: + +- `style.css` +- `stylesheet/*.css` +- `javascript/*.js` +- `javascript/*.mjs` + +这些资源由核心加载器收集并注入页面,适合做以下事情: + +- 为插件自己的 Gradio 组件补充样式。 +- 为插件区域增加少量交互增强。 +- 给插件生成的 DOM 添加无侵入的视觉提示。 + +建议: + +- CSS 选择器尽量加插件专属前缀,例如 `.prompt-prefix-demo ...`,避免影响主界面和其他插件。 +- JavaScript 不要假设页面内部 DOM 结构长期稳定。 +- 前端脚本应当可重复执行且无副作用;如果需要绑定事件,先检查是否已经绑定。 +- 不要在前端脚本里保存敏感数据。 + +### 前端脚本钩子 + +插件前端脚本可以使用 `window.ChuanhuApp`,避免自己重复监听 `DOMContentLoaded` 或直接穿透 Gradio 的 Shadow DOM: + +```js +(function () { + function bind() { + const root = window.ChuanhuApp.root(); + root.querySelectorAll("[data-my-extension-button]").forEach((button) => { + if (button.dataset.bound) return; + button.dataset.bound = "true"; + button.addEventListener("click", () => { + const input = window.ChuanhuApp.userInput(); + if (!input) return; + window.ChuanhuApp.setInputValue(input, "插入到输入框的内容\n\n" + input.value); + }); + }); + } + + window.ChuanhuApp.onReady(bind); + window.ChuanhuApp.onMutation(bind); +})(); +``` + +可用接口: + +| 接口 | 说明 | +| --- | --- | +| `root()` / `gradioApp()` | 返回当前 Gradio 根节点,兼容 Shadow DOM。 | +| `onReady(callback)` | 主界面初始化完成后执行;如果已经初始化,会立即执行。 | +| `onRender(callback)` | Gradio render 后执行;适合读取主界面已缓存的 DOM。 | +| `onMutation(callback)` | Gradio 根节点内容变化时执行;适合给动态生成的插件 DOM 绑定事件。 | +| `userInput()` | 返回主输入框的 `textarea/input`。 | +| `setInputValue(input, value)` | 设置输入框值并派发 `input/change` 事件。 | + +## 侧边栏功能 Tab + +插件可以向侧边栏工具箱注册自己的 Tab,用于放置对当前聊天流程影响较大的开关、输入框或按钮。通过 `on_toolbox_tab` 注册: + +```python +import gradio as gr + +from modules.plugin_callbacks import on_toolbox_tab + +STATE = {"enabled": True} +TEXT = { + "zh_CN": {"enable": "启用我的插件"}, + "en_US": {"enable": "Enable my extension"}, +} + + +def tr(key, language="zh_CN"): + return TEXT.get(language, TEXT["zh_CN"]).get(key, key) + + +def set_enabled(value): + STATE["enabled"] = bool(value) + + +@on_toolbox_tab +def render_controls(): + with gr.Group(elem_classes="my-extension-controls"): + enabled = gr.Checkbox(label=tr("enable"), value=STATE["enabled"]) + enabled.change(set_enabled, inputs=enabled) +``` + +核心会自动用插件名称创建 Tab,回调只负责渲染 Tab 内部内容。示例里通过 Gradio 组件事件把值写入插件模块内的 `STATE`,后续对话 hook 再读取这个状态。 + +## 设置页设置 Tab + +插件可以向设置页注册自己的设置 Tab,用于放置不需要频繁修改的选项。通过 `on_settings_tab` 注册: + +```python +import gradio as gr + +from modules.plugin_callbacks import on_settings_tab + +CONFIG = {"footer": "由插件追加"} +TEXT = { + "zh_CN": {"footer": "追加文本"}, + "en_US": {"footer": "Footer text"}, +} + + +def tr(key, language="zh_CN"): + return TEXT.get(language, TEXT["zh_CN"]).get(key, key) + + +def set_footer(value): + CONFIG["footer"] = value or "" + + +@on_settings_tab +def render_settings(): + with gr.Group(elem_classes="my-extension-settings"): + footer = gr.Textbox(label=tr("footer"), value=CONFIG["footer"]) + footer.change(set_footer, inputs=footer) +``` + +核心会自动用插件名称创建设置 Tab。设置页 UI 和侧边栏 UI 的写法一致,区别主要是放置位置和使用场景。 + +## 插件与插件设置 + +设置页里的“插件”标签页只用于管理插件生命周期,不承载插件自己的业务设置。当前内置能力包括: + +- 查看已发现插件的名称、ID、版本、状态、路径和错误信息。 +- 使用开关启用或禁用插件。运行时禁用会立即停止该插件已注册的 Python hooks。 +- 从 Git URL 或本地目录安装插件。 +- 刷新插件列表。 +- 对 Git 仓库形式安装的插件执行更新。 + +插件自己的设置项由 `on_settings_tab` 注册,并显示为设置页里的插件专属 Tab。这一点参考 SD WebUI 的分工:Extensions 页面负责安装、启用、更新等管理;扩展自己的配置通过 settings/options 机制进入全局设置区域,而不是混在 Extensions 管理页里。 + +注意: + +- 新安装插件或更新插件后,Python hooks 会尝试重新加载;CSS / JavaScript 等前端静态资源仍建议刷新页面或重启应用后生效。 +- 启用 / 禁用开关当前是运行时状态;如需默认禁用某插件,可在 `config.json` 中配置 `disabled_extensions`。 +- 插件安装和更新会执行本地文件复制或 `git clone` / `git pull`。只安装可信来源的插件。 + +## 对话生命周期 Hooks + +当前插件 API 包含这些对话 hook: + +| Hook | 注册函数 | 典型用途 | +| --- | --- | --- | +| 对话开始前 | `on_before_chat` | 修改用户输入、记录元数据、根据插件设置调整上下文。 | +| 输入准备后 | `on_after_prepare` | 查看或调整 RAG / 联网搜索处理后的输入、展示附加内容。 | +| 模型调用前 | `on_before_model_call` | 在模型请求前观察最终上下文,或做轻量参数调整。 | +| 模型完整回答后 | `on_after_chat` | 修改最终回复、追加说明、写入插件处理结果。 | +| 对话异常时 | `on_chat_error` | 记录插件自己的错误状态或做降级提示。 | +| 历史保存后 | `on_after_history_saved` | 同步导出、写日志、通知外部系统。 | + +导入方式: + +```python +from modules.plugin_callbacks import on_before_chat, on_after_chat +from modules.plugin_context import ChatContext +``` + +推荐把 hook 写成接收一个 `ChatContext` 参数,并就地修改上下文对象: + +```python +@on_before_chat +def before_chat(context: ChatContext): + context.metadata["my_extension_enabled"] = True + + +@on_after_chat +def after_chat(context: ChatContext): + if context.assistant_reply: + context.assistant_reply += "\n\n由插件处理。" +``` + +不要依赖 hook 的返回值,除非核心 API 明确说明某个返回值会被消费。 + +## ChatContext 字段 + +`ChatContext` 表示一次对话流程中传递给插件的上下文。当前字段如下: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `model` | `Any` | 当前模型或模型配置对象。 | +| `user_input` | `Any` | 用户原始输入或当前输入。插件修改输入时应先判断类型。 | +| `chatbot` | `list` | 当前聊天记录 / UI 消息列表。 | +| `use_websearch` | `bool` | 当前是否启用联网搜索。 | +| `files` | `list | None` | 当前消息关联文件。 | +| `reply_language` | `str` | 回复语言,默认 `中文`。 | +| `limited_context` | `bool` | 是否使用有限上下文。 | +| `fake_input` | `str` | 展示或替代输入相关字段,按核心流程解释使用。 | +| `display_append` | `str` | 用于追加展示内容的字段,按核心流程解释使用。 | +| `prepared_input` | `Any` | 预处理后的输入。 | +| `assistant_reply` | `str | None` | 模型完整回复。`after_chat` 常用。 | +| `status_text` | `str` | 当前状态文本。 | +| `history_file_path` | `str | None` | 历史记录文件路径。 | +| `metadata` | `dict[str, Any]` | 插件之间或插件内部传递轻量元数据的字典。 | + +使用建议: + +- 修改 `user_input`、`assistant_reply` 前先判断类型。 +- 插件自定义数据放在 `metadata` 里,并使用唯一 key,例如 `metadata["prompt_tools"]`。 +- 不要把大文件、模型对象副本或不可序列化的大对象塞进 `metadata`。 +- 不确定字段语义时,优先只读,不要写入。 + +## 错误处理 + +插件脚本加载失败或回调执行异常时,核心会记录插件错误,并尽量不影响其他插件继续运行。插件自身仍应主动处理可预期错误: + +```python +@on_after_chat +def after_chat(context: ChatContext): + try: + if isinstance(context.assistant_reply, str): + context.assistant_reply += "\n\n插件追加内容。" + except Exception as exc: + context.metadata["my_extension_error"] = str(exc) +``` + +建议: + +- 对用户输入、文件列表、模型返回值做类型检查。 +- 网络请求、文件读写、第三方库调用必须捕获异常。 +- 错误信息尽量写入 `metadata` 或日志,不要把 Python traceback 原样展示给普通用户。 +- 插件失败时应降级为“不处理”,避免阻断主对话。 + +## 推荐实践 + +开发原则: + +- 只在自己的插件目录内放置文件,不修改主程序核心文件。 +- 插件 ID、CSS class、metadata key 使用统一前缀。 +- hook 逻辑保持短小,耗时任务应谨慎处理,避免拖慢聊天响应。 +- 不要在模块顶层执行耗时操作;模块顶层只注册回调和初始化轻量默认值。 +- 使用 `gr.Group`、`gr.Accordion` 等容器组织插件 UI,避免界面过散。 +- 默认配置应安全、可撤销、容易理解。 + +协作原则: + +- 示例和文档应说明依赖的核心 API,不要暗示未确认的能力已经存在。 +- 对尚未实现的能力,只描述当前边界,不要写成已经支持。 +- 修改插件时只改插件目录和对应文档,避免顺手改核心模块。 +- 示例插件应小而完整:一个插件演示一个主要能力。 + +安全原则: + +- 不要在插件中硬编码 API Key、访问令牌或用户隐私数据。 +- 不要默认上传用户输入、聊天记录或文件。 +- 如需访问外部网络,应提供清晰开关和说明。 +- 对插入 HTML / Markdown 的内容进行最小化处理,避免意外注入。 + +## 示例插件 + +仓库内提供两个默认插件,它们既可以直接使用,也可以作为开发参考: + +- `extensions/prompt_tools`:提示词工具箱。它在侧边栏提供润色、翻译、总结、解释代码、提取待办、起草邮件等常用模板按钮,点击后把提示词插入当前输入框,不在发送阶段隐式改写内容。 +- `extensions/auto_notes`:自动笔记。它在侧边栏提供记录开关、标题和标签,在设置页提供保存文件名和完整回答选项,并通过 `after_chat` 把问答追加到 Markdown 笔记。 + +这两个插件主要依赖: + +```python +from modules.plugin_callbacks import ( + on_toolbox_tab, + on_settings_tab, + on_after_chat, +) +from modules.plugin_context import ChatContext +``` diff --git a/extensions/auto_notes/metadata.json b/extensions/auto_notes/metadata.json new file mode 100644 index 00000000..ee83787f --- /dev/null +++ b/extensions/auto_notes/metadata.json @@ -0,0 +1,16 @@ +{ + "id": "auto_notes", + "name": "自动笔记", + "name_i18n": { + "en_US": "Auto notes" + }, + "version": "0.1.0", + "description": "把启用期间的问答自动追加到 Markdown 笔记,适合沉淀会议纪要、学习记录和调试日志。", + "description_i18n": { + "en_US": "Append Q&A during enabled sessions to a Markdown note, useful for meeting notes, study logs, and debugging records." + }, + "author": "ChuanhuChatGPT", + "enabled": true, + "priority": 110, + "tags": ["notes", "archive", "after_chat"] +} diff --git a/extensions/auto_notes/scripts/main.py b/extensions/auto_notes/scripts/main.py new file mode 100644 index 00000000..b0c3a398 --- /dev/null +++ b/extensions/auto_notes/scripts/main.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from datetime import datetime +import locale +import os +from pathlib import Path +import re + +import commentjson as json +import gradio as gr + +from modules.plugin_callbacks import on_after_chat, on_settings_tab, on_toolbox_tab +from modules.plugin_context import ChatContext + + +PLUGIN_DIR = Path(__file__).resolve().parents[1] +DATA_DIR = PLUGIN_DIR / "data" + +TRANSLATIONS = { + "en_US": { + "川虎 Chat 自动笔记": "Chuanhu Chat auto notes", + "记录后续问答": "Record future Q&A", + "本次笔记标题": "Note title", + "标签": "Tags", + "例如:会议, 需求, 调试": "For example: meeting, requirements, debugging", + "保存文件名": "Save filename", + "保存完整回答": "Save full answers", + "笔记会保存到": "Notes will be saved to", + "(已截断,可在设置中启用完整回答)": "(truncated; enable full answers in settings)", + "(未捕获到用户输入)": "(user input was not captured)", + "标签:": "Tags: ", + "用户": "User", + "助手": "Assistant", + } +} + + +def _language(): + language = "auto" + config_path = Path("config.json") + if config_path.exists(): + try: + with config_path.open("r", encoding="utf-8") as f: + language = json.load(f).get("language", language) + except Exception: + pass + language = os.environ.get("LANGUAGE", language).replace("-", "_") + if language == "auto": + language = locale.getdefaultlocale()[0] or "zh_CN" + return language + + +LANGUAGE = _language() + + +def tr(text: str): + if LANGUAGE.startswith("zh"): + return text + translations = TRANSLATIONS.get(LANGUAGE) or TRANSLATIONS.get(LANGUAGE.split("_", 1)[0]) or TRANSLATIONS["en_US"] + return translations.get(text, text) + +STATE = { + "enabled": False, + "title": tr("川虎 Chat 自动笔记"), + "tags": "", + "filename": "notes.md", + "include_full_answer": True, +} + + +def _set_enabled(value: bool): + STATE["enabled"] = bool(value) + + +def _set_title(value: str): + STATE["title"] = value or tr("川虎 Chat 自动笔记") + + +def _set_tags(value: str): + STATE["tags"] = value or "" + + +def _set_filename(value: str): + value = value or "notes.md" + value = re.sub(r"[^A-Za-z0-9_.-]", "_", value) + if not value.endswith(".md"): + value += ".md" + STATE["filename"] = value + + +def _set_include_full_answer(value: bool): + STATE["include_full_answer"] = bool(value) + + +@on_toolbox_tab +def render_controls(): + with gr.Group(elem_classes="auto-notes"): + enabled = gr.Checkbox(label=tr("记录后续问答"), value=STATE["enabled"]) + title = gr.Textbox(label=tr("本次笔记标题"), value=STATE["title"], lines=1) + tags = gr.Textbox(label=tr("标签"), value=STATE["tags"], lines=1, placeholder=tr("例如:会议, 需求, 调试")) + + enabled.change(_set_enabled, inputs=enabled) + title.change(_set_title, inputs=title) + tags.change(_set_tags, inputs=tags) + + +@on_settings_tab +def render_settings(): + filename = gr.Textbox( + label=tr("保存文件名"), + value=STATE["filename"], + lines=1, + elem_classes="no-container", + ) + include_full_answer = gr.Checkbox( + label=tr("保存完整回答"), + value=STATE["include_full_answer"], + elem_classes="switch-checkbox", + ) + gr.Markdown(tr("笔记会保存到") + f" `{DATA_DIR}`。", elem_classes="auto-notes-muted") + + filename.change(_set_filename, inputs=filename) + include_full_answer.change(_set_include_full_answer, inputs=include_full_answer) + + +def _extract_user_text(context: ChatContext) -> str: + if context.fake_input: + return context.fake_input + if isinstance(context.user_input, str): + return context.user_input + if isinstance(context.user_input, list) and context.user_input: + first = context.user_input[0] + if isinstance(first, dict) and isinstance(first.get("text"), str): + return first["text"] + return "" + + +def _clip_answer(answer: str) -> str: + if STATE["include_full_answer"]: + return answer + limit = 1200 + if len(answer) <= limit: + return answer + return answer[:limit].rstrip() + "\n\n..." + tr("(已截断,可在设置中启用完整回答)") + + +def _note_path() -> Path: + DATA_DIR.mkdir(parents=True, exist_ok=True) + return DATA_DIR / STATE["filename"] + + +@on_after_chat +def append_note(context: ChatContext): + if not STATE["enabled"]: + return + if not isinstance(context.assistant_reply, str) or not context.assistant_reply.strip(): + return + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + user_text = _extract_user_text(context).strip() or tr("(未捕获到用户输入)") + answer = _clip_answer(context.assistant_reply.strip()) + tags = STATE["tags"].strip() + tags_line = f"\n{tr('标签:')}{tags}" if tags else "" + + entry = ( + f"\n\n## {STATE['title']} - {now}\n" + f"{tags_line}\n\n" + f"### {tr('用户')}\n\n{user_text}\n\n" + f"### {tr('助手')}\n\n{answer}\n" + ) + path = _note_path() + if not path.exists(): + path.write_text(f"# {STATE['title']}\n", encoding="utf-8") + with path.open("a", encoding="utf-8") as f: + f.write(entry) + + context.metadata["auto_notes"] = {"path": str(path)} diff --git a/extensions/auto_notes/style.css b/extensions/auto_notes/style.css new file mode 100644 index 00000000..2bbb5c07 --- /dev/null +++ b/extensions/auto_notes/style.css @@ -0,0 +1,6 @@ +.auto-notes-muted, +.auto-notes-muted p { + color: var(--body-text-color-subdued); + font-size: var(--text-xs); + opacity: .72; +} diff --git a/extensions/prompt_tools/javascript/prompt_tools.js b/extensions/prompt_tools/javascript/prompt_tools.js new file mode 100644 index 00000000..ffd19167 --- /dev/null +++ b/extensions/prompt_tools/javascript/prompt_tools.js @@ -0,0 +1,64 @@ +(function () { + function appRoot() { + return window.ChuanhuApp?.root?.() || document; + } + + function currentInput() { + return window.ChuanhuApp?.userInput?.() || appRoot().querySelector("#user-input-tb textarea, #user-input-tb input"); + } + + function selectedLanguage(button) { + const panel = button.closest(".prompt-tools-panel"); + return panel?.querySelector("[data-prompt-tools-language]")?.value || "中文"; + } + + function emitInput(input) { + if (!window.ChuanhuApp?.setInputValue?.(input, input.value)) { + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + } + input.focus(); + } + + function applyTemplate(input, template, language) { + const prompt = template.replaceAll("{language}", language); + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? input.value.length; + const selected = input.value.slice(start, end); + if (selected.trim()) { + input.setRangeText(`${prompt}\n\n${selected}`, start, end, "end"); + return; + } + if (input.value.trim()) { + input.value = `${prompt}\n\n${input.value}`; + input.selectionStart = input.selectionEnd = prompt.length + 2; + return; + } + input.value = `${prompt}\n\n`; + input.selectionStart = input.selectionEnd = input.value.length; + } + + function bindPromptButtons() { + appRoot().querySelectorAll("[data-prompt-tools-template]").forEach((button) => { + if (button.dataset.promptToolsBound) return; + button.dataset.promptToolsBound = "true"; + button.addEventListener("click", () => { + const input = currentInput(); + if (!input) return; + applyTemplate(input, button.dataset.promptToolsTemplate || "", selectedLanguage(button)); + emitInput(input); + }); + }); + } + + if (window.ChuanhuApp?.onReady) { + window.ChuanhuApp.onReady(bindPromptButtons); + window.ChuanhuApp.onMutation(bindPromptButtons); + } else { + const observer = new MutationObserver(bindPromptButtons); + window.addEventListener("DOMContentLoaded", () => { + bindPromptButtons(); + observer.observe(document.body, { childList: true, subtree: true }); + }); + } +})(); diff --git a/extensions/prompt_tools/metadata.json b/extensions/prompt_tools/metadata.json new file mode 100644 index 00000000..8367186b --- /dev/null +++ b/extensions/prompt_tools/metadata.json @@ -0,0 +1,16 @@ +{ + "id": "prompt_tools", + "name": "提示词工具箱", + "name_i18n": { + "en_US": "Prompt toolbox" + }, + "version": "0.1.0", + "description": "在侧边栏点选常用提示词模板,并自动插入到当前输入框。", + "description_i18n": { + "en_US": "Click common prompt templates in the toolbox and insert them into the current input box." + }, + "author": "ChuanhuChatGPT", + "enabled": true, + "priority": 100, + "tags": ["prompt", "productivity", "toolbox"] +} diff --git a/extensions/prompt_tools/scripts/main.py b/extensions/prompt_tools/scripts/main.py new file mode 100644 index 00000000..6dc9ca6a --- /dev/null +++ b/extensions/prompt_tools/scripts/main.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import html +import locale +import os +from pathlib import Path + +import commentjson as json +import gradio as gr + +from modules.plugin_callbacks import on_toolbox_tab + + +TRANSLATIONS = { + "en_US": { + "输出语言": "Output language", + "中文": "Chinese", + "润色": "Polish", + "翻译": "Translate", + "总结": "Summarize", + "解释代码": "Explain code", + "提取待办": "Extract todos", + "写邮件": "Draft email", + "评审改进": "Review and improve", + "会议纪要": "Meeting notes", + "点击按钮会把对应提示词插入到当前输入框。若输入框已有内容,会自动把提示词放到内容前面。": "Click a button to insert that prompt into the current input box. If the input box already has content, the prompt is prepended automatically.", + "请润色以下内容,使其清晰、自然、专业。输出语言:{language}。": "Polish the following content so it is clear, natural, and professional. Output language: {language}.", + "请把以下内容翻译为{language},要求自然、准确,保留专有名词和原有格式。": "Translate the following content into {language}. Make it natural and accurate, and preserve proper nouns and the original format.", + "请总结以下内容。输出语言:{language}。先给 3-5 条要点,再列出重要细节和后续行动。": "Summarize the following content. Output language: {language}. Start with 3-5 bullet points, then list important details and next actions.", + "请解释以下代码或技术内容。输出语言:{language}。说明它做了什么、关键逻辑、潜在问题和改进建议。": "Explain the following code or technical content. Output language: {language}. Cover what it does, key logic, potential issues, and improvements.", + "请从以下内容中提取待办事项。输出语言:{language}。按负责人、事项、截止时间、依赖或风险整理;未知项标为“未指定”。": "Extract todos from the following content. Output language: {language}. Organize by owner, task, due date, dependencies or risks; mark unknown fields as \"unspecified\".", + "请根据以下内容起草一封邮件。输出语言:{language}。包含主题、称呼、正文和结尾,语气清晰得体。": "Draft an email from the following content. Output language: {language}. Include subject, salutation, body, and closing with a clear and appropriate tone.", + "请评审以下内容。输出语言:{language}。指出问题、风险、遗漏点,并给出可执行的修改建议。": "Review the following content. Output language: {language}. Point out issues, risks, omissions, and actionable improvements.", + "请把以下内容整理成会议纪要。输出语言:{language}。包含背景、结论、决策、待办事项和风险。": "Turn the following content into meeting notes. Output language: {language}. Include background, conclusions, decisions, action items, and risks.", + } +} + +PROMPTS = [ + ("润色", "请润色以下内容,使其清晰、自然、专业。输出语言:{language}。"), + ("翻译", "请把以下内容翻译为{language},要求自然、准确,保留专有名词和原有格式。"), + ("总结", "请总结以下内容。输出语言:{language}。先给 3-5 条要点,再列出重要细节和后续行动。"), + ("解释代码", "请解释以下代码或技术内容。输出语言:{language}。说明它做了什么、关键逻辑、潜在问题和改进建议。"), + ("提取待办", "请从以下内容中提取待办事项。输出语言:{language}。按负责人、事项、截止时间、依赖或风险整理;未知项标为“未指定”。"), + ("写邮件", "请根据以下内容起草一封邮件。输出语言:{language}。包含主题、称呼、正文和结尾,语气清晰得体。"), + ("评审改进", "请评审以下内容。输出语言:{language}。指出问题、风险、遗漏点,并给出可执行的修改建议。"), + ("会议纪要", "请把以下内容整理成会议纪要。输出语言:{language}。包含背景、结论、决策、待办事项和风险。"), +] + + +def _language(): + language = "auto" + config_path = Path("config.json") + if config_path.exists(): + try: + with config_path.open("r", encoding="utf-8") as f: + language = json.load(f).get("language", language) + except Exception: + pass + language = os.environ.get("LANGUAGE", language).replace("-", "_") + if language == "auto": + language = locale.getdefaultlocale()[0] or "zh_CN" + return language + + +LANGUAGE = _language() + + +def tr(text: str): + if LANGUAGE.startswith("zh"): + return text + translations = TRANSLATIONS.get(LANGUAGE) or TRANSLATIONS.get(LANGUAGE.split("_", 1)[0]) or TRANSLATIONS["en_US"] + return translations.get(text, text) + + +def _button(label: str, template: str): + return ( + '" + ) + + +def _render_panel(): + buttons = "\n".join(_button(label, template) for label, template in PROMPTS) + return f""" +
+ +
{buttons}
+

{html.escape(tr("点击按钮会把对应提示词插入到当前输入框。若输入框已有内容,会自动把提示词放到内容前面。"))}

+
+ """ + + +@on_toolbox_tab +def render_controls(): + gr.HTML(_render_panel()) diff --git a/extensions/prompt_tools/style.css b/extensions/prompt_tools/style.css new file mode 100644 index 00000000..6d57c15e --- /dev/null +++ b/extensions/prompt_tools/style.css @@ -0,0 +1,54 @@ +.prompt-tools-panel { + display: flex; + flex-direction: column; + gap: 10px; +} + +.prompt-tools-language { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--body-text-color); + font-size: var(--text-sm); + font-weight: 600; +} + +.prompt-tools-language select { + border: none; + border-radius: var(--input-radius); + background: var(--input-background-fill); + box-shadow: var(--input-shadow); + color: var(--body-text-color); + font: inherit; + padding: 8px 10px; +} + +.prompt-tools-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.prompt-tools-button { + border: none; + border-radius: var(--button-large-radius); + background: var(--button-secondary-background-fill); + box-shadow: var(--button-shadow); + color: var(--button-secondary-text-color); + cursor: pointer; + font: inherit; + font-weight: 600; + padding: 8px 10px; +} + +.prompt-tools-button:hover { + background: var(--button-secondary-background-fill-hover); +} + +.prompt-tools-hint { + color: var(--body-text-color-subdued); + font-size: var(--text-xs); + line-height: 1.4; + margin: 0; + opacity: .72; +} diff --git a/locale/en_US.json b/locale/en_US.json index d0faf30b..3550eff0 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -154,6 +154,35 @@ "您输入的 API 密钥为:": "The API key you entered is:", "找到了缓存的索引文件,加载中……": "Found cached index file, loading...", "拓展": "Extensions", + "插件": "Extensions", + "插件设置": "Extension settings", + "插件安装": "Install extension", + "已安装插件": "Installed extensions", + "插件错误": "Extension errors", + "插件列表已刷新。": "Extension list refreshed.", + "插件操作参数无效。": "Invalid extension action parameters.", + "未知插件操作。": "Unknown extension action.", + "未发现插件。": "No extensions found.", + "启用": "Enable", + "安装": "Install", + "版本": "Version", + "已启用": "Enabled", + "已禁用": "Disabled", + "Git URL 或本地插件目录": "Git URL or local extension folder", + "刷新插件列表": "Refresh extension list", + "更新全部 Git 插件": "Update all Git extensions", + "请输入 Git URL 或本地插件目录。": "Enter a Git URL or local extension folder.", + "目标插件目录已存在:": "Target extension folder already exists: ", + "本地插件目录不存在:": "Local extension folder does not exist: ", + "插件已安装,请刷新页面或重启应用以加载新的前端资源。": "Extension installed. Refresh the page or restart the app to load new frontend assets.", + "插件已更新,请刷新页面或重启应用以加载新的前端资源。": "Extension updated. Refresh the page or restart the app to load new frontend assets.", + "插件启用失败:": "Failed to enable extension: ", + "插件已启用,前端静态资源变化需要刷新页面后生效。": "Extension enabled. Frontend asset changes take effect after refreshing the page.", + "插件已禁用,已注册的 Python 钩子会立即停止执行。": "Extension disabled. Registered Python hooks stop running immediately.", + "该插件不是 Git 仓库,无法自动更新。": "This extension is not a Git repository and cannot be updated automatically.", + "没有可自动更新的 Git 插件。": "No Git extensions can be updated automatically.", + "未找到插件:": "Extension not found: ", + "错误:": "Error: ", "搜索(支持正则)...": "Search (supports regex)...", "数据集预览": "Dataset Preview", "文件ID": "File ID", @@ -285,4 +314,4 @@ "gpt5nano_description": "Fastest, most cost-efficient version of GPT-5. 400,000-token context window and up to 128,000 output tokens.", "o1_description": "The o1 series of large language models are trained with reinforcement learning to perform complex reasoning. o1 models think before they answer, producing a long internal chain of thought before responding to the user.", "no_permission_to_update_description": "You do not have permission to update. Please contact the administrator. The administrator's configuration method is to add the username to the admin_list in the configuration file config.json." -} \ No newline at end of file +} diff --git a/modules/config.py b/modules/config.py index 4adf6c9a..047a5926 100644 --- a/modules/config.py +++ b/modules/config.py @@ -35,6 +35,7 @@ "chat_name_method_index", "HIDE_MY_KEY", "hfspaceflag", + "disabled_extensions", ] # 添加一个统一的config文件,避免文件过多造成的疑惑(优先级最低) @@ -58,6 +59,7 @@ def load_config_to_environ(key_list): show_api_billing = config.get("show_api_billing", False) show_api_billing = bool(os.environ.get("SHOW_API_BILLING", show_api_billing)) chat_name_method_index = config.get("chat_name_method_index", 2) +disabled_extensions = config.get("disabled_extensions", []) if os.path.exists("api_key.txt"): logging.info("检测到api_key.txt文件,正在进行迁移...") diff --git a/modules/extensions.py b/modules/extensions.py new file mode 100644 index 00000000..f6496291 --- /dev/null +++ b/modules/extensions.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import importlib.util +import json +import logging +import os +from pathlib import Path +import shutil +import subprocess +import sys +import traceback +from html import escape + +import gradio as gr + +from . import plugin_callbacks +from . import shared +from .presets import i18n + + +EXTENSIONS_DIR = Path(shared.chuanhu_path) / "extensions" + + +@dataclass +class Extension: + id: str + path: Path + name: str + version: str = "" + enabled: bool = True + priority: int = 100 + metadata: dict = field(default_factory=dict) + loaded_scripts: list[str] = field(default_factory=list) + error: str | None = None + + +_loaded_extensions: list[Extension] = [] +_loaded = False +_configured_disabled_extensions: list[str] = [] + + +def extensions_dir() -> Path: + EXTENSIONS_DIR.mkdir(exist_ok=True) + return EXTENSIONS_DIR + + +def get_loaded_extensions() -> list[Extension]: + return list(_loaded_extensions) + + +def _read_metadata(path: Path) -> dict: + metadata_path = path / "metadata.json" + if not metadata_path.exists(): + return {} + with metadata_path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def _extension_from_path(path: Path, disabled_extensions: set[str]) -> Extension: + metadata = _read_metadata(path) + extension_id = metadata.get("id") or path.name + enabled = bool(metadata.get("enabled", True)) and extension_id not in disabled_extensions + return Extension( + id=extension_id, + path=path, + name=metadata.get("name") or extension_id, + version=metadata.get("version", ""), + enabled=enabled, + priority=int(metadata.get("priority", 100)), + metadata=metadata, + ) + + +def discover_extensions(disabled_extensions: list[str] | None = None) -> list[Extension]: + disabled = set(disabled_extensions or []) + root = extensions_dir() + extensions = [] + for child in sorted(root.iterdir(), key=lambda p: p.name.lower()): + if child.is_dir() and not child.name.startswith("."): + if not (child / "metadata.json").exists() and not _script_paths(Extension(id=child.name, path=child, name=child.name)): + continue + try: + extensions.append(_extension_from_path(child, disabled)) + except Exception as exc: + extension = Extension(id=child.name, path=child, name=child.name, enabled=False) + extension.error = i18n("读取 metadata.json 失败:") + str(exc) + extensions.append(extension) + return sorted(extensions, key=lambda item: (item.priority, item.id.lower())) + + +def _script_paths(extension: Extension) -> list[Path]: + paths = [] + root_script = extension.path / "extension.py" + if root_script.exists(): + paths.append(root_script) + scripts_dir = extension.path / "scripts" + if scripts_dir.exists(): + paths.extend(sorted(scripts_dir.glob("*.py"), key=lambda p: p.name.lower())) + return paths + + +def _load_script(extension: Extension, script_path: Path): + module_name = f"chuanhu_extension_{extension.id}_{script_path.stem}".replace("-", "_") + spec = importlib.util.spec_from_file_location(module_name, script_path) + if spec is None or spec.loader is None: + raise RuntimeError(i18n("无法加载插件脚本:") + str(script_path)) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + previous_path = list(sys.path) + sys.path.insert(0, str(extension.path)) + try: + with plugin_callbacks.extension_context(extension.id): + spec.loader.exec_module(module) + finally: + sys.path = previous_path + extension.loaded_scripts.append(str(script_path.relative_to(extension.path))) + + +def load_extensions(disabled_extensions: list[str] | None = None, force: bool = False): + global _loaded, _loaded_extensions, _configured_disabled_extensions + if _loaded and not force: + return get_loaded_extensions() + if disabled_extensions is not None: + _configured_disabled_extensions = list(disabled_extensions) + else: + disabled_extensions = _configured_disabled_extensions + plugin_callbacks.clear_callbacks() + _loaded_extensions = discover_extensions(disabled_extensions) + for extension in _loaded_extensions: + plugin_callbacks.set_extension_enabled(extension.id, extension.enabled and not extension.error) + for extension in _loaded_extensions: + if not extension.enabled or extension.error: + continue + for script_path in _script_paths(extension): + try: + _load_script(extension, script_path) + except Exception as exc: + traceback.print_exc() + extension.error = f"{script_path.name}: {exc}" + plugin_callbacks.set_extension_enabled(extension.id, False) + plugin_callbacks.register_error(extension.id, extension.error) + break + _loaded = True + logging.info(i18n("已加载 {count} 个插件").format(count=len([x for x in _loaded_extensions if x.enabled and not x.error]))) + return get_loaded_extensions() + + +def _collect_files(extension: Extension, relative_dirs: list[str], suffixes: tuple[str, ...]) -> list[Path]: + files = [] + for relative_dir in relative_dirs: + folder = extension.path / relative_dir + if folder.exists(): + files.extend(path for path in sorted(folder.iterdir(), key=lambda p: p.name.lower()) if path.suffix.lower() in suffixes) + return files + + +def javascript_files() -> list[Path]: + load_extensions() + files = [] + for extension in _loaded_extensions: + if extension.enabled and not extension.error: + files.extend(_collect_files(extension, ["javascript"], (".js", ".mjs"))) + return files + + +def stylesheet_files() -> list[Path]: + load_extensions() + files = [] + for extension in _loaded_extensions: + if extension.enabled and not extension.error: + style = extension.path / "style.css" + if style.exists(): + files.append(style) + files.extend(_collect_files(extension, ["stylesheet"], (".css",))) + return files + + +def _metadata_text(extension: Extension, key: str, fallback: str = ""): + value = extension.metadata.get(key) or fallback + translations = extension.metadata.get(f"{key}_i18n") + if not isinstance(translations, dict): + return value + language = getattr(i18n, "language", "zh_CN") or "zh_CN" + candidates = [language, language.split("_", 1)[0], "en_US", "en"] + for candidate in candidates: + translated = translations.get(candidate) + if translated: + return translated + return value + + +def _extension_title(extension_id: str): + extension = _find_extension(extension_id) + if extension is None: + return extension_id + return _metadata_text(extension, "name", extension.name) + + +def _extension_description(extension: Extension): + return _metadata_text(extension, "description", "") + + +def _render_callback_tabs(kind: str): + for record in plugin_callbacks.iter_callbacks(kind): + title = _extension_title(record.extension_id) + tab_classes = "extension-generated-tab" + if kind == "extension_settings": + tab_classes += " extension-settings-generated-tab" + with gr.Tab(label=title, elem_classes=tab_classes): + try: + record.callback() + except Exception as exc: + message = "".join(traceback.format_exception_only(type(exc), exc)).strip() + plugin_callbacks.register_error(record.extension_id, f"{record.name}: {message}") + gr.Markdown(i18n("加载插件界面失败:") + f"`{message}`") + + +def render_extension_tabs(): + _render_callback_tabs("extension_controls") + + +def _extension_status(extension: Extension): + if extension.error: + return i18n("错误") + if not extension.enabled: + return i18n("已禁用") + return i18n("已启用") + + +def _is_git_extension(extension: Extension): + return (extension.path / ".git").exists() + + +def _git_extension_has_updates(extension: Extension): + if not _is_git_extension(extension): + return False + try: + subprocess.run( + ["git", "-C", str(extension.path), "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + errors="ignore", + ) + result = subprocess.run( + ["git", "-C", str(extension.path), "rev-list", "--count", "HEAD..@{u}"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + errors="ignore", + ) + return int((result.stdout or "0").strip() or "0") > 0 + except Exception: + return False + + +def _safe_extension_name(source: str): + name = source.rstrip("/").split("/")[-1] + if name.endswith(".git"): + name = name[:-4] + name = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in name) + return name or "new_extension" + + +def _find_extension(extension_id: str): + for extension in _loaded_extensions: + if extension.id == extension_id: + return extension + return None + + +def _set_extension_enabled(extension_id: str, enabled: bool): + extension = _find_extension(extension_id) + if extension is None: + return i18n("未找到插件:") + extension_id + extension.enabled = bool(enabled) + if enabled and not extension.loaded_scripts and not extension.error: + for script_path in _script_paths(extension): + try: + _load_script(extension, script_path) + except Exception as exc: + traceback.print_exc() + extension.error = f"{script_path.name}: {exc}" + plugin_callbacks.register_error(extension.id, extension.error) + break + plugin_callbacks.set_extension_enabled(extension.id, extension.enabled and not extension.error) + if extension.error: + return i18n("插件启用失败:") + extension.error + if extension.enabled: + return i18n("插件已启用,前端静态资源变化需要刷新页面后生效。") + return i18n("插件已禁用,已注册的 Python 钩子会立即停止执行。") + + +def install_extension(source: str): + source = (source or "").strip() + if not source: + return i18n("请输入 Git URL 或本地插件目录。") + target_name = _safe_extension_name(source) + target_path = extensions_dir() / target_name + if target_path.exists(): + return i18n("目标插件目录已存在:") + str(target_path) + try: + if source.startswith(("http://", "https://", "git@")) or source.endswith(".git"): + subprocess.run( + ["git", "clone", "--depth", "1", source, str(target_path)], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + errors="ignore", + ) + else: + source_path = Path(source).expanduser().resolve() + if not source_path.is_dir(): + return i18n("本地插件目录不存在:") + str(source_path) + shutil.copytree(source_path, target_path) + load_extensions(force=True) + return i18n("插件已安装,请刷新页面或重启应用以加载新的前端资源。") + except subprocess.CalledProcessError as exc: + return i18n("插件安装失败:") + (exc.stderr or str(exc)) + except Exception as exc: + return i18n("插件安装失败:") + str(exc) + + +def update_extension(extension_id: str): + extension = _find_extension(extension_id) + if extension is None: + return i18n("未找到插件:") + extension_id + if not _is_git_extension(extension): + return i18n("该插件不是 Git 仓库,无法自动更新。") + try: + subprocess.run( + ["git", "-C", str(extension.path), "pull", "--ff-only"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + errors="ignore", + ) + load_extensions(force=True) + return i18n("插件已更新,请刷新页面或重启应用以加载新的前端资源。") + except subprocess.CalledProcessError as exc: + return i18n("插件更新失败:") + (exc.stderr or str(exc)) + except Exception as exc: + return i18n("插件更新失败:") + str(exc) + + +def update_all_extensions(): + messages = [] + for extension in get_loaded_extensions(): + if _is_git_extension(extension): + messages.append(f"{extension.name}: {update_extension(extension.id)}") + if not messages: + return i18n("没有可自动更新的 Git 插件。") + return "\n\n".join(messages) + + +def refresh_extension_list(): + load_extensions(force=True) + return i18n("插件列表已刷新。") + + +def extension_manager_html(): + extensions = get_loaded_extensions() + if not extensions: + return f'
{escape(i18n("未发现插件。"))}
' + + rows = [] + for extension in extensions: + checked = "checked" if extension.enabled and not extension.error else "" + disabled = "disabled" if extension.error else "" + description = _extension_description(extension) + version = extension.version or "-" + status = _extension_status(extension) + update_link = "" + if _git_extension_has_updates(extension): + update_link = ( + f'' + ) + rows.append( + f""" +
+
+
+ {escape(_extension_title(extension.id))} + {update_link} +
+
{escape(description)}
+
{escape(version)} · {escape(status)}
+ {f'
{escape(i18n("错误:") + extension.error)}
' if extension.error else ''} +
+ +
+ """ + ) + return "\n".join(rows) + + +def handle_extension_action(action_json: str): + try: + action = json.loads(action_json or "{}") + except Exception: + return i18n("插件操作参数无效。"), extension_manager_html() + + extension_id = action.get("id", "") + action_type = action.get("action", "") + if action_type == "toggle": + message = _set_extension_enabled(extension_id, bool(action.get("enabled", False))) + elif action_type == "update": + message = update_extension(extension_id) + else: + message = i18n("未知插件操作。") + return message, extension_manager_html() + + +def install_extension_from_ui(source: str): + return install_extension(source), extension_manager_html() + + +def refresh_extension_list_from_ui(): + return refresh_extension_list(), extension_manager_html() + + +def update_all_extensions_from_ui(): + return update_all_extensions(), extension_manager_html() + + +def render_extension_manager(): + status_box = gr.Markdown("", elem_classes="extension-status") + action_payload = gr.Textbox(value="", visible=False, elem_id="extension-action-payload") + action_btn = gr.Button(value="", visible=False, elem_id="extension-action-btn") + + source = gr.Textbox( + label=i18n("Git URL 或本地插件目录"), + placeholder="https://github.com/user/chuanhu-extension-example.git", + lines=1, + elem_classes="no-container extension-install-source", + ) + install_btn = gr.Button(i18n("安装"), variant="primary", elem_classes="extension-action-button extension-install-button") + + gr.Markdown(i18n("已安装插件"), elem_classes="extension-section-label extension-installed-title") + list_html = gr.HTML(extension_manager_html(), elem_id="extension-manager-list") + + install_btn.click(install_extension_from_ui, inputs=[source], outputs=[status_box, list_html], show_progress=True) + action_btn.click(handle_extension_action, inputs=[action_payload], outputs=[status_box, list_html], show_progress=True) + + errors = plugin_callbacks.get_errors() + if errors: + gr.Markdown(i18n("插件错误"), elem_classes="extension-section-label") + for item in errors: + gr.Markdown(f"- `{item['extension']}`:{item['message']}", elem_classes="extension-error") + + +def render_extension_settings(): + labels = [ + _extension_title(record.extension_id) + for record in plugin_callbacks.iter_callbacks("extension_settings") + ] + if labels: + gr.HTML( + '' + ) + _render_callback_tabs("extension_settings") diff --git a/modules/models/base_model.py b/modules/models/base_model.py index 818026b8..009d5784 100644 --- a/modules/models/base_model.py +++ b/modules/models/base_model.py @@ -31,8 +31,10 @@ HumanMessage, SystemMessage) from .. import shared +from .. import plugin_callbacks from ..config import retrieve_proxy, auth_list from ..index_func import * +from ..plugin_context import ChatContext, ChatErrorContext from ..presets import * from ..utils import * @@ -602,6 +604,21 @@ def predict( should_check_token_count=True, ): # repetition_penalty, top_k status_text = "开始生成回答……" + chat_context = ChatContext( + model=self, + user_input=inputs, + chatbot=chatbot, + use_websearch=use_websearch, + files=files, + reply_language=reply_language, + status_text=status_text, + ) + plugin_callbacks.invoke("before_chat", chat_context) + inputs = chat_context.user_input + chatbot = chat_context.chatbot + use_websearch = chat_context.use_websearch + files = chat_context.files + reply_language = chat_context.reply_language if type(inputs) == list: logging.info( "用户" @@ -644,6 +661,17 @@ def predict( reply_language=reply_language, chatbot=chatbot, ) + chat_context.limited_context = limited_context + chat_context.fake_input = fake_inputs + chat_context.display_append = display_append + chat_context.prepared_input = inputs + chat_context.chatbot = chatbot + plugin_callbacks.invoke("after_prepare", chat_context) + limited_context = chat_context.limited_context + fake_inputs = chat_context.fake_input + display_append = chat_context.display_append + inputs = chat_context.prepared_input + chatbot = chat_context.chatbot yield chatbot + [(fake_inputs, "")], status_text if ( @@ -675,6 +703,15 @@ def predict( self.history.append(inputs) else: self.history.append(construct_user(inputs)) + chat_context.prepared_input = inputs + chat_context.chatbot = chatbot + plugin_callbacks.invoke("before_model_call", chat_context) + if chat_context.prepared_input != inputs: + inputs = chat_context.prepared_input + if type(inputs) == list: + self.history[-1] = inputs + else: + self.history[-1] = construct_user(inputs) start_time = time.time() try: @@ -699,9 +736,20 @@ def predict( yield chatbot, status_text except Exception as e: traceback.print_exc() + plugin_callbacks.invoke("chat_error", ChatErrorContext(self, e, chat_context)) status_text = STANDARD_ERROR_MSG + beautify_err_msg(str(e)) yield chatbot, status_text end_time = time.time() + if len(self.history) > 0 and isinstance(self.history[-1], dict) and self.history[-1].get("role") == "assistant": + chat_context.assistant_reply = self.history[-1]["content"] + chat_context.chatbot = chatbot + chat_context.status_text = status_text + plugin_callbacks.invoke("after_chat", chat_context) + if isinstance(chat_context.assistant_reply, str) and chat_context.assistant_reply != self.history[-1]["content"]: + self.history[-1] = construct_assistant(chat_context.assistant_reply) + if chatbot: + chatbot[-1] = (chatbot[-1][0], chat_context.assistant_reply + chat_context.display_append) + yield chatbot, status_text if len(self.history) > 1 and self.history[-1]["content"] != fake_inputs: logging.info( "回答为:" @@ -735,6 +783,8 @@ def predict( self.chatbot = chatbot self.auto_save(chatbot) + chat_context.history_file_path = self.history_file_path + plugin_callbacks.invoke("after_history_saved", chat_context) def retry( self, diff --git a/modules/plugin_callbacks.py b/modules/plugin_callbacks.py new file mode 100644 index 00000000..d4a32905 --- /dev/null +++ b/modules/plugin_callbacks.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass +import logging +import traceback +from typing import Any, Callable + +import gradio as gr + + +Callback = Callable[..., Any] + + +@dataclass +class CallbackRecord: + extension_id: str + name: str + callback: Callback + + +_callback_map: dict[str, list[CallbackRecord]] = { + "extension_controls": [], + "extension_settings": [], + "before_chat": [], + "after_prepare": [], + "before_model_call": [], + "after_chat": [], + "chat_error": [], + "after_history_saved": [], +} + +_current_extension_id = "core" +_errors: list[dict[str, str]] = [] +_extension_enabled: dict[str, bool] = {} + + +@contextmanager +def extension_context(extension_id: str): + global _current_extension_id + previous = _current_extension_id + _current_extension_id = extension_id + try: + yield + finally: + _current_extension_id = previous + + +def clear_callbacks(): + for records in _callback_map.values(): + records.clear() + _errors.clear() + _extension_enabled.clear() + + +def register_error(extension_id: str, message: str): + logging.error("[extension:%s] %s", extension_id, message) + _errors.append({"extension": extension_id, "message": message}) + + +def get_errors(): + return list(_errors) + + +def set_extension_enabled(extension_id: str, enabled: bool): + _extension_enabled[extension_id] = bool(enabled) + + +def is_extension_enabled(extension_id: str): + return _extension_enabled.get(extension_id, True) + + +def _register(kind: str, callback: Callback): + if kind not in _callback_map: + raise ValueError(f"Unknown plugin callback kind: {kind}") + record = CallbackRecord( + extension_id=_current_extension_id, + name=getattr(callback, "__name__", repr(callback)), + callback=callback, + ) + _callback_map[kind].append(record) + return callback + + +def on_extension_controls(callback: Callback): + return _register("extension_controls", callback) + + +def on_toolbox_tab(callback: Callback): + return on_extension_controls(callback) + + +def on_extension_settings(callback: Callback): + return _register("extension_settings", callback) + + +def on_settings_tab(callback: Callback): + return on_extension_settings(callback) + + +def on_before_chat(callback: Callback): + return _register("before_chat", callback) + + +def on_after_prepare(callback: Callback): + return _register("after_prepare", callback) + + +def on_before_model_call(callback: Callback): + return _register("before_model_call", callback) + + +def on_after_chat(callback: Callback): + return _register("after_chat", callback) + + +def on_chat_error(callback: Callback): + return _register("chat_error", callback) + + +def on_after_history_saved(callback: Callback): + return _register("after_history_saved", callback) + + +def iter_callbacks(kind: str): + return [ + record + for record in _callback_map.get(kind, []) + if is_extension_enabled(record.extension_id) + ] + + +def callback_counts(): + return {kind: len(records) for kind, records in _callback_map.items()} + + +def invoke(kind: str, *args, **kwargs): + results = [] + for record in iter_callbacks(kind): + try: + results.append(record.callback(*args, **kwargs)) + except Exception as exc: + message = "".join(traceback.format_exception_only(type(exc), exc)).strip() + register_error(record.extension_id, f"{record.name}: {message}") + logging.debug("Plugin callback traceback", exc_info=True) + return results + + +def render_callbacks(kind: str, empty_text: str, separator: bool = False): + from modules.presets import i18n + + rendered = False + for record in iter_callbacks(kind): + try: + record.callback() + rendered = True + if separator: + gr.Markdown("---") + except Exception as exc: + message = "".join(traceback.format_exception_only(type(exc), exc)).strip() + register_error(record.extension_id, f"{record.name}: {message}") + with gr.Accordion(f"{record.extension_id}", open=False): + gr.Markdown(i18n("加载插件界面失败:") + f"`{message}`") + if separator: + gr.Markdown("---") + if not rendered: + gr.Markdown(empty_text) diff --git a/modules/plugin_context.py b/modules/plugin_context.py new file mode 100644 index 00000000..48ac4da6 --- /dev/null +++ b/modules/plugin_context.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class ChatContext: + model: Any + user_input: Any + chatbot: list + use_websearch: bool = False + files: list | None = None + reply_language: str = "中文" + limited_context: bool = False + fake_input: str = "" + display_append: str = "" + prepared_input: Any = None + assistant_reply: str | None = None + status_text: str = "" + history_file_path: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ChatErrorContext: + model: Any + error: Exception + chat_context: ChatContext | None = None + metadata: dict[str, Any] = field(default_factory=dict) diff --git a/modules/webui.py b/modules/webui.py index 480cc8a3..bf2eb553 100644 --- a/modules/webui.py +++ b/modules/webui.py @@ -4,6 +4,7 @@ import gradio as gr from . import shared +from . import extensions # with open("./assets/ChuanhuChat.js", "r", encoding="utf-8") as f, \ # open("./assets/external-scripts.js", "r", encoding="utf-8") as f1: @@ -19,7 +20,8 @@ def get_html(filename): return "" def webpath(fn): - if fn.startswith(shared.assets_path): + fn = str(fn) + if fn.startswith(shared.assets_path) or fn.startswith(shared.chuanhu_path): web_path = os.path.relpath(fn, shared.chuanhu_path).replace('\\', '/') else: web_path = os.path.abspath(fn) @@ -33,12 +35,17 @@ def javascript_html(): head += f'\n' for script in list_scripts("javascript", ".mjs"): head += f'\n' + for script in extensions.javascript_files(): + script_type = "module" if script.suffix.lower() == ".mjs" else "text/javascript" + head += f'\n' return head def css_html(): head = "" for cssfile in list_scripts("stylesheet", ".css"): head += f'' + for cssfile in extensions.stylesheet_files(): + head += f'' return head def list_scripts(scriptdirname, extension): @@ -82,4 +89,4 @@ def template_response(*args, **kwargs): gr.routes.templates.TemplateResponse = template_response -GradioTemplateResponseOriginal = gr.routes.templates.TemplateResponse \ No newline at end of file +GradioTemplateResponseOriginal = gr.routes.templates.TemplateResponse diff --git a/web_assets/javascript/ChuanhuChat.js b/web_assets/javascript/ChuanhuChat.js index 9fbb7233..dbb02356 100644 --- a/web_assets/javascript/ChuanhuChat.js +++ b/web_assets/javascript/ChuanhuChat.js @@ -47,6 +47,61 @@ var isInIframe = (window.self !== window.top); var currentTime = new Date().getTime(); let windowWidth = window.innerWidth; // 初始窗口宽度 +var chuanhuReady = false; +var chuanhuReadyCallbacks = []; +var chuanhuRenderCallbacks = []; +var chuanhuMutationCallbacks = []; +var chuanhuPluginObserver = null; + +function runChuanhuCallbacks(callbacks) { + callbacks.forEach((callback) => { + try { + callback(window.ChuanhuApp); + } catch (error) { + console.error("[ChuanhuApp callback]", error); + } + }); +} + +function addChuanhuCallback(callbacks, callback, runNow = false) { + if (typeof callback !== "function") return; + callbacks.push(callback); + if (runNow && chuanhuReady) { + try { + callback(window.ChuanhuApp); + } catch (error) { + console.error("[ChuanhuApp callback]", error); + } + } +} + +function setChuanhuInputValue(input, value) { + if (!input) return false; + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + return true; +} + +function startChuanhuPluginObserver() { + if (chuanhuPluginObserver) { + chuanhuPluginObserver.disconnect(); + } + chuanhuPluginObserver = new MutationObserver(() => { + runChuanhuCallbacks(chuanhuMutationCallbacks); + }); + chuanhuPluginObserver.observe(gradioApp(), { childList: true, subtree: true }); +} + +window.ChuanhuApp = { + root: () => gradioApp(), + gradioApp: () => gradioApp(), + onReady: (callback) => addChuanhuCallback(chuanhuReadyCallbacks, callback, true), + onRender: (callback) => addChuanhuCallback(chuanhuRenderCallbacks, callback, true), + onMutation: (callback) => addChuanhuCallback(chuanhuMutationCallbacks, callback, false), + userInput: () => gradioApp().querySelector("#user-input-tb textarea, #user-input-tb input"), + setInputValue: setChuanhuInputValue, +}; function addInit() { var needInit = {chatbotIndicator, uploaderIndicator}; @@ -133,6 +188,13 @@ function initialize() { setChatbotScroll(); setTimeout(showOrHideUserInfo(), 2000); + const wasChuanhuReady = chuanhuReady; + chuanhuReady = true; + if (!wasChuanhuReady) { + runChuanhuCallbacks(chuanhuReadyCallbacks); + } + runChuanhuCallbacks(chuanhuRenderCallbacks); + startChuanhuPluginObserver(); // setHistroyPanel(); // trainBody.classList.add('hide-body'); diff --git a/web_assets/javascript/extensions.js b/web_assets/javascript/extensions.js new file mode 100644 index 00000000..2457709a --- /dev/null +++ b/web_assets/javascript/extensions.js @@ -0,0 +1,117 @@ +(function () { + const LABEL_CLASS = "extension-tabs-section-label"; + + function appRoot() { + return window.ChuanhuApp?.root?.() || document; + } + + function buttonText(button) { + return (button?.textContent || "").trim(); + } + + function findSettingTabs() { + return appRoot().querySelector("#chuanhu-setting-tabs"); + } + + function extensionSettingsLabelText() { + return appRoot().querySelector("#extension-settings-label-source")?.textContent?.trim() || "插件设置"; + } + + function extensionSettingsTabLabels() { + const source = appRoot().querySelector("#extension-settings-tab-labels"); + if (!source?.dataset?.labels) return []; + try { + return JSON.parse(source.dataset.labels); + } catch { + return []; + } + } + + function findTabNav(tabsRoot, settingLabels) { + if (!tabsRoot) return null; + const navs = Array.from(tabsRoot.querySelectorAll(".tab-nav")); + return navs.find((nav) => { + const buttons = Array.from(nav.querySelectorAll("button")); + return buttons.some((button) => settingLabels.includes(buttonText(button))); + }) || null; + } + + function installExtensionSettingsLabel() { + const tabsRoot = findSettingTabs(); + const settingLabels = extensionSettingsTabLabels(); + if (!settingLabels.length) return; + + const nav = findTabNav(tabsRoot, settingLabels); + if (!nav || nav.querySelector(`.${LABEL_CLASS}`)) return; + + const buttons = Array.from(nav.querySelectorAll("button")); + const firstSettingsButton = buttons.find((button) => settingLabels.includes(buttonText(button))); + if (!firstSettingsButton) return; + + const label = document.createElement("div"); + label.className = LABEL_CLASS; + label.textContent = extensionSettingsLabelText(); + firstSettingsButton.insertAdjacentElement("beforebegin", label); + } + + function gradioTextInput(root) { + return root?.querySelector("textarea, input"); + } + + function setGradioValue(root, value) { + const input = gradioTextInput(root); + if (!input) return false; + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + return true; + } + + function submitExtensionAction(action) { + const root = appRoot(); + const payloadRoot = root.querySelector("#extension-action-payload"); + const actionButton = root.querySelector("#extension-action-btn button, #extension-action-btn"); + if (!setGradioValue(payloadRoot, JSON.stringify(action)) || !actionButton) return; + actionButton.click(); + } + + function bindExtensionManager() { + appRoot().querySelectorAll("[data-extension-action]").forEach((element) => { + if (element.dataset.extensionBound) return; + element.dataset.extensionBound = "true"; + const action = element.dataset.extensionAction; + if (action === "toggle") { + element.addEventListener("change", () => { + submitExtensionAction({ + action: "toggle", + id: element.dataset.extensionId, + enabled: element.checked, + }); + }); + } else if (action === "update") { + element.addEventListener("click", () => { + submitExtensionAction({ + action: "update", + id: element.dataset.extensionId, + }); + }); + } + }); + } + + function syncExtensionsUi() { + installExtensionSettingsLabel(); + bindExtensionManager(); + } + + if (window.ChuanhuApp?.onReady) { + window.ChuanhuApp.onReady(syncExtensionsUi); + window.ChuanhuApp.onMutation(syncExtensionsUi); + } else { + const observer = new MutationObserver(syncExtensionsUi); + window.addEventListener("DOMContentLoaded", () => { + syncExtensionsUi(); + observer.observe(document.body, { childList: true, subtree: true }); + }); + } +})(); diff --git a/web_assets/stylesheet/custom-components.css b/web_assets/stylesheet/custom-components.css index 93d8244e..51f409c1 100644 --- a/web_assets/stylesheet/custom-components.css +++ b/web_assets/stylesheet/custom-components.css @@ -243,6 +243,183 @@ input:checked + .apSlider::before { left: 18px; } +/* extension settings */ +.extension-section-label, +.extension-section-label p, +.extension-section-label * { + margin: 0 !important; + color: var(--body-text-color); + font-size: var(--text-md); + font-weight: 700 !important; + text-align: left; +} + +div.no-container.extension-install-source { + padding-top: 0 !important; +} + +.extension-install-button { + margin-bottom: 4px !important; +} + +.extension-installed-title.block { + margin-top: 6px !important; + margin-bottom: -8px !important; +} + +.extension-action-button { + min-width: 82px !important; +} + +#toolbox-area .extension-generated-tab > div { + padding: 12px; +} + +.extension-list-row { + display: grid !important; + grid-template-columns: minmax(0, 1fr) 48px; + align-items: center; + gap: 12px; + min-width: 0; + margin: 0 !important; + padding: 12px 0 !important; + border-top: 1px solid var(--border-color-primary); +} + +.extension-info { + min-width: 0; +} + +.extension-title-line { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + margin-bottom: 4px; +} + +.extension-title { + font-weight: 600; + line-height: 1.35; +} + +.extension-meta, +.extension-desc, +.extension-muted, +.extension-status { + color: var(--body-text-color-subdued); + font-size: var(--text-xs); + line-height: 1.35; + opacity: .72; + text-align: left; +} + +.extension-desc { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; +} + +.extension-inline-update { + margin: 0; + border: none; + padding: 0; + background: transparent; + color: var(--link-text-color, #1a73e8); + font: inherit; + font-size: var(--text-xs); + line-height: 1.2; + text-decoration: underline; + cursor: pointer; +} + +.extension-native-switch { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 36px; + cursor: pointer; +} + +.extension-native-switch input, +.extension-native-input { + position: absolute !important; + width: 1px !important; + height: 1px !important; + margin: -1px !important; + border: 0 !important; + padding: 0 !important; + overflow: hidden !important; + clip: rect(0 0 0 0) !important; + clip-path: inset(50%) !important; + white-space: nowrap !important; +} + +.extension-native-slider { + display: inline-block; + position: relative; + width: 40px; + height: 22px; + border-radius: 11px; + background-color: var(--switch-checkbox-color-light); + box-shadow: inset 0 0 1px 0 rgba(0,0,0,0.05), inset 0 0 2px 0 rgba(0,0,0,0.08); + transition: .2s ease background-color; +} + +.dark .extension-native-slider { + background-color: var(--switch-checkbox-color-dark); +} + +.extension-native-slider::before { + content: ""; + position: absolute; + width: 22px; + height: 22px; + top: 0; + left: 0; + transform: scale(0.9); + border-radius: 11px; + background: #fff; + box-shadow: var(--input-shadow); + transition: .4s ease all; +} + +.extension-native-switch input:checked + .extension-native-slider { + background-color: var(--primary-600); +} + +.extension-native-switch input:checked + .extension-native-slider::before { + left: 18px; +} + +.extension-error { + margin: 4px 0 !important; + color: var(--error-text-color, #b42318); + font-size: var(--text-xs); + text-align: left; +} + +.extension-tabs-section-label { + display: block; + width: 100%; + color: var(--body-text-color-subdued); + font-size: var(--text-xs); + font-weight: 700; + line-height: 1.2; + margin: 18px 0 6px; + padding: 0 12px; + text-align: left; + pointer-events: none; +} + +@media screen and (max-width: 767px) { + .extension-tabs-section-label { + display: none; + } +} + /* .scroll-shadow-left::before { content: "";