|
|
8de1499c40
|
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 插件,无需手工放置文件。
|
2026-10-02 09:04:47 +08:00 |
|
|
|
1c690611f8
|
feat(gui): WebUI 与 Electron 壳的插件安装/删除/禁用/编辑
## WebUI:新增「插件」页
- 列表来自 on_disk(不是 loaded 集合)——**加载失败的插件也必须显示并带错误**,
否则一个语法错误看起来和"插件没装"完全一样
- 启用/禁用(PUT {"enabled":bool})、删除、编辑源码、安装/覆盖
- 显示 hook_errors:插件抛异常在别处毫无痕迹,没有这一栏的症状就是
"功能就是不work"
- 插到 dropzone 与代码编辑器都做了泛型化(bindDropzone / openCodeModal),
适配器与插件共用一份,而不是复制第二份只改 4 个 id 的函数
## TABS 收敛为单一常量
tab 清单原本是字面量散在三处:goTab、refresh()、admin-only 隐藏列表。
加一个 tab 意味着三处都要记得改,漏一处就是"路由认得但界面不显示"——
和今天早些时候 chain_step 漏报同一类静默缺口。现在只有 const TABS。
## Electron 壳:设置面板里的插件管理
渲染进程不能直连内嵌核心(没有 key、不知道端口),所以走 IPC:
renderer → plugins:proxy → main → HTTP /api/plugins
代理是 (method, path, body) 透传而不是固定命令表:固定表每加一个端点就要扩,
而"按钮存在但什么都不做"比"没有这个按钮"更糟。透传让渲染层能调用核心将来
新增的任何 /api/plugins 路由,路径在主进程校验。
## ★ GUI 此前零测试,而本次改动就引入了三类"看起来没事"的问题
1. 引用了不存在的 CSS 类(.tag / .sm)——渲染成无样式文本
2. 引用了不存在的 helper(esc / escAttr)——那是 WebUI 的,renderer/app.js
是独立文档,点击时 ReferenceError
3. .ghost/.primary 只在 .form .actions 作用域内生效,插件按钮在 .pl-acts 里
于是是无样式裸按钮
补 4 个静态判据(不启动 Electron,守卫的正是"打开应用才看得见"那一类):
TestGUICSSClassesExist 用到的类必须在样式表里定义
TestGUIHelperFunctionsAreDefined 被调用的函数必须有定义
TestGUIPluginPanelIsReachable 面板在 overlay 内、按钮已绑定、打开设置会加载
TestGUIIPCPathIsConstrained 代理必须限定 /api/plugins 前缀并拒绝路径穿越
写第一个判据时我错了三次:CSS 解析器先丢最后一个 selector、再把变量块当
selector、最后漏掉复合选择器(.tb-btn.tb-close)。两次"判据自己坏了"的
教训和本项目一贯一致——**判据出错的信号是它报了一个假问题**。现在改用宽松的
token 提取 + 显式的 guiKnownUnstyled 豁免表(blob/tgl/rail 是既有无样式类,
不是本次引入,失败它们只会让判据对新工作失去意义)。
## 变异验证
改坏唯一的 CSS 定义(.pl-empty)→ TestGUICSSClassesExist 红
改坏 helper 名 → TestGUIHelperFunctionsAreDefined 红
★ 第一次变异我改了 .pl-broken,判据**正确地没报**——因为它还被另一条规则定义。
这是变异选错目标,不是判据有洞;换 .pl-empty 后如期变红。
363 个测试全绿。
|
2026-10-02 08:47:08 +08:00 |
|
|
|
a51a6811a6
|
feat(plugin): Lua 插件机制 + 计费插件 + 插件文档
插件 = plugin_dir 下的单个 .lua 文件,做两件事:挂请求流水线的钩子、在启动时
贡献 WebUI 界面(整页或往现有页面追加组件)。两者独立。
## 流水线 stage(三个)
request_start 已解析鉴权、未选源
routed 已选定 (source, model)、未发往上游
request_end 每请求恰好一次,带最终计量
request_end 挂在 gateway.writeRec——四条入口路径(直连/AUTO × 流式/非流式)的
唯一汇合点:既不漏(流式 token 只有流结束才知道)也不重。
## 计费插件(plugins/billing.lua,默认 seed,开箱可用)
源 / 模型 / 密钥三个维度定价。token 价优先级 keys > models > default;per_request
固定价是**叠加**的(生图模型可以既算 token 又收固定费)。单位是 USD/单 token,
即各家 provider 的公布口径。累计 total / by_source / by_model / by_key / by_day。
失败请求保留 token 费用、丢弃固定费(可经 count_failures 翻转)。
界面 = 一个独立页 + 状态页顶部一块总开销 tile。
## 一个明确的设计边界
计费插件**只报表,不执法**。网关自己的配额会计(stats.go,入口强制)才是限额
权威,插件不参与任何路由/配额决策。两套独立会计若对不上,比一套功能略少的
更糟。
## ★ 中途改掉的一个根本设计错误
最初让插件复用适配器的**弹性 worker 池**(多状态)。这对适配器是对的(它们无
状态),对插件是错的:计费插件往 plugin.state 累加,多状态意味着总量被劈成
几份;而 SetState 写价格只写进其中一个 worker,钩子恰好跑到另一个时**所有请求
按 0 计费**。改为**单状态 + 互斥锁**。代价写进文档:钩子必须短、同步、不阻塞,
卡住的钩子会卡住所有插件的钩子。
这个 bug 是测试逼出来的——先写了 SetState+Fire 的用例,数字全是 0 才挖出来。
另一个连带缺陷:只带 prices 的 PUT 会整体替换 state,把累计量清零。改为
prices/state 分离——prices 是配置、state 是历史,改价不动账。
## 撞到的三个 Lua 绑定的坑(都写进注释)
- SetGlobal **会 pop 栈**:连着调两次,第二次从空栈取,赋成 nil
- GetField 索引越界是 **SIGABRT 整个进程**,不是 panic,recover 救不了
- Call(nargs, n) **不接受函数索引**,它调的是 nargs 个参数正下方那个;
传索引会调到参数上("attempt to call a table value")
另外 GetField/SetField 用绝对索引,SetTop(0) 之后必须重取。
## 错误隔离
钩子 error() 不影响转发:捕获 → 记进 hook_errors → 跳下一个插件。适配器出错
会让源进冷却,插件出错**零惩罚**——插件是可选功能。/api/plugins 的 hook_errors
让"坏掉的插件"可见而不是静默消失。
## 界面注入
GET /api/ui-inject 一次返回所有插件的扩展(侧栏需要全部 page 才能建好)。
WebUI 在首次 render **之前** await 注入:先插 HTML 再重建 <script> 让它执行
(innerHTML/template 插入的 script 不会执行,这正是要的效果——避免脚本跑在
自己 DOM 之前)。注入失败不影响仪表盘。
browser 侧 pluginAPI 暴露 fetchState / postState / onTabShown。
## 文档
docs/plugins.md —— 快速上手、加载与热更新、三个 stage 的完整字段表、界面扩展、
状态与 HTTP API、运行时约束(单状态/异常隔离/内置函数)、计费插件的定价与
计费策略、排错表、与适配器的对比表。
## 判据(328 个测试全绿,插件相关 33 个)
- 计费断言的是**具体金额**(0.00625 / 0.0402 / 0.0075…),不是"能加载"
- 4 个变异都红:钩子异常不隔离 / prices 清空累计 / 忽略 key 优先级 /
毫秒时间戳不换算
- UI 侧 6 个判据把注入顺序、script 执行时机、pluginAPI 名称、tab 路由、
anchor 四种形式、失败非致命全钉住
- 鉴权:state 读任意角色、写仅 admin
|
2026-10-02 00:37:29 +08:00 |
|