@nimoxie/dsh-ui-tweaks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DEVELOPING.md ADDED
@@ -0,0 +1,72 @@
1
+ # 开发笔记
2
+
3
+ 面向改这个插件的人。用户文档在 [README.md](./README.md)。
4
+
5
+ ## 骨架(与 dsh-model-organizer 同构)
6
+
7
+ - `lib/index.js`:host 半区,空 `apply()`,只为让插件出现在 cordis loader / bundle 列表。
8
+ - `lib/client.js`:浏览器半区,`window.__ModuleLoader__.load({ id, factory })` 包裹,`module.exports = { name, apply, inject }`;`apply(ctx)` 由客户端 cordis 上下文调用。
9
+ - `cordis.patch.yml`:`- insert: [{id: ui-tweaks, name: '@nimoxie/dsh-ui-tweaks'}]`。**profile bundle 必须声明 `dsh.bundle.patch`,否则 dsh 启动即报 declares no dsh.bundle**。
10
+ - `package.json`:`exports["./client"]` 指向 client bundle;`dsh.client.platform: "web"`;`dsh.client.inject` 列出需要的官方包(本插件只要 `@deepseek-ai/dsh-api-remotes`,`remote.*` 服务由它提供)。
11
+ - 客户端可用模块:`react` / `react-dom` / `@deepseek-ai/dsh-client-ui-primitives` 都是 shell 内置(PLATFORM_MODULES),不需要 `external` 声明。本插件 v1 没用 React。
12
+
13
+ ### 命名(scoped 后的约定,2026-09-30 因重名加 @nimoxie scope)
14
+
15
+ - npm 包名 `@nimoxie/dsh-ui-tweaks`;**patch 里的 `insert.name` 必须写全 scope** —— `@linxin666` 系插件全是这样(loader 按它在 node_modules 里解析包)。
16
+ - loader `id` 与 `module.exports.name` 只是内部标识,换名不必跟着改(dsh-model-organizer 改 scoped 后这俩仍是旧值,工作正常);本插件 loader id 用了全名,`exports.name` 用 `ui-tweaks`。
17
+ - 换名要同步四处:package.json、cordis.patch.yml、两个 profile 的依赖键 + `dsh.profile.bundles` 数组、(桌面 profile 还有 lockfile 与 node_modules 软链路径)。
18
+
19
+ ## 本地开发(link: 才是软链!)
20
+
21
+ - **`file:` 依赖是打包快照,`link:` 才是符号链接。** profile 里写 `file:` 时 pnpm 把目录复制进 store,改仓库不生效。
22
+ - 安装/重装:
23
+ ```sh
24
+ dsh plugin --profile web remove @nimoxie/dsh-ui-tweaks
25
+ dsh plugin --profile web add "link:D:/tools/DeepSeek Harness/dsh-ui-tweaks"
26
+ ```
27
+ - 改完 client.js 后页面要 **Ctrl+Shift+R**:客户端 bundle 以 `Cache-Control: immutable, max-age=31536000` 下发且 URL 不随代码变化,普通刷新一直吃缓存。
28
+ - 加载/卸载插件需要**重启 dsh web**(插件在启动时装载)。
29
+ - **桌面 profile 由 Electron 应用独占管理**:npm 版 CLI 装/卸会被拒(`managed exclusively by the Electron application`)。两条路:① 客户端插件管理页安装,spec 填 `link:<路径>`;② 应用完全退出后手动改四件套(package.json 依赖 + `dsh.profile.bundles`、pnpm-lock importers、node_modules 软链)。实测客户端会监听 package.json 变化、自动用自带 pnpm 建好软链。
30
+
31
+ ## 已知坑(多半继承自 dsh-model-organizer 的踩坑记录)
32
+
33
+ - **`ctx.inject([...])` 只要有一个服务名不存在,回调永远不执行且不报错**。升级 dsh 后功能静默消失,第一件事去新版 `dsh-client-ui-*` 包里 grep 注入的每个服务名。本插件在 8 秒拿不到服务时会给出明确的错误行,不会无限等。
34
+ - 每个模块的注册各套 `guard()`:失败记 `console.warn`,不拖垮同文件其它模块。
35
+ - 官方 CSS-module 类名是「哈希_原名」结构,锚定一律用**属性前缀**(`[class*="tableScroll"]`、`[class*="widthHandle"]`、`[class*="composerSeat"]`),绝不硬编码 hash。
36
+ - **0.2.0-rc.1 已把 0.1.x 的 `data-dsh-part` 标记全部移除**(滚动条/调宽条/输入框的选择器都因此改过一轮);`[data-slot="..."]` 是渲染器统一输出的,锚插槽容器用它。
37
+ - 浮层挂在 `document.body` 的 Shadow DOM 里:座位在页面深子树里时 `position:fixed` 会被困在堆叠上下文,z-index 再高也没用。
38
+
39
+ ## 更新检查的实现要点
40
+
41
+ - **全部走官方 remote API**,不自建 host 路由:`pluginManager.listBundles()`(清单+版本)、`installBundle("<名>@latest", { enabled: true, requestId })`(更新)、`waitForInstall(requestId)`(进度)、`cancelInstall(requestId)`(取消)。`installBundle` 的第二参里官方传了 `registry`(来自 `pluginManager.registries()`),本插件省略 —— 若某天报 registry 缺失,先补这个。
42
+ - **registry 比对在浏览器直连**:`registry.npmjs.org` 带 `Access-Control-Allow-Origin: *`(2026-09 实测),逐包串行 fetch `/latest`,不给 registry 打并发。
43
+ - **装完必须回读实际版本**:pnpm 11 的 `minimumReleaseAge` 门禁会静默暂缓当日发布,只看安装请求成功会把「没装上」报成「成功」。回读**只用 `pluginManager.listBundles()`** —— `pluginInventory` 只有 `list()` 没有 `listBundles`,先取它会掉进「回读版本失败」假阳性(踩过);装完瞬间清单可能短暂不可用,重试 3 次(1.5s 间隔)再下结论。重试后仍读不到就标「已发起·未确认」(可点重查),版本没变才标「暂缓更新」。
44
+ - **本地包识别 = host 路由为主,404 签名兜底**:`listBundles()` 不透传安装来源(`installed` 不是 spec 字符串),而发布到 npm 的本地包(如 `@nimoxie/dsh-model-organizer`)连 404 签名都不命中 —— 所以 host 半区挂了 `GET /api/ui-tweaks/local-plugins`(读 profile `package.json` 的 dependencies,把 `link:/file:/git+/http` 声明的包名回给面板;仅回环放行)。client 每轮检查先取这份名单标「本地链接」,取不到(host 半区未重启)再退回 404 签名。**改 host 半区必须重启 dsh/客户端才生效**(client bundle 刷新即可)。
45
+ profile 目录定位:先从 `import.meta.url` 向上找带 `dsh.profile.bundles` 且包含自身的 package.json(npm 直装必命中);软链安装时模块被解析到 profile 外的真实目录,退回 `DSH_HOME/profiles/*` 扫描,多 profile 命中时按 `ELECTRON_RUN_AS_NODE` 区分 desktop/web。
46
+ - **更新严格串行**:pnpm 对 profile 的 package.json 有文件锁,并发发起不会真并行、只会在锁上排队。队列里的项标「等待更新」,正在装的标「更新中 …」,两者都可点取消。
47
+ - `waitForInstall` 的返回形态官方页面之外没有文档,本插件对 `phase/state/status/ok` 多形状都认、240 秒兜底超时;真实跑通后按实际返回收紧。
48
+ - **刻意不检查 dsh 本体**(用户决策):桌面端自带更新器,web 端本体升级走 npm。
49
+
50
+ ## 动画修复的实现要点(模块 4)
51
+
52
+ - **原理**:Windows「动画效果」关闭 → Chromium 报 `prefers-reduced-motion: reduce` → dsh 的 40 处(17 包)`@media` 规则关掉动画。页面内**删不掉媒体查询的求值**,但删得掉规则本身:dsh 的 CSS 全部经 `<style>` 标签注入(实测 0 处 adoptedStyleSheets),同源 sheet 的 `cssRules` 可遍历可 `deleteRule` —— 把 reduce 的 media 块整条递归删除,基础动画声明(非 media 规则)立即生效。效果与 dsh-web-shell 的 CDP `Emulation.setEmulatedMedia` 等价,但**不需要调试端口,桌面端也能用**。
53
+ - **JS 门控**:chat 包 3 处 `matchMedia("(prefers-reduced-motion: reduce)")`(滚动平滑 vs 跳转)用 `window.matchMedia` 包装覆盖 —— 在返回的 MediaQueryList 上 `defineProperty("matches")` 影子化,按 no-preference 应答。包装必须在应用渲染前生效(本插件 apply 在合成期,早于挂载)。
54
+ - **晚到样式**:分包懒加载/换肤重写都靠 `MutationObserver(head)` 防抖重扫 + 2s 轮询兜底(纯 CSSOM 枚举,无布局开销)。
55
+ - **取舍**:与 dsh-web-shell 的 `forceAnimations` 相同 —— 覆盖系统无障碍设置。窗口级覆盖(启动器)换成了会话级覆盖(插件),想恢复系统行为就得停用本插件;若将来要做成可开关,参考 dsh-model-organizer 的设置卡座位。
56
+ - web 窗口里本模块与 dsh-web-shell 的 CDP 覆盖并存是**无害冗余**(都指向 no-preference)。
57
+
58
+ ## 从 dsh-web-shell 迁移的功能(2026-09-30)
59
+
60
+ 对话区左侧滚动条与宽表格修复原是 dsh-web-shell 的 CDP 注入脚本,已整体迁入本插件(client bundle 自由 JS),dsh-web-shell 自身删除了对应代码与 `leftScrollbar` / `tableWidthFix` 配置。迁移时保留的全部实现要点都在 `lib/client.js` 各模块的注释里(宿主挂 body 的理由、composerSeat 裁剪 −4px、拖动三层防御、零位移 wheel 解阅读锚点、出血布局三件套等),此处不重复;下面只记两块迁移后才成立的知识:
61
+
62
+ - **「检查更新」按安装来源分流**:dsh-web-shell 时代来源由 PowerShell 读 profile 依赖判定;迁入插件后走 host 路由(见「更新检查的实现要点」)。
63
+ - **判断 dsh 是否已修宽表格**(决定何时删除模块 1):打开含 ≥4 列表格的会话,控制台执行 `getComputedStyle(document.querySelector('[class*="tableScroll"]')).maxWidth` —— 若不再是 `100%`(或包裹层不再越界)说明官方已修,删除 `installTableWidthFix` 函数及其 `apply` 里的 guard 行即完全回滚。
64
+
65
+ ## 待办 / 路线
66
+
67
+ - [ ] 入口按钮从「会话头部 utilities」迁到 `sidebar.footer.action`(常驻可见,@linxin666/dsh-update 用同一个座位)—— 需先验证空座位容器是否也渲染 `data-slot` 锚点。
68
+ - [ ] 有更新时按钮加角标(现在要点开面板才看到)。
69
+ - [ ] i18n(`ctx.locale.register(NS, {zh, en})`,照 dsh-model-organizer 的写法)。
70
+ - [ ] 第 3 优化点:会话内「重启 dsh」按钮(需要 host 侧配合或官方重启 API,未调研)。
71
+ - [ ] `waitForInstall` 终态按实测收紧;`installBundle` 的 registry 参数补齐。
72
+ - [ ] 插件被禁用/卸载时的运行时清理(目前 DOM 残留到页面刷新为止)。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NimoXie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @nimoxie/dsh-ui-tweaks
2
+
3
+ [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) Web UI 的微调合集插件。装上即生效,无需配置。
4
+
5
+ ## 功能
6
+
7
+ - **宽表格越界修复** —— 修复 dsh 本体的一个 CSS 缺陷(≥4 列表格撑出对话区、右沿被裁掉还看不到滚动条)。放弃官方的「出血」布局,把宽表格收敛回正文列,横向可滚动。
8
+ - **对话区左侧滚动条** —— 对话区的原生滚动条贴在最右缘,宽屏下要横跨半屏去够;本插件在右侧宽度调整条的左边放一条自绘的常显滚动条:拖拽、点轨道翻页、滚轮转动手感都与原生一致,主题自动跟随明暗,且**只在会话页出现**、切到插件页等非会话页自动移除。
9
+ - **动画修复** —— Windows 关闭「动画效果」时 Chromium 会报告 `prefers-reduced-motion: reduce`,dsh 前端几十处媒体查询规则把动画全关(圈圈不转、流光消失)。本插件在页面内清除这些覆盖规则并让 `matchMedia` 按「有动画」应答,效果与 dsh-web-shell 的调试端口覆盖相同,**web 与桌面客户端都生效**。
10
+ - **插件更新检查** —— 会话工具栏多一枚「插件更新」按钮:逐个比对**全部已装插件**与 npm registry 的版本,弹面板列出(名字 / 当前版本 → 远端 / 状态标签);**有新版本的插件点标签就地更新**,支持「全部更新」与中途取消。**不检查 dsh 本体**(桌面客户端自带更新器;web 端本体升级请走 `npm install -g @deepseek-ai/dsh@next`)。
11
+
12
+ ## 环境要求
13
+
14
+ - dsh `>= 0.2.0-rc.1`(依赖 0.2.x 的 `remote.pluginManager` / `remote.pluginInventory` 接口)。
15
+ - web / 桌面客户端均可(桌面端同样适用,但桌面端有自己的本体更新,本插件的更新检查只覆盖插件)。
16
+
17
+ ## 安装
18
+
19
+ ```sh
20
+ # 从 npm 安装
21
+ dsh plugin --profile <profile> add @nimoxie/dsh-ui-tweaks
22
+
23
+ # 本地开发(软链,改完 Ctrl+Shift+R 即生效,见 DEVELOPING.md)
24
+ dsh plugin --profile <profile> add link:<本仓库路径>
25
+ ```
26
+
27
+ ## 注意
28
+
29
+ - 本插件是启动器 dsh-web-shell 的配套插件:对话区滚动条、宽表格修复等界面增强已由本插件提供,`dsh-web-shell` 自身不再注入(其「检查更新」按钮仍负责 **dsh 本体**的比对与升级提示)。
30
+ - 更新走 profile 内的插件安装接口,与官方插件管理页同源;`link:` / `file:` / `git+` 直装的插件没有可比的 registry 版本,会标为「本地链接」跳过。
31
+
32
+ ## 开源协议
33
+
34
+ [MIT](./LICENSE)
@@ -0,0 +1,7 @@
1
+ # dsh-ui-tweaks bundle patch: one host row over the web profile.
2
+ # The row name resolves from the profile's own node_modules where
3
+ # `dsh plugin --profile web add <path>` installed this package.
4
+ # Scoped 包名的 name 必须写全(同 @linxin666 系插件),loader 按它解析 node_modules。
5
+ - insert:
6
+ - id: ui-tweaks
7
+ name: '@nimoxie/dsh-ui-tweaks'
package/lib/client.js ADDED
@@ -0,0 +1,994 @@
1
+ /* dsh-ui-tweaks browser half.
2
+ *
3
+ * 三个独立模块,任一失败都不得拖垮其它(各自 guard):
4
+ * 1. tablefix —— 宽表格越界修复(纯 CSS 覆盖,见 README「宽表格修复」)
5
+ * 2. scrollbar —— 对话区左侧自绘滚动条(从 dsh-web-shell 移植,选择器适配 0.2.x)
6
+ * 3. updates —— 插件更新检查面板(不含 dsh 本体;remote.pluginManager/pluginInventory)
7
+ *
8
+ * 装载机制:shell 的 window.__ModuleLoader__.load + factory(require);
9
+ * apply(ctx) 由客户端 cordis 上下文调用(同 dsh-model-organizer 的结构)。
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: "@nimoxie/dsh-ui-tweaks",
13
+ factory: (require) => {
14
+ const module = { exports: {} };
15
+ const exports = module.exports;
16
+
17
+ const BUILD = "0.1.0";
18
+
19
+ /* 每个模块的注册各套一层 guard:失败只记 warn(座位/模块失败模式是「静默消失」,
20
+ 必须主动吭声;但不能让一个模块的异常打断同文件里其它模块的 apply)。 */
21
+ function guard(name, fn) {
22
+ try {
23
+ return fn();
24
+ } catch (e) {
25
+ try { console.warn("[dsh-ui-tweaks:" + BUILD + "]", name, e); } catch (_) {}
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /* ======================================================================
31
+ * 模块 1:宽表格越界修复(对 dsh 本体的只读样式补丁)
32
+ *
33
+ * 缺陷(0.2.0-rc.1 实测仍在):@deepseek-ai/dsh-client-ui-chat 给
34
+ * .md-table-wide(≥4 列且不在引用块内)做了「出血」布局(width 加宽 +
35
+ * margin-left 负值左移 + padding-left 推回),而 primitives 的
36
+ * .tableScroll{max-width:100%} 管不住内部 width:max-content 的表格,
37
+ * overflow-x:hidden 之下内容直接被裁(实测右沿超出可视区约 280px)。
38
+ *
39
+ * 修法:放弃出血,收敛回正文列 —— 同时中和 width / margin-left /
40
+ * padding-left 三件套并放开 overflow-x(三者缺一不可,只改宽度会留下
41
+ * 负 margin,只去 margin 不约束宽度则继续越界)。
42
+ *
43
+ * 防失效设计:
44
+ * ① 选择器用属性前缀 [class*="tableScroll"](CSS modules「哈希_原名」,
45
+ * dsh 换版本改哈希也不失效,绝不硬编码 hash);
46
+ * ② 全部声明带 !important 压过 dsh 自己的规则;
47
+ * ③ 单一 <style id> + 存在性检查,删除节点即完全回滚。
48
+ * ==================================================================== */
49
+ function installTableWidthFix() {
50
+ var STYLE_ID = "dshuiw-table-width-fix";
51
+ if (document.getElementById(STYLE_ID)) { return; }
52
+ var st = document.createElement("style");
53
+ st.id = STYLE_ID;
54
+ st.setAttribute("data-dshuiw", "table-width-fix");
55
+ st.textContent =
56
+ "[class*=\"tableScroll\"]{" +
57
+ "width:100%!important;" +
58
+ "max-width:100%!important;" +
59
+ "margin-left:0!important;" +
60
+ "padding-left:0!important;" +
61
+ "padding-bottom:0!important;" +
62
+ "overflow-x:auto!important;" +
63
+ "}" +
64
+ /* 内部表格:允许按内容排布(保留列宽语义),宽度交给包裹层约束 */
65
+ "[class*=\"tableScroll\"]>table{" +
66
+ "max-width:none!important;" +
67
+ "min-width:max-content;" +
68
+ "}" +
69
+ /* 滚动条尽量细、贴近 dsh 的视觉语言 */
70
+ "[class*=\"tableScroll\"]{scrollbar-width:thin}";
71
+ (document.head || document.documentElement).appendChild(st);
72
+ }
73
+
74
+ /* ======================================================================
75
+ * 模块 2:对话区左侧自绘滚动条(自绘拇指)
76
+ *
77
+ * 从 dsh-web-shell 的注入脚本移植(该实现经过 A/B 实测):
78
+ * · 宿主挂 document.body + position:fixed —— 挂进 React 管的容器会被
79
+ * 「加载更早」等重渲染抹掉;fixed 还让滚动区自己滚时宿主纹丝不动。
80
+ * · 拖动 = PointerCapture 下的纯函数:sp.scrollTop 由拇指位置一步算出;
81
+ * pointermove 同步写 + scroll 捕获段回纠 + rAF 兜底(三层防御)。
82
+ * · 「加载更早」后 dsh 会钉阅读锚点,只有 wheel/pointerdown 等读者意图
83
+ * 事件会解除 —— 宿主在滚动区外,pointerdown 到不了 dsh 的监听器,
84
+ * 所以按下/滚轮时补发一个零位移 synthetic wheel(notifyReaderIntent)。
85
+ * · 只在「会话视图」显示:scrollport + 右侧调宽条 + 有实际高度三者齐备
86
+ * 才保留宿主,切到插件页等非会话页自动移除。
87
+ * · 高度裁到输入框(composerSeat,sticky 浮在底部)上沿 −4px。
88
+ *
89
+ * 0.2.x 选择器适配(0.1.x 的 data-dsh-part 标记已被 dsh 全部移除):
90
+ * 滚动区 [data-conversation-scroll]
91
+ * 调宽条 [class*="widthHandle"][data-side="right"]
92
+ * 输入框 [class*="composerSeat"]
93
+ * ==================================================================== */
94
+ function installLeftScrollbar() {
95
+ if (window.__DSHUIW_LEFT_SB__) { return; }
96
+ window.__DSHUIW_LEFT_SB__ = true;
97
+ var HOST_ID = "dshuiw-scrollbar-host";
98
+
99
+ function findScroller() {
100
+ return document.querySelector('[data-dsh-part="scrollport"]') ||
101
+ document.querySelector("[data-conversation-scroll]");
102
+ }
103
+ function findRightHandle() {
104
+ var hs = document.querySelectorAll('[data-dsh-part="resize-handle"]');
105
+ for (var i = 0; i < hs.length; i++) {
106
+ if (hs[i].getAttribute("data-side") === "right") { return hs[i]; }
107
+ }
108
+ /* dsh 0.2.x:宽度调整条 = widthHandle 类 + data-side(无 data-dsh-part) */
109
+ return document.querySelector('[class*="widthHandle"][data-side="right"]');
110
+ }
111
+ function findComposer() {
112
+ var c = document.querySelector('[data-dsh-part="composer-input"]');
113
+ if (c) { return c.closest('[class*="composerSeat"]') || c; }
114
+ return document.querySelector('[class*="composerSeat"]');
115
+ }
116
+
117
+ var sp = null, handle = null, host = null, track = null, thumb = null;
118
+ var composerEl = null, ro = null;
119
+ if (window.ResizeObserver) {
120
+ ro = new ResizeObserver(function () { ensure(); });
121
+ }
122
+ var dragging = false, dragOffsetY = 0;
123
+ var curThumbH = 40, curThumbTop = 0, curTrackH = 0;
124
+ var trRect = null;
125
+ var lastPointerY = 0, dragRaf = 0;
126
+
127
+ /* 「加载更早」、切会话等操作可能换掉 scrollport 节点:交互点先校验节点
128
+ 身份,变了就重绑,否则写入落在旧节点上 —— 拖动没反应、松手弹回。 */
129
+ function rebindIfScrollerChanged() {
130
+ if (sp && findScroller() !== sp) { ensure(); return true; }
131
+ return false;
132
+ }
133
+
134
+ function visibleHeight() {
135
+ if (!sp) { return 0; }
136
+ var c = findComposer();
137
+ if (!c) { return sp.clientHeight; }
138
+ var cr = c.getBoundingClientRect();
139
+ var sr = sp.getBoundingClientRect();
140
+ if (cr.top >= sr.bottom - 2) { return sp.clientHeight; }
141
+ var v = cr.top - sr.top - 4;
142
+ return (v > 80 && v < sp.clientHeight) ? v : sp.clientHeight;
143
+ }
144
+
145
+ function build() {
146
+ host = document.createElement("div");
147
+ host.id = HOST_ID;
148
+ host.style.width = "14px";
149
+ var sr = host.attachShadow({ mode: "open" });
150
+ sr.innerHTML = "<style>" +
151
+ ":host{position:fixed;z-index:6}" +
152
+ ".track{position:absolute;top:0;right:0;width:100%;height:100%;" +
153
+ "user-select:none;-webkit-user-select:none;touch-action:none}" +
154
+ ".track[hidden]{display:none}" +
155
+ ".thumb{position:absolute;right:0;width:100%;background:transparent}" +
156
+ ".thumb::after{content:\"\";position:absolute;top:0;bottom:0;right:3px;width:7px;border-radius:4px;" +
157
+ "background:rgba(128,128,128,.4);" +
158
+ "background:color-mix(in srgb,currentColor 32%,transparent);" +
159
+ "transition:width .12s,right .12s,background .12s}" +
160
+ ".track:hover .thumb::after,.thumb.drag::after{right:2px;width:9px;" +
161
+ "background:rgba(128,128,128,.62);" +
162
+ "background:color-mix(in srgb,currentColor 55%,transparent)}" +
163
+ "</style><div class=\"track\" hidden><div class=\"thumb\"></div></div>";
164
+ track = sr.querySelector(".track");
165
+ thumb = sr.querySelector(".thumb");
166
+ wireEvents();
167
+ document.body.appendChild(host);
168
+ }
169
+
170
+ /* thumb 几何:高 = 轨道 × 可见比,位置 = scrollTop / scrollHeight */
171
+ function render() {
172
+ if (!sp || !track || !thumb) { return; }
173
+ track.hidden = false;
174
+ var range = sp.scrollHeight - sp.clientHeight;
175
+ var th = track.clientHeight;
176
+ if (range <= 0 || th <= 0) { track.hidden = true; return; }
177
+ curTrackH = th;
178
+ curThumbH = Math.round(th * sp.clientHeight / sp.scrollHeight);
179
+ if (curThumbH < 28) { curThumbH = 28; }
180
+ if (curThumbH > th) { curThumbH = th; }
181
+ curThumbTop = Math.round(th * sp.scrollTop / sp.scrollHeight);
182
+ if (curThumbTop > th - curThumbH) { curThumbTop = th - curThumbH; }
183
+ if (curThumbTop < 0) { curThumbTop = 0; }
184
+ if (thumb.style.height !== curThumbH + "px") { thumb.style.height = curThumbH + "px"; }
185
+ if (thumb.style.top !== curThumbTop + "px") { thumb.style.top = curThumbTop + "px"; }
186
+ }
187
+
188
+ /* 补发零位移 wheel 作纯阅读意图:解除 dsh 的阅读锚点保留(stopPreserving),
189
+ 否则「加载更早」后拖动会被判成程序化滚动、松手弹回锚点。 */
190
+ function notifyReaderIntent() {
191
+ if (!sp) { return; }
192
+ var targets = [sp];
193
+ try {
194
+ var outer = sp.closest("[data-conversation-scroll]");
195
+ if (outer && outer !== sp) { targets.push(outer); }
196
+ } catch (e) {}
197
+ for (var i = 0; i < targets.length; i++) {
198
+ try {
199
+ targets[i].dispatchEvent(new WheelEvent("wheel", { deltaY: 0, bubbles: false, cancelable: true }));
200
+ } catch (e) {}
201
+ }
202
+ }
203
+
204
+ /* 三层防御共用的纯函数:pointermove 同步写 + scroll 捕获段回纠 + rAF 兜底 */
205
+ function applyDrag() {
206
+ if (!dragging || !sp || !track || !trRect) { return; }
207
+ var top = lastPointerY - dragOffsetY - trRect.top;
208
+ var maxTop = curTrackH - curThumbH;
209
+ if (maxTop <= 0) { return; }
210
+ if (top < 0) { top = 0; }
211
+ if (top > maxTop) { top = maxTop; }
212
+ var want = top / curTrackH * sp.scrollHeight;
213
+ if (Math.abs(sp.scrollTop - want) > 0.5) { sp.scrollTop = want; }
214
+ render();
215
+ }
216
+
217
+ function wireEvents() {
218
+ track.addEventListener("pointerdown", function (e) {
219
+ if (!sp || !track) { return; }
220
+ rebindIfScrollerChanged();
221
+ if (!sp || !track) { return; }
222
+ trRect = track.getBoundingClientRect();
223
+ var tR = thumb.getBoundingClientRect();
224
+ if (e.clientY >= tR.top && e.clientY <= tR.bottom) {
225
+ dragging = true;
226
+ dragOffsetY = e.clientY - tR.top;
227
+ } else {
228
+ /* 点轨道 = 标准翻页:拇指中心跳到点击处,顺势进入拖动 */
229
+ var top0 = e.clientY - trRect.top - curThumbH / 2;
230
+ var maxTop0 = curTrackH - curThumbH;
231
+ if (top0 < 0) { top0 = 0; }
232
+ if (top0 > maxTop0) { top0 = maxTop0; }
233
+ sp.scrollTop = top0 / curTrackH * sp.scrollHeight;
234
+ curThumbTop = top0;
235
+ dragging = true;
236
+ dragOffsetY = e.clientY - (trRect.top + curThumbTop);
237
+ }
238
+ lastPointerY = e.clientY;
239
+ notifyReaderIntent();
240
+ thumb.classList.add("drag");
241
+ try { track.setPointerCapture(e.pointerId); } catch (err) {}
242
+ e.preventDefault();
243
+ render();
244
+ startDragTick();
245
+ });
246
+ track.addEventListener("pointermove", function (e) {
247
+ if (!dragging || !sp || !track || !trRect) { return; }
248
+ lastPointerY = e.clientY;
249
+ if (rebindIfScrollerChanged()) { trRect = track.getBoundingClientRect(); }
250
+ applyDrag();
251
+ });
252
+ function release(e) {
253
+ if (!dragging) { return; }
254
+ dragging = false;
255
+ trRect = null;
256
+ if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = 0; }
257
+ thumb.classList.remove("drag");
258
+ try { track.releasePointerCapture(e.pointerId); } catch (err) {}
259
+ }
260
+ function startDragTick() {
261
+ if (dragRaf) { cancelAnimationFrame(dragRaf); }
262
+ var tick = function () {
263
+ if (!dragging) { dragRaf = 0; return; }
264
+ rebindIfScrollerChanged();
265
+ applyDrag();
266
+ dragRaf = requestAnimationFrame(tick);
267
+ };
268
+ dragRaf = requestAnimationFrame(tick);
269
+ }
270
+ track.addEventListener("pointerup", release);
271
+ track.addEventListener("pointercancel", release);
272
+ /* 悬停在条上时滚轮转发给真容器(同样补发阅读意图) */
273
+ host.addEventListener("wheel", function (e) {
274
+ if (!sp) { return; }
275
+ rebindIfScrollerChanged();
276
+ if (!sp) { return; }
277
+ e.preventDefault();
278
+ notifyReaderIntent();
279
+ sp.scrollTop += e.deltaY;
280
+ render();
281
+ }, { passive: false });
282
+ }
283
+
284
+ function place() {
285
+ if (!host || !sp) { return; }
286
+ var q = sp.getBoundingClientRect();
287
+ var top = Math.round(q.top), h = Math.round(visibleHeight());
288
+ var edge = handle ? handle.getBoundingClientRect().left : q.right;
289
+ var left = Math.round(edge - host.offsetWidth);
290
+ if (host.style.top !== top + "px") { host.style.top = top + "px"; }
291
+ if (host.style.height !== h + "px") { host.style.height = h + "px"; }
292
+ if (host.style.left !== left + "px") { host.style.left = left + "px"; }
293
+ }
294
+
295
+ /* scroll 捕获段监听(scroll 不冒泡但下传捕获段),对节点替换天然免疫;
296
+ 拖动期间还负责把非本脚本的 scrollTop 写动立刻纠正回来。 */
297
+ function onDocScroll(e) {
298
+ if (!sp || e.target !== sp) { return; }
299
+ if (dragging) { applyDrag(); } else { render(); }
300
+ }
301
+ document.addEventListener("scroll", onDocScroll, { capture: true, passive: true });
302
+
303
+ function ensure() {
304
+ var found = findScroller();
305
+ if (found !== sp) { sp = found; }
306
+ var hFound = findRightHandle();
307
+ if (hFound !== handle) {
308
+ if (handle) { try { handle.removeEventListener("pointermove", place); } catch (e) {} }
309
+ handle = hFound;
310
+ if (handle) { try { handle.addEventListener("pointermove", place, { passive: true }); } catch (e) {} }
311
+ }
312
+ /* 只在会话视图显示:scrollport + 调宽条 + 实际高度三者齐备;
313
+ 否则移除宿主(切到插件页等非会话页不留残影)。 */
314
+ var spVisible = !!(sp && sp.isConnected && sp.getBoundingClientRect().height > 0);
315
+ if (!spVisible || !handle) {
316
+ if (host) {
317
+ try { host.remove(); } catch (e) {}
318
+ host = null; track = null; thumb = null;
319
+ dragging = false;
320
+ dragOffsetY = 0;
321
+ if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = 0; }
322
+ }
323
+ return;
324
+ }
325
+ if (!host || !host.isConnected) {
326
+ if (host) { try { host.remove(); } catch (e) {} }
327
+ host = null; track = null; thumb = null;
328
+ build();
329
+ }
330
+ if (ro) {
331
+ try { ro.observe(sp); } catch (e) {}
332
+ var ce = findComposer();
333
+ if (ce !== composerEl) {
334
+ if (composerEl) { try { ro.unobserve(composerEl); } catch (e) {} }
335
+ composerEl = ce;
336
+ if (composerEl) { try { ro.observe(composerEl); } catch (e) {} }
337
+ }
338
+ }
339
+ place();
340
+ render();
341
+ }
342
+
343
+ ensure();
344
+ /* 700ms 兜底轮询:补挂 / 高度 / 落点;不用全文档 MutationObserver
345
+ (流式输出每帧都有 mutation,ensure 读 rect 会强制布局)。 */
346
+ setInterval(ensure, 700);
347
+ window.addEventListener("resize", ensure);
348
+ }
349
+
350
+ /* ======================================================================
351
+ * 模块 3:插件更新检查面板(不含 dsh 本体 —— 桌面端自带更新器,
352
+ * web 端本体升级走 npm,都不归这个插件管)
353
+ *
354
+ * 全部走官方 remote API,不自建 host 路由:
355
+ * remote.pluginManager.listBundles() → 已装插件包(name/version/installed)
356
+ * remote.pluginManager.installBundle(spec, {enabled, requestId}) → 更新
357
+ * remote.pluginManager.waitForInstall(requestId) → 安装进度轮询
358
+ * remote.pluginManager.cancelInstall(requestId) → 取消
359
+ * registry 比对从浏览器直连(registry.npmjs.org 带 CORS *)。
360
+ *
361
+ * 更新严格串行(pnpm 对 profile 的 package.json 有文件锁,并发只会在锁上
362
+ * 排队);更新完成后回读实际版本 —— pnpm 的 minimumReleaseAge 可能静默
363
+ * 暂缓当日发布,只看退出状态会把「没装上」报成「成功」。
364
+ * ==================================================================== */
365
+ function installPluginUpdates(ctx) {
366
+ if (window.__DSHUIW_UPDATES__) { return; }
367
+ window.__DSHUIW_UPDATES__ = true;
368
+ var BTN_ID = "dshuiw-updates-btn-host";
369
+ var PANEL_ID = "dshuiw-updates-panel-host";
370
+ var POS_KEY = "dshuiw-updates-panel-pos";
371
+
372
+ /* ---- semver 比较(rc 比数字/字母、正式版 > 预发布版;字符串比会把
373
+ rc.9 判成大于 rc.10,必须自己实现)---- */
374
+ function cmpSemver(a, b) {
375
+ function parse(v) {
376
+ var m = String(v == null ? "" : v).trim().replace(/^v/, "")
377
+ .match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
378
+ if (!m) { return null; }
379
+ return { core: [+m[1], +m[2], +m[3]], pre: m[4] ? m[4].split(".") : null };
380
+ }
381
+ var pa = parse(a), pb = parse(b);
382
+ if (!pa || !pb) { return String(a) === String(b) ? 0 : (String(a) < String(b) ? -1 : 1); }
383
+ for (var i = 0; i < 3; i++) {
384
+ if (pa.core[i] !== pb.core[i]) { return pa.core[i] < pb.core[i] ? -1 : 1; }
385
+ }
386
+ var ha = pa.pre === null, hb = pb.pre === null;
387
+ if (ha && hb) { return 0; }
388
+ if (ha) { return 1; }
389
+ if (hb) { return -1; }
390
+ for (var j = 0; j < Math.max(pa.pre.length, pb.pre.length); j++) {
391
+ var x = pa.pre[j], y = pb.pre[j];
392
+ if (x === undefined) { return -1; }
393
+ if (y === undefined) { return 1; }
394
+ var xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
395
+ if (xn && yn) { var d = +x - +y; if (d) { return d < 0 ? -1 : 1; } }
396
+ else if (xn) { return -1; }
397
+ else if (yn) { return 1; }
398
+ else if (x !== y) { return x < y ? -1 : 1; }
399
+ }
400
+ return 0;
401
+ }
402
+
403
+ function uuid() {
404
+ try { if (crypto && crypto.randomUUID) { return crypto.randomUUID(); } } catch (e) {}
405
+ return "req-" + Date.now() + "-" + Math.random().toString(36).slice(2);
406
+ }
407
+ function esc(s) {
408
+ return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
409
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;" }[c];
410
+ });
411
+ }
412
+
413
+ /* ---- remote 服务解析:ctx.inject 的 scope 优先(缓存复用),超时兜底直读 ctx ---- */
414
+ var savedScope = null;
415
+ var got = null; // checkNow 的服务句柄缓存({pm})
416
+ function pickService(scope, dotted) {
417
+ var cands = [];
418
+ var s = scope || savedScope;
419
+ if (s) {
420
+ cands.push(s[dotted]);
421
+ var root = dotted.split(".")[0];
422
+ if (s[root]) { cands.push(s[root][dotted.split(".")[1]]); }
423
+ }
424
+ cands.push(ctx && ctx[dotted]);
425
+ if (ctx) {
426
+ var root2 = dotted.split(".")[0];
427
+ if (ctx[root2]) { cands.push(ctx[root2][dotted.split(".")[1]]); }
428
+ }
429
+ for (var i = 0; i < cands.length; i++) { if (cands[i]) { return cands[i]; } }
430
+ return null;
431
+ }
432
+
433
+ function fetchLatest(name) {
434
+ var path = String(name).split("/").map(encodeURIComponent).join("/");
435
+ return fetch("https://registry.npmjs.org/" + path + "/latest", { cache: "no-store" })
436
+ .then(function (r) {
437
+ if (r.status === 404) {
438
+ /* npm 上没有这个包:本地直装(link:/file:)或尚未发布 —— 不是「检查失败」。
439
+ 官方 listBundles 不透传安装来源,404 就是本地包的唯一可靠签名。 */
440
+ var e404 = new Error("npm 上查不到此包(未发布或本地直装)");
441
+ e404.notFound = true;
442
+ throw e404;
443
+ }
444
+ if (!r.ok) { throw new Error("HTTP " + r.status); }
445
+ return r.json();
446
+ })
447
+ .then(function (d) { return d && d.version ? String(d.version) : null; });
448
+ }
449
+
450
+ /* 本地直装(link:/file:/git+/http)没有可比的 registry 版本。
451
+ * 来源判定主路径是 host 半区的 /api/ui-tweaks/local-plugins(读 profile
452
+ * 依赖声明);host 未重启/路由不可用时退回 registry 404 签名。 */
453
+ function fetchLocalNames(cb) {
454
+ fetch("/api/ui-tweaks/local-plugins", { cache: "no-store" })
455
+ .then(function (r) { if (!r.ok) { throw new Error("HTTP " + r.status); } return r.json(); })
456
+ .then(function (d) { cb(d && d.ok && Array.isArray(d.local) ? d.local : []); })
457
+ .catch(function () { cb([]); });
458
+ }
459
+ function localSource(src) {
460
+ var s = String(src || "");
461
+ return /^(link:|file:|git[+:@]|github:|http)/.test(s);
462
+ }
463
+
464
+ /* ---- 面板状态 ---- */
465
+ var rows = []; // {name, version, latest, state, why}
466
+ // state: pending | checking | current | update | local | error | queuing | busy | done | unchanged
467
+ var queue = []; // 待更新名单(串行队列)
468
+ var busyName = ""; // 正在更新的那个
469
+ var busyReq = "";
470
+ var cancelling = false;
471
+ var checking = false;
472
+ var listeners = []; // 面板重绘订阅(面板未开时也维护状态)
473
+
474
+ function emit() { for (var i = 0; i < listeners.length; i++) { try { listeners[i](); } catch (e) {} } }
475
+
476
+ function setRow(name, patch) {
477
+ for (var i = 0; i < rows.length; i++) {
478
+ if (rows[i].name === name) { for (var k in patch) { rows[i][k] = patch[k]; } break; }
479
+ }
480
+ emit();
481
+ }
482
+
483
+ function checkNow() {
484
+ if (checking) { return; }
485
+ checking = true;
486
+ rows = [];
487
+ emit();
488
+ var pm = null;
489
+ // 服务句柄在回调里现取(ctx.inject 服务齐了才回调,可能异步;waitScope 轮询等待)
490
+ if (!got) {
491
+ ctx.inject(["remote", "remote.pluginManager", "remote.pluginInventory"], function (s) {
492
+ savedScope = s;
493
+ got = { pm: pickService(s, "remote.pluginManager") };
494
+ });
495
+ }
496
+ var started = Date.now();
497
+ function waitScope() {
498
+ if (!got && Date.now() - started < 8000) { setTimeout(waitScope, 150); return; }
499
+ if (!got || !got.pm) {
500
+ rows = [{ name: "(插件清单服务不可用)", version: "", latest: "", state: "error", why: "remote.pluginManager 未就绪;请升级 dsh 或稍后重试" }];
501
+ checking = false; emit(); return;
502
+ }
503
+ pm = got.pm;
504
+ Promise.all([pm.listBundles(), pm.listPlugins()])
505
+ .then(function (r) {
506
+ var bundles = (r[0] && r[0].ok && r[0].value) || [];
507
+ var plugins = (r[1] && r[1].ok && r[1].value) || [];
508
+ if (!bundles.length && plugins.length) { bundles = plugins; }
509
+ rows = bundles.map(function (b) {
510
+ var name = b.name || b.id || "";
511
+ var ver = b.version != null ? String(b.version) : (b.installedVersion != null ? String(b.installedVersion) : "");
512
+ var src = typeof b.installed === "string" ? b.installed : (typeof b.source === "string" ? b.source : "");
513
+ return {
514
+ name: name, version: ver, latest: "",
515
+ state: localSource(src) ? "local" : (ver ? "checking" : "unknown"),
516
+ src: src, why: ""
517
+ };
518
+ }).filter(function (x) { return x.name; });
519
+ checking = false;
520
+ emit();
521
+ // 先向 host 半区要本地直装名单,命中的行直接标「本地链接」不进比对 ——
522
+ // 已发布到 npm 的本地包(如 @nimoxie/dsh-model-organizer)靠它认出;
523
+ // host 未重启(无路由)时名单为空,退回 registry 404 签名判定。
524
+ fetchLocalNames(function (localNames) {
525
+ var localSet = {};
526
+ (localNames || []).forEach(function (n) { localSet[n] = true; });
527
+ rows.forEach(function (row) {
528
+ if (localSet[row.name] && row.state === "checking") {
529
+ row.state = "local";
530
+ row.why = "profile 依赖声明为本地路径,不参与 registry 比对";
531
+ }
532
+ });
533
+ emit();
534
+ // 逐个查 registry(串行,别对 registry 打并发)
535
+ var chain = Promise.resolve();
536
+ rows.forEach(function (row) {
537
+ if (row.state !== "checking") { return; }
538
+ chain = chain.then(function () {
539
+ return fetchLatest(row.name).then(function (v) {
540
+ row.latest = v || "";
541
+ row.state = v && cmpSemver(v, row.version) > 0 ? "update" : "current";
542
+ emit();
543
+ }).catch(function (e) {
544
+ row.state = e && e.notFound ? "local" : "error";
545
+ row.why = String(e && e.message || e);
546
+ emit();
547
+ });
548
+ });
549
+ });
550
+ });
551
+ })
552
+ .catch(function (e) {
553
+ rows = [{ name: "(读取插件清单失败)", version: "", latest: "", state: "error", why: String(e && e.message || e) }];
554
+ checking = false; emit();
555
+ });
556
+ }
557
+ waitScope();
558
+ }
559
+
560
+ /* ---- 更新执行:串行队列;装完回读实际版本判定真实结果 ---- */
561
+ function enqueue(name) {
562
+ if (busyName) {
563
+ if (queue.indexOf(name) < 0 && name !== busyName) { queue.push(name); setRow(name, { state: "queuing" }); }
564
+ return;
565
+ }
566
+ queue.push(name);
567
+ pump();
568
+ }
569
+ function pump() {
570
+ var name = queue.shift();
571
+ if (!name) { return; }
572
+ startOne(name);
573
+ }
574
+ function startOne(name) {
575
+ busyName = name;
576
+ busyReq = uuid();
577
+ setRow(name, { state: "busy" });
578
+ emit();
579
+ var pm = pickService(null, "remote.pluginManager");
580
+ if (!pm) { finishOne(name, false, "remote.pluginManager 不可用"); return; }
581
+ var settled = false;
582
+ function finish(ok, why) {
583
+ if (settled) { return; }
584
+ settled = true;
585
+ finishOne(name, ok, why || "");
586
+ }
587
+ try {
588
+ pm.installBundle(name + "@latest", { enabled: true, requestId: busyReq })
589
+ .then(function (res) {
590
+ if (!res || res.ok === false) {
591
+ finish(false, (res && res.failure && (res.failure.message || res.failure.reason)) || (res && res.message) || "安装请求被拒");
592
+ return;
593
+ }
594
+ /* 轮询进度;终态形态未知,多形状都认,240s 兜底 */
595
+ var t0 = Date.now();
596
+ function poll() {
597
+ var done = false;
598
+ try {
599
+ var p = pm.waitForInstall ? pm.waitForInstall(busyReq) : null;
600
+ Promise.resolve(p).then(function (st) {
601
+ var ph = st && (st.phase || st.state || st.status);
602
+ if (ph === "done" || ph === "completed" || ph === "finished" || (st && st.ok === true)) { done = true; finish(true, ""); }
603
+ else if (ph === "failed" || ph === "error" || ph === "cancelled" || (st && st.ok === false)) {
604
+ finish(false, (st && st.failure && (st.failure.message || st.failure.reason)) || "安装失败");
605
+ }
606
+ }).catch(function () { /* 轮询失败不作为终态 */ });
607
+ } catch (e) {}
608
+ if (!done && !settled && Date.now() - t0 < 240000) { setTimeout(poll, 900); }
609
+ else if (!settled && Date.now() - t0 >= 240000) { finish(false, "更新超时(240 秒未结束)"); }
610
+ }
611
+ poll();
612
+ })
613
+ .catch(function (e) { finish(false, String(e && e.message || e)); });
614
+ } catch (e) { finish(false, String(e && e.message || e)); }
615
+ }
616
+ /* 装完回读清单:版本变了才算真的装上(pnpm minimumReleaseAge 可能静默暂缓)。
617
+ * 只用 pluginManager.listBundles —— pluginInventory 没有 listBundles 方法
618
+ * (只有 list()),之前先取 inventory 导致 guard 落到 reject,面板上的
619
+ * 「安装已发起,但回读版本失败」假阳性就是它。装完瞬间清单可能短暂
620
+ * 不可用,重试 3 次(间隔 1.5s)再下结论。 */
621
+ function finishOne(name, ok, why) {
622
+ var before = null;
623
+ for (var i = 0; i < rows.length; i++) { if (rows[i].name === name) { before = rows[i].version; break; } }
624
+ var pm = pickService(null, "remote.pluginManager");
625
+ function done() {
626
+ busyName = ""; busyReq = ""; cancelling = false;
627
+ emit();
628
+ pump();
629
+ }
630
+ function attempt(n) {
631
+ if (!pm || !pm.listBundles) {
632
+ setRow(name, ok ? { state: "unconfirmed", why: "安装已发起;清单服务不可用,未能回读版本" } : { state: "error", why: why || "更新失败" });
633
+ done();
634
+ return;
635
+ }
636
+ Promise.resolve(pm.listBundles()).then(function (r) {
637
+ var bundles = (r && r.ok && r.value) || [];
638
+ var ver = "";
639
+ for (var i = 0; i < bundles.length; i++) {
640
+ if (bundles[i].name === name) { ver = bundles[i].version != null ? String(bundles[i].version) : ""; break; }
641
+ }
642
+ if (!bundles.length && n < 3) { setTimeout(function () { attempt(n + 1); }, 1500); return; }
643
+ if (ver) {
644
+ if (before && ver === before) {
645
+ setRow(name, { state: "unchanged", why: "版本未变化:可能被 pnpm 发布时长策略暂缓,也可能检查时远端版本信息有误" });
646
+ } else {
647
+ setRow(name, { version: ver, state: "current", latest: ver, why: "" });
648
+ }
649
+ } else if (ok) {
650
+ setRow(name, { state: "unconfirmed", why: "安装已发起,但清单里没读到它;点击重查确认" });
651
+ } else {
652
+ setRow(name, { state: "error", why: why || "更新失败" });
653
+ }
654
+ done();
655
+ }).catch(function () {
656
+ if (n < 3) { setTimeout(function () { attempt(n + 1); }, 1500); return; }
657
+ setRow(name, ok ? { state: "unconfirmed", why: "安装已发起,但回读版本失败;点击重查确认" } : { state: "error", why: why || "更新失败" });
658
+ done();
659
+ });
660
+ }
661
+ attempt(0);
662
+ }
663
+ function requestCancel() {
664
+ if (!busyName || cancelling) { return; }
665
+ cancelling = true;
666
+ queue = [];
667
+ rows.forEach(function (r) { if (r.state === "queuing") { r.state = r.latest && cmpSemver(r.latest, r.version) > 0 ? "update" : "current"; } });
668
+ var pm = pickService(null, "remote.pluginManager");
669
+ try { if (pm && pm.cancelInstall && busyReq) { pm.cancelInstall(busyReq); } } catch (e) {}
670
+ setRow(busyName, { state: "error", why: "已请求取消" });
671
+ emit();
672
+ }
673
+
674
+ /* ---- 面板 UI:挂在 body 的 Shadow DOM(fixed 浮层不能放进 flex 工具栏)---- */
675
+ function panelWrap() {
676
+ var host = document.getElementById(PANEL_ID);
677
+ if (host && host.shadowRoot) { return host.shadowRoot.querySelector(".wrap"); }
678
+ if (host) { host.remove(); }
679
+ host = document.createElement("div");
680
+ host.id = PANEL_ID;
681
+ document.body.appendChild(host);
682
+ var sr = host.attachShadow({ mode: "open" });
683
+ sr.innerHTML = "<style>" +
684
+ ".wrap{position:fixed;top:56px;right:16px;z-index:2147483000;width:344px;display:flex;" +
685
+ "flex-direction:column;max-height:64vh;overflow:hidden;border-radius:12px;" +
686
+ "border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.32));" +
687
+ "background:var(--dsw-alias-bg-base,#1b1d23);color:var(--dsw-alias-label-primary,#e8e9ec);" +
688
+ "box-shadow:0 10px 30px rgba(0,0,0,.34);" +
689
+ "font:13px/1.55 system-ui,-apple-system,\"Microsoft YaHei UI\",sans-serif}" +
690
+ ".wrap[hidden]{display:none}" +
691
+ ".hd{display:flex;align-items:center;gap:8px;padding:10px 12px;flex:0 0 auto;" +
692
+ "border-bottom:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.22));" +
693
+ "cursor:move;user-select:none;-webkit-user-select:none}" +
694
+ ".hd b{flex:1;font-size:13px;font-weight:600}" +
695
+ ".x{cursor:pointer;opacity:.55;font-size:17px;line-height:1;padding:0 3px}" +
696
+ ".x:hover{opacity:1}" +
697
+ ".rows{flex:1 1 auto;min-height:0;overflow:auto}" +
698
+ ".row{display:flex;align-items:center;gap:8px;padding:7px 12px;" +
699
+ "border-bottom:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.12))}" +
700
+ ".row:last-child{border-bottom:0}" +
701
+ ".nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}" +
702
+ ".ver{opacity:.6;font-size:12px;white-space:nowrap;font-variant-numeric:tabular-nums}" +
703
+ ".tag{font-size:11px;padding:1px 7px;border-radius:999px;white-space:nowrap;" +
704
+ "border:1px solid transparent;flex:0 0 auto}" +
705
+ ".tag.update{background:rgba(255,138,0,.16);color:#f0a35e;border-color:rgba(255,138,0,.32);cursor:pointer}" +
706
+ ".tag.current{background:rgba(62,207,142,.14);color:#4fbf8b;border-color:rgba(62,207,142,.28)}" +
707
+ ".tag.other{background:rgba(128,128,128,.14);opacity:.85}" +
708
+ ".tag.busy{opacity:.6}" +
709
+ ".tag.error{background:rgba(255,80,80,.14);color:#e07070;border-color:rgba(255,80,80,.28);cursor:pointer}" +
710
+ ".allbtn{cursor:pointer;font-size:12px;padding:2px 9px;border-radius:6px;flex:0 0 auto;" +
711
+ "border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4))}" +
712
+ ".allbtn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14))}" +
713
+ ".allbtn[hidden]{display:none}" +
714
+ ".recheck{cursor:pointer;font-size:12px;padding:2px 9px;border-radius:6px;flex:0 0 auto;" +
715
+ "border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));opacity:.85}" +
716
+ ".recheck:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14));opacity:1}" +
717
+ ".msg{padding:10px 12px;white-space:pre-wrap;word-break:break-word;opacity:.85}" +
718
+ "</style><div class=\"wrap\" hidden></div>";
719
+ var wrap = sr.querySelector(".wrap");
720
+
721
+ /* 拖拽(记 localStorage,重载回原位) */
722
+ try {
723
+ var saved = localStorage.getItem(POS_KEY);
724
+ if (saved) {
725
+ var a = saved.split(",");
726
+ if (isFinite(+a[0]) && isFinite(+a[1])) { wrap.style.left = a[0] + "px"; wrap.style.top = a[1] + "px"; wrap.style.right = "auto"; }
727
+ }
728
+ } catch (e) {}
729
+ sr.addEventListener("pointerdown", function (e) {
730
+ var t = e.target;
731
+ if (!t || !t.closest || !t.closest(".hd") || t.closest(".x") || t.closest(".allbtn")) { return; }
732
+ var r = wrap.getBoundingClientRect();
733
+ var offX = e.clientX - r.left, offY = e.clientY - r.top;
734
+ e.preventDefault();
735
+ function clamp(x, y, w, h) {
736
+ return { x: Math.min(Math.max(0, x), Math.max(0, window.innerWidth - w)),
737
+ y: Math.min(Math.max(0, y), Math.max(0, window.innerHeight - h)) };
738
+ }
739
+ function onMove(ev) {
740
+ var w = wrap.getBoundingClientRect().width || 344, h = wrap.getBoundingClientRect().height || 220;
741
+ var p = clamp(ev.clientX - offX, ev.clientY - offY, w, h);
742
+ wrap.style.left = p.x + "px"; wrap.style.top = p.y + "px"; wrap.style.right = "auto";
743
+ try { localStorage.setItem(POS_KEY, p.x + "," + p.y); } catch (e2) {}
744
+ }
745
+ function onUp() {
746
+ window.removeEventListener("pointermove", onMove);
747
+ window.removeEventListener("pointerup", onUp);
748
+ window.removeEventListener("pointercancel", onUp);
749
+ }
750
+ window.addEventListener("pointermove", onMove);
751
+ window.addEventListener("pointerup", onUp);
752
+ window.addEventListener("pointercancel", onUp);
753
+ });
754
+
755
+ /* 点击外部收起(捕获段,按下即收) */
756
+ document.addEventListener("pointerdown", function (e) {
757
+ var w = document.getElementById(PANEL_ID);
758
+ if (w !== host) { return; }
759
+ var cur = host.shadowRoot.querySelector(".wrap");
760
+ if (!cur || cur.hidden) { return; }
761
+ if (host.contains(e.target)) { return; }
762
+ cur.hidden = true;
763
+ }, true);
764
+ document.addEventListener("keydown", function (e) {
765
+ if (e.key !== "Escape") { return; }
766
+ var cur = host.shadowRoot.querySelector(".wrap");
767
+ if (cur) { cur.hidden = true; }
768
+ });
769
+
770
+ /* 行内点击委托:有新版本 → 入队;更新中/等待 → 取消;失败 → 重试 */
771
+ wrap.addEventListener("click", function (e) {
772
+ var t = e.target;
773
+ if (!t || !t.classList) { return; }
774
+ if (t.classList.contains("x")) { wrap.hidden = true; return; }
775
+ if (t.classList.contains("recheck")) { if (!checking) { checkNow(); } return; }
776
+ if (t.classList.contains("reverify")) { if (!checking) { checkNow(); } return; }
777
+ if (t.classList.contains("allbtn")) {
778
+ rows.forEach(function (r) { if (r.state === "update" && queue.indexOf(r.name) < 0 && r.name !== busyName) { enqueue(r.name); } });
779
+ return;
780
+ }
781
+ if (t.classList.contains("act")) {
782
+ var nm = t.getAttribute("data-n");
783
+ if (nm) { enqueue(nm); }
784
+ return;
785
+ }
786
+ if (t.classList.contains("cancel")) { requestCancel(); return; }
787
+ if (t.classList.contains("retry")) {
788
+ var nm2 = t.getAttribute("data-n");
789
+ checkOne(nm2);
790
+ }
791
+ });
792
+
793
+ /* 状态 → 行渲染订阅 */
794
+ listeners.push(function () { paint(); });
795
+ return wrap;
796
+ }
797
+
798
+ function checkOne(name) {
799
+ for (var i = 0; i < rows.length; i++) {
800
+ if (rows[i].name === name) {
801
+ rows[i].state = "checking"; rows[i].why = "";
802
+ emit();
803
+ fetchLatest(name).then(function (v) {
804
+ var r = null;
805
+ for (var j = 0; j < rows.length; j++) { if (rows[j].name === name) { r = rows[j]; break; } }
806
+ if (!r) { return; }
807
+ r.latest = v || "";
808
+ r.state = v && cmpSemver(v, r.version) > 0 ? "update" : "current";
809
+ emit();
810
+ }).catch(function (e) {
811
+ setRow(name, e && e.notFound ? { state: "local", why: String(e.message) } : { state: "error", why: String(e && e.message || e) });
812
+ });
813
+ return;
814
+ }
815
+ }
816
+ }
817
+
818
+ var TAG_TEXT = {
819
+ checking: "比对中 …", current: "已是最新", update: "有新版本",
820
+ local: "本地链接", unknown: "未比对", error: "检查失败", queuing: "等待更新",
821
+ busy: "更新中 …", unchanged: "暂缓更新", unconfirmed: "已发起·未确认"
822
+ };
823
+ function tagHtml(r) {
824
+ var cls = "tag other", act = "", title = "";
825
+ if (r.state === "update") { cls = "tag update act"; act = " data-n=\"" + esc(r.name) + "\""; title = " title=\"点击更新到最新版\""; }
826
+ else if (r.state === "current") { cls = "tag current"; }
827
+ else if (r.state === "error") { cls = "tag error retry"; act = " data-n=\"" + esc(r.name) + "\""; title = " title=\"" + esc(r.why || "检查失败") + "(点击重查)\""; }
828
+ else if (r.state === "local") { title = " title=\"" + esc(r.src || r.why || "本地直装") + ";没有可比的 registry 版本\""; }
829
+ else if (r.state === "queuing" || r.state === "busy") { cls = "tag other cancel"; title = " title=\"点击取消" + (r.state === "busy" ? "本次更新" : "排队") + "\""; }
830
+ else if (r.state === "unchanged") { cls = "tag update act"; act = " data-n=\"" + esc(r.name) + "\""; title = " title=\"" + esc(r.why || "") + "(点击重试)\""; }
831
+ else if (r.state === "unconfirmed") { cls = "tag other reverify"; title = " title=\"" + esc(r.why || "") + "(点击重查)\""; }
832
+ return "<span class=\"" + cls + "\"" + act + title + ">" + esc(TAG_TEXT[r.state] || "未比对") + "</span>";
833
+ }
834
+ function paint() {
835
+ var wrap = panelWrap();
836
+ var upd = 0;
837
+ rows.forEach(function (r) { if (r.state === "update") { upd++; } });
838
+ var allBtn = upd ? "<span class=\"allbtn\">全部更新(" + upd + ")</span>" : "<span class=\"allbtn\" hidden></span>";
839
+ var body;
840
+ if (!rows.length) {
841
+ body = "<div class=\"msg\">" + (checking ? "正在读取插件清单…" : "点上方「重新检查」开始比对") + "</div>";
842
+ } else {
843
+ body = rows.map(function (r) {
844
+ var ver = r.latest && (r.state === "update" || r.state === "unchanged") ? r.version + " → " + r.latest : r.version;
845
+ return "<div class=\"row\">" +
846
+ "<span class=\"nm\" title=\"" + esc(r.name) + "\">" + esc(r.name) + "</span>" +
847
+ "<span class=\"ver\" title=\"" + esc(ver) + "\">" + esc(ver) + "</span>" +
848
+ tagHtml(r) + "</div>";
849
+ }).join("");
850
+ }
851
+ var html = "<div class=\"hd\"><b>插件更新 · " + (busyName ? "正在更新 " + esc(busyName) + " …" : (checking ? "检查中…" : (rows.length ? upd + " 个插件有新版本" : "插件更新检查"))) + "</b>" +
852
+ "<span class=\"recheck\" title=\"重新读取插件清单并比对 registry\">重新检查</span>" + allBtn +
853
+ "<span class=\"x\" title=\"关闭\">×</span></div><div class=\"rows\">" + body + "</div>";
854
+ if (wrap.innerHTML !== html) {
855
+ var keep = wrap.querySelector(".rows");
856
+ var st = keep ? keep.scrollTop : 0;
857
+ wrap.innerHTML = html;
858
+ var nk = wrap.querySelector(".rows");
859
+ if (nk && st) { nk.scrollTop = st; }
860
+ }
861
+ wrap.hidden = false;
862
+ }
863
+
864
+ /* ---- 入口按钮:挂会话头部 utilities 槽(0.2.0-rc.1 实测容器带
865
+ data-slot 属性;React 重渲染会抹掉手插节点,MutationObserver 补挂)---- */
866
+ function mountButton(openPanel) {
867
+ function mount() {
868
+ var slot = document.querySelector('[data-slot="conversation.session.header.utilities"]');
869
+ if (!slot || !slot.parentElement) { return false; }
870
+ if (document.getElementById(BTN_ID)) { return true; }
871
+ var host = document.createElement("span");
872
+ host.id = BTN_ID;
873
+ var root = host.attachShadow({ mode: "open" });
874
+ root.innerHTML = "<style>" +
875
+ "button{font:inherit;font-size:13px;height:26px;padding:0 10px;border-radius:6px;" +
876
+ "border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:transparent;" +
877
+ "color:var(--dsw-alias-label-primary,inherit);cursor:pointer;white-space:nowrap;" +
878
+ "transition:background-color .13s,border-color .13s}" +
879
+ "button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12))}" +
880
+ "</style><button type=\"button\" title=\"比对全部已装插件与 npm registry 的版本(不含 dsh 本体)\">插件更新</button>";
881
+ root.querySelector("button").addEventListener("click", openPanel);
882
+ slot.parentElement.insertBefore(host, slot);
883
+ return true;
884
+ }
885
+ mount();
886
+ try {
887
+ var mo = new MutationObserver(function () {
888
+ if (!document.getElementById(BTN_ID)) { mount(); }
889
+ });
890
+ mo.observe(document.documentElement, { childList: true, subtree: true });
891
+ } catch (e) {}
892
+ setInterval(function () { if (!document.getElementById(BTN_ID)) { mount(); } }, 2000);
893
+ }
894
+
895
+ mountButton(function () { paint(); if (!rows.length && !checking) { checkNow(); } });
896
+ }
897
+
898
+ /* ======================================================================
899
+ * 模块 4:动画修复(把 prefers-reduced-motion 强制成 no-preference)
900
+ *
901
+ * 背景:Windows「动画效果」关闭时 Chromium 对外报告 prefers-reduced-motion:
902
+ * reduce,dsh 前端 17 个包共 40 处 @media (prefers-reduced-motion: reduce)
903
+ * 规则把动画全关(圈圈不转、流光消失)。dsh-web-shell 用 CDP
904
+ * Emulation.setEmulatedMedia 修复,但桌面端默认没有调试端口,CDP 进不去。
905
+ *
906
+ * 页面内等价修法(无需调试端口,web/桌面通用):
907
+ * 1) CSSOM 清除 —— 实测 dsh 的 CSS 全部经 <style> 标签注入(0 处
908
+ * adoptedStyleSheets),同源样式表能从 document.styleSheets 遍历
909
+ * cssRules;把 reduce 的 media 块整条删除后,基础动画声明(都在
910
+ * 非 media 规则里)立即生效 —— 效果与 CDP 覆盖等价。
911
+ * 2) matchMedia 包装 —— chat 包有 3 处 JS 门控(滚动平滑/跳转)读
912
+ * matchMedia;包一层按 no-preference 应答,顺带覆盖未来新增门控。
913
+ * CSS 分包是懒加载的,新 <style> 注入由 MutationObserver + 轮询兜底重扫;
914
+ * 换肤等对既有 style 节点的重写也靠轮询兜回来。
915
+ * ==================================================================== */
916
+ function installAnimationFix() {
917
+ if (window.__DSHUIW_ANIM__) { return; }
918
+ window.__DSHUIW_ANIM__ = true;
919
+ var REDUCE_RE = /prefers-reduced-motion\s*:\s*reduce/i;
920
+ var purged = 0;
921
+
922
+ /* 递归删 reduce media 块;owner 是持有 deleteRule 的一层(sheet 或外层规则) */
923
+ function purge(ruleList, owner) {
924
+ for (var i = ruleList.length - 1; i >= 0; i--) {
925
+ var r = null;
926
+ try { r = ruleList[i]; } catch (e) { continue; }
927
+ if (!r) { continue; }
928
+ if (r.media && REDUCE_RE.test(r.media.mediaText || "")) {
929
+ try { owner.deleteRule(i); purged++; } catch (e) {}
930
+ continue;
931
+ }
932
+ try { if (r.cssRules && r.cssRules.length) { purge(r.cssRules, r); } } catch (e) {}
933
+ }
934
+ }
935
+ function scanAll() {
936
+ var sheets = [];
937
+ try { sheets = Array.prototype.slice.call(document.styleSheets); } catch (e) {}
938
+ try { if (document.adoptedStyleSheets) { sheets = sheets.concat(Array.prototype.slice.call(document.adoptedStyleSheets)); } } catch (e) {}
939
+ for (var i = 0; i < sheets.length; i++) {
940
+ try {
941
+ var sh = sheets[i];
942
+ if (sh && sh.cssRules && sh.cssRules.length) { purge(sh.cssRules, sh); }
943
+ } catch (e) {} // 跨域 <link> 读 cssRules 会抛,静默跳过
944
+ }
945
+ return purged;
946
+ }
947
+
948
+ var first = scanAll();
949
+ try { console.info("[dsh-ui-tweaks:" + BUILD + "] 动画修复:已清除 " + first + " 条 prefers-reduced-motion: reduce 规则"); } catch (e) {}
950
+
951
+ /* 懒加载分包/换肤重写样式 → 防抖重扫;2s 轮询兜底(纯内存枚举,开销可忽略) */
952
+ var timer = 0;
953
+ function schedule() {
954
+ if (timer) { return; }
955
+ timer = setTimeout(function () { timer = 0; scanAll(); }, 200);
956
+ }
957
+ try {
958
+ var mo = new MutationObserver(schedule);
959
+ mo.observe(document.head, { childList: true, subtree: true });
960
+ } catch (e) {}
961
+ setInterval(scanAll, 2000);
962
+
963
+ /* matchMedia 包装:一律按 no-preference 应答(覆盖 JS 门控)。
964
+ 注意要在应用渲染前生效 —— 本插件 apply 在合成期执行,早于挂载。 */
965
+ try {
966
+ var nativeMM = window.matchMedia ? window.matchMedia.bind(window) : null;
967
+ if (nativeMM) {
968
+ window.matchMedia = function (q) {
969
+ var mql = nativeMM(q);
970
+ try {
971
+ var qs = String(q || "");
972
+ if (/prefers-reduced-motion/i.test(qs)) {
973
+ var fake = !/:\s*reduce/i.test(qs); // reduce→false;no-preference/裸→true
974
+ Object.defineProperty(mql, "matches", { value: fake, configurable: true });
975
+ }
976
+ } catch (e) {}
977
+ return mql;
978
+ };
979
+ }
980
+ } catch (e) {}
981
+ }
982
+
983
+ function apply(ctx) {
984
+ try { window.__DSHUIW_BUILD__ = BUILD; } catch (e) {}
985
+ guard("tablefix", installTableWidthFix);
986
+ guard("scrollbar", installLeftScrollbar);
987
+ guard("updates", function () { installPluginUpdates(ctx); });
988
+ guard("anim", installAnimationFix);
989
+ }
990
+
991
+ module.exports = { name: "ui-tweaks", apply: apply, inject: ["remote", "remote.pluginManager", "remote.pluginInventory"] };
992
+ return module.exports;
993
+ }
994
+ });
package/lib/index.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * dsh-ui-tweaks host half.
3
+ *
4
+ * 挂一条 GET /api/ui-tweaks/local-plugins:返回当前 profile package.json
5
+ * dependencies 里声明为本地路径(link:/file:/git+/http)的包名清单。
6
+ * 浏览器半区的更新面板据此把这些插件标成「本地链接」、跳过 registry 比对
7
+ * —— listBundles 不透传安装来源,而「npm 上恰好也发布过」的本地包(如
8
+ * @nimoxie/dsh-model-organizer)在客户端侧没有任何别的办法认出来。
9
+ *
10
+ * 纯读操作:只读 profile 的 package.json,不改任何东西;仅回环访问放行。
11
+ */
12
+ import { readdirSync, readFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ export const name = "ui-tweaks";
18
+ export const inject = ["webServer"];
19
+
20
+ const SELF = "@nimoxie/dsh-ui-tweaks";
21
+ const LOCAL_SPEC_RE = /^(link:|file:|git[+:@]|github:|http)/;
22
+ const LOOPBACK_RE = /^(127\.|::1$|::ffff:127\.)/;
23
+
24
+ function readJson(p) {
25
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
26
+ }
27
+
28
+ function hasUs(m) {
29
+ return !!(m && m.dsh && m.dsh.profile && Array.isArray(m.dsh.profile.bundles) && m.dsh.profile.bundles.includes(SELF));
30
+ }
31
+
32
+ function findProfileDir() {
33
+ // ① npm 直装:包物理位于 profile/node_modules 内,向上走必经 profile 根
34
+ // (profile 根 package.json 的判据是 dsh.profile.bundles 且包含本插件)。
35
+ let dir = dirname(dirname(fileURLToPath(import.meta.url)));
36
+ for (let depth = 0; depth < 8; depth++) {
37
+ const m = readJson(join(dir, "package.json"));
38
+ if (hasUs(m)) { return dir; }
39
+ const parent = dirname(dir);
40
+ if (parent === dir) { break; }
41
+ dir = parent;
42
+ }
43
+ // ② link: 软链安装:Node 默认把 ESM 模块解析到真实路径,import.meta.url
44
+ // 指向 profile 外的仓库目录,①必然失败。退回扫描 profiles/*:
45
+ // DSH_HOME 环境变量 → 用户主目录默认 .dsh(dsh 未设环境变量时的标准位置)。
46
+ // 多个 profile 都装了本插件时按 host 进程形态二选一
47
+ // (桌面 host 跑在 ELECTRON_RUN_AS_NODE=1 的 Electron Node 下)。
48
+ const homes = [process.env.DSH_HOME, join(homedir(), ".dsh")].filter(Boolean);
49
+ let hits = [];
50
+ for (const home of homes) {
51
+ try {
52
+ for (const entry of readdirSync(join(home, "profiles"))) {
53
+ const dir2 = join(home, "profiles", entry);
54
+ if (hasUs(readJson(join(dir2, "package.json")))) { hits.push(dir2); }
55
+ }
56
+ } catch { /* 该 home 不存在或不可读,试下一个 */ }
57
+ if (hits.length) { break; }
58
+ }
59
+ if (hits.length === 0) { return null; }
60
+ if (hits.length === 1) { return hits[0]; }
61
+ const wanted = process.env.ELECTRON_RUN_AS_NODE === "1" ? "desktop" : "web";
62
+ return hits.find((d) => new RegExp(wanted + "$").test(d)) || hits[0];
63
+ }
64
+
65
+ export function apply(ctx) {
66
+ const route = {
67
+ kind: "exact",
68
+ path: "/api/ui-tweaks/local-plugins",
69
+ handler: (req, res) => {
70
+ const done = (code, body) => {
71
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
72
+ res.end(body);
73
+ };
74
+ try {
75
+ const addr = String((req.socket && req.socket.remoteAddress) || "");
76
+ if (!LOOPBACK_RE.test(addr)) { done(403, JSON.stringify({ ok: false, code: "forbidden" })); return; }
77
+ if (req.method !== "GET") { done(405, JSON.stringify({ ok: false, code: "method not allowed" })); return; }
78
+ const dir = findProfileDir();
79
+ const manifest = dir ? readJson(join(dir, "package.json")) : null;
80
+ const deps = (manifest && manifest.dependencies) || {};
81
+ const local = Object.keys(deps).filter((n) => LOCAL_SPEC_RE.test(String(deps[n])));
82
+ done(200, JSON.stringify({ ok: true, local, profile: (manifest && manifest.name) || "" }));
83
+ } catch (e) {
84
+ done(500, JSON.stringify({ ok: false, code: String((e && e.message) || e) }));
85
+ }
86
+ }
87
+ };
88
+ ctx.effect(() => {
89
+ const dispose = ctx.webServer.register(route);
90
+ return () => { try { dispose(); } catch {} };
91
+ }, "ui-tweaks: local-plugins route");
92
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@nimoxie/dsh-ui-tweaks",
3
+ "version": "0.1.0",
4
+ "description": "dsh Web UI 微调合集:宽表格越界修复、对话区左侧自绘滚动条、插件更新检查面板(不含 dsh 本体)",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./lib/index.js"
10
+ },
11
+ "./client": {
12
+ "default": "./lib/client.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib",
18
+ "cordis.patch.yml",
19
+ "README.md",
20
+ "DEVELOPING.md",
21
+ "LICENSE"
22
+ ],
23
+ "keywords": [
24
+ "dsh",
25
+ "dsh-plugin",
26
+ "deepseek-harness",
27
+ "ui",
28
+ "scrollbar",
29
+ "table",
30
+ "plugin-updates",
31
+ "reduced-motion"
32
+ ],
33
+ "license": "MIT",
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "author": "NimoXie",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/NimoXie15/dsh-ui-tweaks.git"
41
+ },
42
+ "homepage": "https://github.com/NimoXie15/dsh-ui-tweaks#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/NimoXie15/dsh-ui-tweaks/issues"
45
+ },
46
+ "dsh": {
47
+ "engines": {
48
+ "dsh": ">=0.2.0-rc.1"
49
+ },
50
+ "bundle": {
51
+ "patch": "./cordis.patch.yml"
52
+ },
53
+ "client": {
54
+ "platform": "web",
55
+ "inject": [
56
+ "@deepseek-ai/dsh-api-remotes"
57
+ ]
58
+ }
59
+ }
60
+ }