mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
fix(ui): 插件元素注入被宿主页面重建擦除 —— 元素型注入从未真正生效
## 现象
部署示例后打开 WebUI:侧栏有 Billing 页,但**状态页上没有任何计费组件**。
插件明明声明了 elements,/api/ui-inject 也确实返回了 mount(1570 字节)。
## 根因(不是缺功能)
七个宿主页面的渲染函数都用 `pane.innerHTML = ...` **整体替换**自己的 DOM。
`renderStatus` 在 `injectPluginUI()` 之后由 refresh() 立刻调用,于是刚挂上的
plugin-el 连同整个 pane 一起被下一次赋值销毁。
时序上它**从没有过"显示一帧"的机会**:注入 → refresh("status") → innerHTML 覆盖。
所以症状是"元素从来没出现过",而不是"刷新后消失"——这正是我先前据
/api/ui-inject 返回值判定"注入正常"而漏掉的地方:**载荷到达 ≠ DOM 存活**。
Billing 页不受影响,因为它属于 PLUGIN_PAGES,走插件自有 DOM,不经宿主重建。
于是看起来像"页面注入有效、元素注入无效",把排查引向插件声明本身。
## 修法
- mountPluginElements 改成具名可重入函数,并注册进 PLUGIN_MOUNT_HOOKS
- refresh() 在**唯一出口**统一调 remountPluginElements(),而不是给七个渲染函数
各加一次调用——后者是多一处会忘的地方,而忘记的后果是静默的
- 每个挂载点按 data-idx 幂等:宿主重绘时若该 pane 已有该元素就直接返回,
否则插件的 <script> 会每次重绘都跑一遍,计数器静默翻倍
## ★ 验证方式换了:真实浏览器,而不是 payload
静态测试和 curl 都看不出这个 bug(载荷完全正确)。用 CDP 连本机共享浏览器实测:
修复后:首屏 tile=1,页面重建后=1,连续重建 5 次仍=1,console 无错误
回退后:tile 全程=0
对照二进制(把 done 改成空函数重编译)实测首屏就是 0,
**证明"从未显示过",不是"显示后消失"**。
## 判据与变异
TestPluginElementsSurviveHostRebuild 锁住:remountPluginElements 存在、
PLUGIN_MOUNT_HOOKS 在使用之前声明(const TDZ 会让首屏直接抛错)、refresh 挂了重挂、
挂载按 data-idx 幂等。
三个变异全部被抓住:撤掉 refresh 的重挂 / 去掉幂等守卫 / 把 const 声明移到 push 之后。
★ 第一次跑第三个变异时**判据正确地没报**,因为我的替换脚本命中了注释里的同名文本,
真正的 const 没被移动——是变异无效,不是判据有洞。换按行定位后如期变红。
## 同时补上部署示例
packaging/config.example.yaml 里补 plugin_dir 说明(之前只有 online 部署路径踩过)。
实测升级路径本身是好的:给已有配置加 plugin_dir 后,首次启动会自动 seed 内置
billing 插件,无需手工放置文件。
This commit is contained in:
@ -5052,18 +5052,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- boot ---------- */
|
/* ---------- boot ---------- */
|
||||||
|
// refresh renders one tab.
|
||||||
|
//
|
||||||
|
// The plugin-element re-mount is applied HERE, once, rather than at the end
|
||||||
|
// of each renderer: seven pages assign their pane's innerHTML wholesale,
|
||||||
|
// and a per-page call is one more place to forget.
|
||||||
function refresh(tab) {
|
function refresh(tab) {
|
||||||
if (tab === "status") return renderStatus();
|
const done = () => remountPluginElements();
|
||||||
if (tab === "chat") return renderChat();
|
if (tab === "status") return renderStatus().then(done, done);
|
||||||
if (tab === "keys") return renderKeys();
|
if (tab === "chat") return renderChat().then(done, done);
|
||||||
if (tab === "sort") return renderSort();
|
if (tab === "keys") return renderKeys().then(done, done);
|
||||||
if (tab === "sources") return renderSources();
|
if (tab === "sort") return renderSort().then(done, done);
|
||||||
if (tab === "adapters") return renderAdapters();
|
if (tab === "sources") return renderSources().then(done, done);
|
||||||
if (tab === "plugins") return renderPlugins();
|
if (tab === "adapters") return renderAdapters().then(done, done);
|
||||||
|
if (tab === "plugins") return renderPlugins().then(done, done);
|
||||||
// A page contributed by a plugin has no renderer here: its <script>
|
// A page contributed by a plugin has no renderer here: its <script>
|
||||||
// already ran at injection time and owns its own DOM. We only fire the
|
// already ran at injection time and owns its own DOM. We only fire the
|
||||||
// "shown" callbacks so it can refresh when the user lands on it.
|
// "shown" callbacks so it can refresh when the user lands on it.
|
||||||
if (PLUGIN_PAGES.has(tab)) return notifyPluginTab(tab);
|
if (PLUGIN_PAGES.has(tab)) notifyPluginTab(tab);
|
||||||
|
else return undefined;
|
||||||
|
done();
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5077,6 +5085,12 @@
|
|||||||
const PLUGIN_PAGES = new Set();
|
const PLUGIN_PAGES = new Set();
|
||||||
const PLUGIN_TAB_CBS = {};
|
const PLUGIN_TAB_CBS = {};
|
||||||
const PLUGIN_ELEMENTS = [];
|
const PLUGIN_ELEMENTS = [];
|
||||||
|
// PLUGIN_MOUNT_HOOKS holds re-mount functions for plugin ELEMENTS. A host
|
||||||
|
// page that rebuilds its own DOM (renderStatus assigns pane.innerHTML
|
||||||
|
// wholesale) destroys whatever a plugin mounted into it, so refresh() calls
|
||||||
|
// remountPluginElements() after every rebuild. Declared here, next to the
|
||||||
|
// other plugin state, because injectPluginUI below pushes into it.
|
||||||
|
const PLUGIN_MOUNT_HOOKS = [];
|
||||||
|
|
||||||
// pluginAPI is the small surface a plugin's script may rely on. Kept
|
// pluginAPI is the small surface a plugin's script may rely on. Kept
|
||||||
// deliberately tiny: plugins are untrusted, and every convenience here is
|
// deliberately tiny: plugins are untrusted, and every convenience here is
|
||||||
@ -5105,6 +5119,21 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// remountPluginElements re-attaches plugin elements after a host page
|
||||||
|
// rebuilt its DOM. Safe to call at any time: each mount is a no-op when
|
||||||
|
// the wrapper is already present in the current build of the pane, so a
|
||||||
|
// plugin's <script> does not run twice and its counters do not double.
|
||||||
|
function remountPluginElements() {
|
||||||
|
if (!PLUGIN_MOUNT_HOOKS.length) return;
|
||||||
|
PLUGIN_MOUNT_HOOKS.forEach((fn) => {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("plugin element remount failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function notifyPluginTab(tab) {
|
function notifyPluginTab(tab) {
|
||||||
const fns = PLUGIN_TAB_CBS[tab] || PLUGIN_TAB_CBS.__last || [];
|
const fns = PLUGIN_TAB_CBS[tab] || PLUGIN_TAB_CBS.__last || [];
|
||||||
fns.forEach((f) => {
|
fns.forEach((f) => {
|
||||||
@ -5175,10 +5204,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- elements into existing pages ---
|
// --- elements into existing pages ---
|
||||||
(ui.elements || []).forEach((el, i) => {
|
//
|
||||||
|
// mountPluginElements is a named, re-entrant function rather than a
|
||||||
|
// one-shot loop: the host pages REPLACE their own innerHTML on every
|
||||||
|
// build, so renderStatus' `pane.innerHTML = ...` destroys anything this
|
||||||
|
// mounted. The symptom is silent and points at the wrong file — the
|
||||||
|
// plugin did declare an element, the payload did arrive, and the element
|
||||||
|
// is still gone after the next repaint, which reads as "my plugin
|
||||||
|
// declared nothing". refresh() calls remountPluginElements() to re-apply.
|
||||||
|
window.mountPluginElements = function () {
|
||||||
|
(ui.elements || []).forEach((el, i) => {
|
||||||
const target = document.getElementById("tab-" + el.target);
|
const target = document.getElementById("tab-" + el.target);
|
||||||
if (!target || !el.mount) return;
|
if (!target || !el.mount) return;
|
||||||
PLUGIN_ELEMENTS.push(el);
|
PLUGIN_ELEMENTS.push(el);
|
||||||
|
// Already present in THIS build of the pane: leave it alone, or the
|
||||||
|
// widget's <script> would run again on every host re-render and a
|
||||||
|
// counter would silently double-count.
|
||||||
|
if (target.querySelector('.plugin-el[data-idx="' + i + '"]')) return;
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "plugin-el";
|
wrap.className = "plugin-el";
|
||||||
wrap.dataset.target = el.target;
|
wrap.dataset.target = el.target;
|
||||||
@ -5200,7 +5242,10 @@
|
|||||||
s.textContent = old.textContent;
|
s.textContent = old.textContent;
|
||||||
old.replaceWith(s);
|
old.replaceWith(s);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
PLUGIN_MOUNT_HOOKS.push(window.mountPluginElements);
|
||||||
|
window.mountPluginElements();
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@ -260,3 +260,58 @@ func TestUIBindDropzoneIsParameterised(t *testing.T) {
|
|||||||
t.Error("the plugin upload form does not use the parameterised dropzone")
|
t.Error("the plugin upload form does not use the parameterised dropzone")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPluginElementsSurviveHostRebuild guards the defect that made plugin
|
||||||
|
// elements look absent no matter how the injection was configured.
|
||||||
|
//
|
||||||
|
// renderStatus (and six other pages) assign pane.innerHTML wholesale. Anything a
|
||||||
|
// plugin had mounted into that pane is destroyed by the assignment. The symptom
|
||||||
|
// is silent and misleading: the plugin really did declare an element, the
|
||||||
|
// payload really did arrive, and the element is still gone on the next repaint —
|
||||||
|
// so the natural conclusion is "my plugin declared nothing", which sends you
|
||||||
|
// looking in the wrong file.
|
||||||
|
//
|
||||||
|
// The fix is to re-mount after the rebuild. This test asserts the re-mount is
|
||||||
|
// wired at the SINGLE place every renderer passes through, rather than leaving
|
||||||
|
// it to be re-added per page.
|
||||||
|
func TestPluginElementsSurviveHostRebuild(t *testing.T) {
|
||||||
|
html := uiSource(t)
|
||||||
|
|
||||||
|
if !strings.Contains(html, "function remountPluginElements") {
|
||||||
|
t.Fatal("remountPluginElements is not defined; nothing can re-attach a plugin element after a host rebuild")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "const PLUGIN_MOUNT_HOOKS = []") {
|
||||||
|
t.Fatal("PLUGIN_MOUNT_HOOKS is not declared")
|
||||||
|
}
|
||||||
|
// The hook array must be declared BEFORE injectPluginUI pushes to it, and
|
||||||
|
// before refresh() calls into it. A use-before-declaration in a const
|
||||||
|
// block is a hard TDZ ReferenceError at first paint.
|
||||||
|
hookDecl := strings.Index(html, "const PLUGIN_MOUNT_HOOKS = []")
|
||||||
|
push := strings.Index(html, "PLUGIN_MOUNT_HOOKS.push")
|
||||||
|
use := strings.Index(html, "PLUGIN_MOUNT_HOOKS.forEach")
|
||||||
|
if hookDecl < 0 || push < 0 || use < 0 {
|
||||||
|
t.Fatal("the hook array is declared but never both filled and drained")
|
||||||
|
}
|
||||||
|
if hookDecl > push || hookDecl > use {
|
||||||
|
t.Error("PLUGIN_MOUNT_HOOKS is used before its declaration (const TDZ: first paint would throw)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh() is the chokepoint every renderer passes through.
|
||||||
|
rf := strings.Index(html, "function refresh(tab)")
|
||||||
|
if rf < 0 {
|
||||||
|
t.Fatal("refresh(tab) is gone")
|
||||||
|
}
|
||||||
|
body := html[rf:]
|
||||||
|
if i := strings.Index(body, "\n }"); i > 0 {
|
||||||
|
body = body[:i]
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "remountPluginElements") {
|
||||||
|
t.Error("refresh() does not re-mount plugin elements: a page that rebuilds its DOM wipes them")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mount must be idempotent, or the widget's <script> runs again on every
|
||||||
|
// host repaint and its counters silently double.
|
||||||
|
if !strings.Contains(html, `data-idx="`) || !strings.Contains(html, "plugin-el[data-idx=") {
|
||||||
|
t.Error("element mounting is not guarded by a per-pane marker; re-mounting would re-run plugin scripts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -20,6 +20,15 @@ default_model: AUTO
|
|||||||
# Lua 适配器目录(默认 adapters/,首次启动自动写入内置适配器)
|
# Lua 适配器目录(默认 adapters/,首次启动自动写入内置适配器)
|
||||||
adapter_dir: adapters
|
adapter_dir: adapters
|
||||||
|
|
||||||
|
# 插件目录(可选)。设置后:
|
||||||
|
# - 首次启动会把随核心发布的示例插件(billing:按源/模型/密钥计费 + 仪表盘)
|
||||||
|
# 写入本目录,它会在请求流水线上挂钩子,并向 WebUI 注入一个「Billing」页面
|
||||||
|
# 和状态页上的一块总开销组件;
|
||||||
|
# - 之后 WebUI「插件」页与 Electron 壳的设置面板可安装/禁用/删除/编辑。
|
||||||
|
# 目录一旦存在即以目录为准:删除或改写内置插件都是真实生效的操作。
|
||||||
|
# 留空 = 插件功能完全关闭(不影响网关其它功能)。
|
||||||
|
# plugin_dir: /etc/llmsproxy/plugins
|
||||||
|
|
||||||
# 运行时文件:存放 WebUI 管理的源模板、已删除标记、预置模板名单。
|
# 运行时文件:存放 WebUI 管理的源模板、已删除标记、预置模板名单。
|
||||||
#
|
#
|
||||||
# 注意:**AUTO 调度链、网关密钥、上游源都存在本 config.yaml 里**,
|
# 注意:**AUTO 调度链、网关密钥、上游源都存在本 config.yaml 里**,
|
||||||
|
|||||||
Reference in New Issue
Block a user