@objectivex666/dsh-settings-search 1.13.1 → 1.14.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/README.en.md +15 -10
- package/README.md +64 -56
- package/lib/client.js +42 -8
- package/package.json +3 -2
- package/scripts/ai.test.mjs +195 -7
- package/scripts/compat.mjs +255 -0
package/README.en.md
CHANGED
|
@@ -18,16 +18,16 @@
|
|
|
18
18
|
- 🧩 **Three search styles** — choose the classic sidebar, window top bar, or popup search page. The choice is saved in the current browser.
|
|
19
19
|
- ⌨️ **Search shortcut (v1.10.0)** — focus the search box with `Ctrl+F` or `⌘F` inside the settings panel. You can enable or disable it from the "Settings Search" page; disabling keeps the browser's default find behavior.
|
|
20
20
|
- 🤝 **Import DSH model config (v1.9.0)** — import the Base URL and model name from DSH's current default model in the AI config section. When that default is a built-in DeepSeek route (whose settings carry no Base URL), the import uses DeepSeek's own endpoint, `https://api.deepseek.com/anthropic`. For credential safety, the API key is never imported.
|
|
21
|
-
- 🎯 **First-run onboarding (v1.9.0)** — a welcome modal appears only the first time the settings panel opens after installation. It introduces
|
|
22
|
-
- 📂 **In-page option search (v1.2.0)** — search beyond pages and reach the specific options inside them: Plugins-page tabs (`settings.plugins.tab`), the pre-0.2 plugin-card seats (`settings.plugin.item` / `web-ui.plugin.item`, both gone in 0.2), general rows, and more, each shown with a "page › option" breadcrumb.
|
|
21
|
+
- 🎯 **First-run onboarding (v1.9.0)** — a welcome modal appears only the first time the settings panel opens after installation. It introduces live search, pinyin and intent hints, AI hints, click-to-jump, the built-in settings page, and auto-indexing; there are no "Don't show again" or manual reopen buttons.
|
|
22
|
+
- 📂 **In-page option search (v1.2.0)** — search beyond pages and reach the specific options inside them: Plugins-page tabs (`settings.plugins.tab`), the **会话插件 / 全局插件** group titles on the Plugins page, the pre-0.2 plugin-card seats (`settings.plugin.item` / `web-ui.plugin.item`, both gone in 0.2), general rows, and more, each shown with a "page › option" breadcrumb.
|
|
23
23
|
- 🔤 **Pinyin association search (v1.5.0)** — type initials (`sz`) or full pinyin (`shezhi`) to surface Chinese settings items; navigate with `↑`/`↓`, confirm with `Enter`, dismiss with `Esc`, and matched text is highlighted.
|
|
24
24
|
- 🧠 **Intent search (v1.6.0)** — describe the goal in Chinese (e.g. 「太亮了」 "too bright", 「字太小」 "text too small") to surface matching settings without knowing their names, shown in a "You may want" group.
|
|
25
25
|
- 🤖 **AI-assisted search** — when nothing matches locally, your OpenAI-compatible, Anthropic, or local Ollama model understands your search intent and suggests settings. Off by default; configure it on the plugin's settings page. Ollama needs no API key.
|
|
26
|
-
- 💾 **Persistent settings index (v1.12.0)** — the first harvested settings index is stored locally. It rebuilds only on first use
|
|
26
|
+
- 💾 **Persistent settings index (v1.12.0)** — the first harvested settings index is stored locally. It rebuilds only on first use, after another plugin is installed, updated, enabled, or disabled, and can be rebuilt manually from the Settings Search page.
|
|
27
27
|
- 🧾 **Logging & viewing (v1.13.0 / v1.7.0)** — records key plugin actions, searches, and AI requests. Logs are written per local day as `$DSH_HOME/logs/dsh-settings-search/settings-search-YYYY-MM-DD.log`: one dated file per day, capped at 100 MB (the day then continues in numbered parts, `.1`, `.2`, …), with the whole folder held at 100 MB by deleting the oldest files — never the one being written. The plugin's page can **view logs**, **open the log folder**, copy, export, or clear them; the badge beside the "Logs" title counts today's file. API keys are never logged.
|
|
28
28
|
- 🧭 **Click-to-jump** — selecting a result clicks the matching left-nav entry to open its section; for tab options it also opens the tab and flashes the target row. A manual-path hint appears if automatic navigation is not possible.
|
|
29
29
|
- 🌱 **Auto-index on startup (v1.8.0)** — the first time the settings panel opens, the plugin automatically visits every settings section, silently reads in-page options (e.g. "Enable Workshop card") into the search index, then restores your current section, so all section options are searchable without having to open each page manually. A "Importing section options…" hint shows under the search box and clears when done.
|
|
30
|
-
- 🛠 **Built-in settings page (v1.4.0)** — a "Settings Search" page in the left navigation shows the current version and checks npm for a newer release on demand; when one exists it hands you a copy-ready
|
|
30
|
+
- 🛠 **Built-in settings page (v1.4.0)** — a "Settings Search" page in the left navigation shows the current version and checks npm for a newer release on demand; when one exists it hands you a copy-ready upgrade command carrying the profile name and the latest version.
|
|
31
31
|
- 🧭 **View release notes (v1.7.1)** — the "Settings Search" page has a "View changelog" button next to "Check for updates"; it fetches the GitHub release notes and shows them inline, opening the release page if fetching fails.
|
|
32
32
|
- 🌐 **i18n** — built-in Chinese/English dictionaries that follow the DSH localization system and are easy to extend.
|
|
33
33
|
- 🎨 **Theme-aware** — styled entirely with CSS variables, automatically following the DSH theme (light/dark).
|
|
@@ -39,9 +39,11 @@
|
|
|
39
39
|
|
|
40
40
|
This plugin is a standard **DSH profile bundle**: once installed it wires itself into the settings panel — no manual edits to any DSH configuration file.
|
|
41
41
|
|
|
42
|
+
The commands below target a command-line profile. The desktop app manages its own `desktop` profile, which the CLI cannot touch — install the plugin for the desktop app from its plugin management page instead.
|
|
43
|
+
|
|
42
44
|
### Prerequisites
|
|
43
45
|
|
|
44
|
-
- **DeepSeek Harness (DSH)** — DSH 0.2 stable (desktop and `dsh web`) and earlier 0.1 builds; the plugin picks the API the running release offers.
|
|
46
|
+
- **DeepSeek Harness (DSH)** — DSH 0.2 stable (desktop and `dsh web`) and earlier 0.1 builds; the plugin picks the API the running release offers. Verified against **0.2.1-rc.2** item by item (`npm run compat`); behaviour on 0.2.0 and earlier is unchanged.
|
|
45
47
|
- **Node.js** >= 18 (LTS recommended).
|
|
46
48
|
|
|
47
49
|
### From npm (recommended)
|
|
@@ -62,7 +64,7 @@ dsh plugin --profile web add github:objectivex666/dsh-settings-search
|
|
|
62
64
|
dsh plugin --profile web add /path/to/dsh-settings-search
|
|
63
65
|
```
|
|
64
66
|
|
|
65
|
-
After installing, **restart DSH** (`dsh web`); the search box appears
|
|
67
|
+
After installing, **restart DSH** (`dsh web`); the search box appears in the settings panel.
|
|
66
68
|
|
|
67
69
|
To uninstall: `dsh plugin --profile web remove @objectivex666/dsh-settings-search`.
|
|
68
70
|
|
|
@@ -115,9 +117,12 @@ scripts/log.test.mjs Host log file and route regression tests (temp dirs; never
|
|
|
115
117
|
## Development
|
|
116
118
|
|
|
117
119
|
```bash
|
|
118
|
-
npm test
|
|
120
|
+
npm test # structural checks and mocked Ollama / OpenAI / Anthropic regression tests
|
|
121
|
+
npm run compat # audit compatibility against the Harness installed on this machine
|
|
119
122
|
```
|
|
120
123
|
|
|
124
|
+
`npm run compat` reads the local install (auto-detected; `--asar <app.asar>` or `DSH_ASAR=<app.asar>` also work) and checks the surfaces this plugin relies on that belong to no npm API — service names, Remote descriptors, slots, theme tokens, the Fetch route registry, and the DeepSeek constants. Run it first after a Harness upgrade to see exactly what moved.
|
|
125
|
+
|
|
121
126
|
> Historical note: up to v1.1 the repo shipped `dsh-settings-search.js` (an
|
|
122
127
|
> in-session `cordis_define` dynamic-plugin source). Because keeping it in
|
|
123
128
|
> sync with the shipping package kept drifting silently, it was removed in
|
|
@@ -135,7 +140,7 @@ When submitting an issue, please include as much of the following as possible so
|
|
|
135
140
|
|
|
136
141
|
- **Problem description**: what you expected vs. what actually happened.
|
|
137
142
|
- **Reproduction steps**: a step-by-step walkthrough, including the triggering keywords and the settings page involved.
|
|
138
|
-
- **Logs**:
|
|
143
|
+
- **Logs**: open the "Logs" section of the plugin's page, use "Export logs", and paste the recent search/runtime logs (API keys are never logged, so it is safe to share); include any console errors as well.
|
|
139
144
|
- **Environment**: DSH version, Node.js version, plugin version (shown on the "Settings Search" page), OS, and browser.
|
|
140
145
|
- **Screenshots / screen recording**: helpful for UI-related issues.
|
|
141
146
|
|
|
@@ -143,8 +148,8 @@ When submitting an issue, please include as much of the following as possible so
|
|
|
143
148
|
|
|
144
149
|
Every version bump keeps git tags and GitHub Releases in sync, with a bilingual body that lists both new features and fixes:
|
|
145
150
|
|
|
146
|
-
- Update [CHANGELOG.md](./CHANGELOG.md) (Chinese) and [CHANGELOG.en.md](./CHANGELOG.en.md) (English), each with
|
|
147
|
-
- Bump
|
|
151
|
+
- Update [CHANGELOG.md](./CHANGELOG.md) (Chinese) and [CHANGELOG.en.md](./CHANGELOG.en.md) (English), each with a features section and a fixes section (`新功能` / `修复` in Chinese, `Features` / `Fixes` in English).
|
|
152
|
+
- Bump `version` in `package.json` together with `PKG_VERSION` in `lib/client.js` (`npm test` enforces they match), add the version label next to any new feature, then tag and push as `vX.Y.Z`; GitHub Actions publishes to npm (`npm-publish.yml`) and creates a bilingual GitHub Release (`release.yml`).
|
|
148
153
|
- Preview the release body locally: `node scripts/release.mjs --version <ver>`
|
|
149
154
|
- Tag and publish for real: `node scripts/release.mjs --version <ver> --publish` (requires `gh` CLI, logged in)
|
|
150
155
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-settings-search
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 给 DSH(Cordis)设置面板加一个搜索框,输入关键词就跳到对应的设置页和设置项。
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@objectivex666/dsh-settings-search)
|
|
6
6
|
[](./LICENSE)
|
|
@@ -14,36 +14,39 @@
|
|
|
14
14
|
|
|
15
15
|
## 功能特性
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
17
|
+
DSH 的设置项散落在各个分区和插件页里,找一个开关往往要一页页翻过去。这个插件把面板里的每一页、每一行都采进一份索引,再在面板上放一个搜索框,你直接说想找什么就行。
|
|
18
|
+
|
|
19
|
+
- 🔍 **实时搜索**。装好以后设置面板里会多出一个搜索框,输入关键词就实时过滤所有已注册的设置页(`settings.section`)与通用设置项(`settings.general.item`)。
|
|
20
|
+
- 🧩 **三种搜索样式**。经典侧栏、窗口顶栏、弹出搜索页,挑一个顺眼的。选择记在当前浏览器里,换浏览器或换机器要重新选一次。
|
|
21
|
+
- ⌨️ **搜索快捷键**(v1.10.0)。在设置面板里按 `Ctrl+F` 或 `⌘F`,焦点直接落到搜索框上。不需要的话可以在「设置搜索」页关掉,关掉以后按 `Ctrl+F` 恢复成浏览器自己的查找。
|
|
22
|
+
- 🤝 **导入 DSH 模型配置**(v1.9.0)。在 AI 配置区点一下,插件会读取 DSH 当前默认模型的 Base URL 与模型名。默认模型走 DSH 内置的 DeepSeek 路由、设置里没有 Base URL 时,导入的是 `https://api.deepseek.com/anthropic`。导入的内容里没有 API Key,这一点是有意的。
|
|
23
|
+
- 🎯 **新用户引导**(v1.9.0)。装好以后第一次打开设置面板时弹一次,讲的是实时搜索、拼音与意图联想、AI 联想、点击即达、自带设置页和自动导入索引。关掉就不会再出现,也没有手动重开的入口。
|
|
24
|
+
- 📂 **页内选项搜索**(v1.2.0)。索引里有页面,也有页面内部的选项,比如插件页的标签页(`settings.plugins.tab`)、插件页里的**会话插件 / 全局插件**分组标题、旧版的插件卡片座位(`settings.plugin.item` / `web-ui.plugin.item`,0.2 已移除)、通用设置行。结果显示成「页面 › 选项」的面包屑。
|
|
25
|
+
- 🔤 **拼音联想搜索**(v1.5.0)。输入拼音首字母(`sz`)或全拼(`shezhi`)就能联想到中文设置项。`↑`/`↓` 选择,`Enter` 跳转,`Esc` 关闭,命中的字自动高亮。
|
|
26
|
+
- 🧠 **意图联想搜索**(v1.6.0)。记不清选项叫什么也没关系,直接写「太亮了」「字太小」「忘记密码」,插件会猜你指的是哪几个设置,结果显示在「猜你想找」分组里。
|
|
27
|
+
- 🤖 **AI 联想搜索**。本地匹配不到时,把搜索词交给你自己配置的 OpenAI 兼容接口、Anthropic 或本机 Ollama 模型,由模型判断你想改什么。功能默认关闭,需要到「设置搜索」页打开并填好配置,Ollama 不必填 API Key。
|
|
28
|
+
- 💾 **持久设置索引**(v1.12.0)。采集过的索引存在本机浏览器里,只在首次安装,或者别的插件安装、更新、启用、停用时重建,也可以在「设置搜索」页手动重建。
|
|
29
|
+
- 🧾 **日志与查看**(v1.13.0 / v1.7.0)。插件运行、搜索与 AI 请求的关键动作按天写进 DSH 日志目录(`$DSH_HOME/logs/dsh-settings-search/settings-search-YYYY-MM-DD.log`,文件名带日期,一天一个)。单个文件写到 100 MB 就同日换编号分片续写(`.1`、`.2`…),整个日志目录上限 100 MB,超了删最旧的文件,正在写的那个不删。「设置搜索」页里可以直接查看日志、打开日志文件夹、复制、导出或清空,标题旁的计数是当天文件里的条目数。API Key 不会进日志。
|
|
30
|
+
- 🧭 **点击即达**。选中一条结果,插件替你点开左侧对应的导航项,目标在页内标签页里就继续点开那个标签,并把目标行高亮闪一下。跳不过去的时候显示手动路径提示。
|
|
31
|
+
- 🌱 **启动自动索引**(v1.8.0)。第一次打开设置面板时,插件自己把各分区走一遍,静默读走分区里的选项(比如「启用创意工坊卡片」)并收进索引,读完回到你原先待着的分区。过程里搜索框下方有提示,读完自动消失。这之后那些选项不用逐页打开也能搜到。
|
|
32
|
+
- 🛠 **自带设置页**(v1.4.0)。左侧导航多了「设置搜索」页,显示当前版本,可以一键查 npm 上的最新版本。有更新时给出能直接复制执行的升级命令,命令里带着 profile 名和最新版本号。
|
|
33
|
+
- 🧭 **更新说明查看**(v1.7.1)。「设置搜索」页的「检查更新」旁边还有一个「查看更新说明」,拉取 GitHub Release 的内容就地展示,拉不到就直接跳到 Release 页面。
|
|
34
|
+
- 🌐 **多语言支持**。内置中英文两套文案,跟着 DSH 的本地化系统走,也可以继续扩别的语言。
|
|
35
|
+
- 🎨 **主题适配**。样式全部用 CSS 变量写,亮色暗色跟着 DSH 走。
|
|
36
|
+
- ⚡ **动态响应**。插件实时订阅设置项的新增、删除和变更,结果列表跟着刷新。
|
|
35
37
|
|
|
36
38
|
---
|
|
37
39
|
|
|
38
40
|
## 安装(命令行)
|
|
39
41
|
|
|
40
|
-
本插件是一个标准的
|
|
41
|
-
|
|
42
|
+
本插件是一个标准的 DSH profile bundle。装完会自动写进设置面板,不需要手动改任何 DSH 配置文件。
|
|
43
|
+
|
|
44
|
+
下面的命令都是给命令行 profile 用的。桌面端用的是 `desktop` profile,由桌面应用自己管理,命令行改不动它,在桌面端装插件要走应用的插件管理页。
|
|
42
45
|
|
|
43
46
|
### 前置要求
|
|
44
47
|
|
|
45
|
-
- **DeepSeek Harness (DSH)
|
|
46
|
-
- **Node.js
|
|
48
|
+
- **DeepSeek Harness (DSH)**。DSH 0.2 正式版(桌面版与 `dsh web`)和更早的 0.1 版本都可以,插件会按运行中的版本自动选择对应接口。当前已在 **0.2.1-rc.2** 上逐项核对过(`npm run compat`),0.2.0 及之前的行为保持不变。
|
|
49
|
+
- **Node.js**。18 或更高,推荐 LTS。
|
|
47
50
|
|
|
48
51
|
### 通过 npm 安装(推荐)
|
|
49
52
|
|
|
@@ -63,16 +66,23 @@ dsh plugin --profile web add github:objectivex666/dsh-settings-search
|
|
|
63
66
|
dsh plugin --profile web add /path/to/dsh-settings-search
|
|
64
67
|
```
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
卸载:`dsh plugin --profile web remove @objectivex666/dsh-settings-search`。
|
|
69
|
+
装完记得重启 DSH(`dsh web`),打开设置面板就能看见搜索框。要卸载就跑 `dsh plugin --profile web remove @objectivex666/dsh-settings-search`。
|
|
68
70
|
|
|
69
|
-
>
|
|
71
|
+
> 如果你的 profile 不叫 `web`,把命令里的 `--profile web` 换成实际的名字。
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 它不做什么
|
|
76
|
+
|
|
77
|
+
- 索引存在本机浏览器里,换个浏览器要重新采集一遍。
|
|
78
|
+
- 导入 DSH 模型配置时不带 API Key,写日志时也不记录 API Key。
|
|
79
|
+
- 关掉搜索快捷键之后,`Ctrl+F` 还给浏览器,插件不再拦截。
|
|
70
80
|
|
|
71
81
|
---
|
|
72
82
|
|
|
73
83
|
## 手动接线(不通过命令行安装时)
|
|
74
84
|
|
|
75
|
-
|
|
85
|
+
不想用命令行的话,把本包加进 profile 依赖,声明成 bundle 层就行(`dsh plugin add` 帮你做的就是这件事)。
|
|
76
86
|
|
|
77
87
|
```yaml
|
|
78
88
|
# 你的 profile 的 cordis.patch.yml(或通过 --patch 传入)
|
|
@@ -81,27 +91,26 @@ dsh plugin --profile web add /path/to/dsh-settings-search
|
|
|
81
91
|
name: '@objectivex666/dsh-settings-search'
|
|
82
92
|
```
|
|
83
93
|
|
|
84
|
-
包内自带 `cordis.patch.yml
|
|
94
|
+
包内自带 `cordis.patch.yml`,内容和上面一样,`dsh plugin add` 会自动应用。
|
|
85
95
|
|
|
86
96
|
---
|
|
87
97
|
|
|
88
98
|
## 本机 Ollama(v1.11.0)
|
|
89
99
|
|
|
90
|
-
|
|
100
|
+
在「设置搜索」页打开「AI 联想搜索」,把「接口类型」选成「Ollama(本机)」,要填的东西不多。
|
|
91
101
|
|
|
92
|
-
- **Base URL
|
|
93
|
-
-
|
|
94
|
-
- **API Key
|
|
102
|
+
- **Base URL**。默认 `http://127.0.0.1:11434`,端口可以自己改。填根地址、`/api`、`/api/chat` 或 `/v1` 都能识别。
|
|
103
|
+
- **模型名**。点「获取模型」从服务返回的列表里挑,也可以手动敲完整模型名(包括标签),用 `ollama list` 能看到手头有哪些。
|
|
104
|
+
- **API Key**。不用填,发往 Ollama 的请求不会带上原来那套云端密钥。
|
|
95
105
|
|
|
96
|
-
|
|
106
|
+
保存后可以点「测试接口」看看通不通。本地模型第一次加载可能比较慢,Ollama 请求的超时时间定在 120 秒,搜索中途取消会中止这次请求。
|
|
97
107
|
|
|
98
|
-
|
|
108
|
+
请求由浏览器直接发出,所以「本机」说的是浏览器所在的那台机器,不是 DSH 服务器所在的机器。插件不会安装或启动 Ollama,也不会下载模型,服务和模型都要自己准备好。
|
|
99
109
|
|
|
100
|
-
|
|
110
|
+
连不上先看服务地址对不对。浏览器报跨域错误时,把当前 DSH 页面的确切来源(例如 `http://127.0.0.1:3080`)加进 Ollama 的 `OLLAMA_ORIGINS`,重启 Ollama 再试,别图省事写通配符来源。Ollama 不在本机时,地址仍然要求 HTTPS。
|
|
101
111
|
|
|
102
112
|
---
|
|
103
113
|
|
|
104
|
-
|
|
105
114
|
## 包结构
|
|
106
115
|
|
|
107
116
|
```
|
|
@@ -116,35 +125,34 @@ scripts/log.test.mjs 宿主日志文件与路由回归测试(临时目录,
|
|
|
116
125
|
## 开发
|
|
117
126
|
|
|
118
127
|
```bash
|
|
119
|
-
npm test
|
|
128
|
+
npm test # 结构检查及 Ollama / OpenAI / Anthropic 模拟接口回归测试
|
|
129
|
+
npm run compat # 针对本机已安装的 Harness 核对兼容性(服务名、Remote 描述符、slot、主题令牌、路由、DeepSeek 常量)
|
|
120
130
|
```
|
|
121
131
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
132
|
+
`npm run compat` 直接读本机安装包(默认自动查找,也可 `--asar <app.asar>` 或 `DSH_ASAR=<app.asar>`),
|
|
133
|
+
Harness 升级后先跑一遍就能知道哪些「非公开契约」变了——它检查的正是这个插件依赖、但不属于任何 npm API 的那些接口。
|
|
134
|
+
|
|
135
|
+
> 历史说明。v1.1 及更早的版本里,仓库还带一个 `dsh-settings-search.js`(会话内 `cordis_define` 动态插件源)。它和正式包长期分头维护,两边越改越不像,v1.4.1 已经删掉。想参考动态插件的写法可以翻 git 历史,tag `v1.4.0` 里还在。
|
|
125
136
|
|
|
126
137
|
## 反馈与贡献
|
|
127
138
|
|
|
128
|
-
|
|
129
|
-
[GitHub Issues](https://github.com/objectivex666/dsh-settings-search/issues),
|
|
130
|
-
也欢迎直接发起 [Pull Request](https://github.com/objectivex666/dsh-settings-search/pulls)——
|
|
131
|
-
动手前请先读一遍 [贡献指南](./CONTRIBUTING.md)(结构、代码约定、测试与发布流程)。
|
|
139
|
+
用着不对劲、想到什么功能,或者只是没搞明白怎么用,都可以开 [GitHub Issues](https://github.com/objectivex666/dsh-settings-search/issues) 说。也欢迎直接提 [Pull Request](https://github.com/objectivex666/dsh-settings-search/pulls),动手改代码之前先看一眼 [贡献指南](./CONTRIBUTING.md),结构、代码约定、测试和发布流程都写在里面。
|
|
132
140
|
|
|
133
|
-
|
|
141
|
+
提 Issue 时把这些写全,定位和复现都会快很多。
|
|
134
142
|
|
|
135
|
-
-
|
|
136
|
-
-
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
-
|
|
143
|
+
- **问题描述**。你期望发生什么,实际发生了什么。
|
|
144
|
+
- **复现步骤**。一步步写清楚怎么做,包括输入的关键词和当时所在的设置页。
|
|
145
|
+
- **日志**。在「设置搜索」页的「日志」区块里点「导出日志」,把最近的搜索与运行日志贴上来,API Key 不会被记录,可以放心发,控制台里的报错也一并附上更好。
|
|
146
|
+
- **环境信息**。DSH 版本、Node.js 版本、插件版本(「设置搜索」页能看到)、操作系统和浏览器。
|
|
147
|
+
- **截图或录屏**。跟界面表现有关的问题,截图比文字描述清楚。
|
|
140
148
|
|
|
141
149
|
## 更新日志与发布
|
|
142
150
|
|
|
143
|
-
|
|
151
|
+
每次版本更新要同时更新 GitHub Release 和 git tag,正文双语,写清「新功能」与「修复」。
|
|
144
152
|
|
|
145
|
-
- 更新 [CHANGELOG.md](./CHANGELOG.md)
|
|
146
|
-
-
|
|
147
|
-
- 本地预览 release
|
|
148
|
-
-
|
|
153
|
+
- 更新 [CHANGELOG.md](./CHANGELOG.md)(中文)和 [CHANGELOG.en.md](./CHANGELOG.en.md)(英文),两边各要有「新功能」和「修复」两节,英文对应 Features 与 Fixes。
|
|
154
|
+
- 把 `package.json` 的 `version` 和 `lib/client.js` 里的 `PKG_VERSION` 一起改掉(`npm test` 会校验两者一致),功能列表旁边新加的版本标注也顺手补上,然后按 `vX.Y.Z` 打 tag 并推送。GitHub Actions 会同时发 npm(`npm-publish.yml`)并创建双语 GitHub Release(`release.yml`)。
|
|
155
|
+
- 本地预览 release 正文用 `node scripts/release.mjs --version <ver>`
|
|
156
|
+
- 打 tag 并发布用 `node scripts/release.mjs --version <ver> --publish`(需要装好 `gh` CLI 并已登录)
|
|
149
157
|
|
|
150
|
-
>
|
|
158
|
+
> 不带 `--publish` 时,脚本只校验并打印 release 正文,不会创建 tag、推送或发布。
|
package/lib/client.js
CHANGED
|
@@ -30,7 +30,7 @@ window.__ModuleLoader__.load({
|
|
|
30
30
|
|
|
31
31
|
const NS = 'settings-search-v5'
|
|
32
32
|
/** Single source of truth is package.json — scripts/check.mjs asserts this matches. */
|
|
33
|
-
const PKG_VERSION = '1.
|
|
33
|
+
const PKG_VERSION = '1.14.0'
|
|
34
34
|
const NPM_NAME = '@objectivex666/dsh-settings-search'
|
|
35
35
|
const REPO_URL = 'https://github.com/objectivex666/dsh-settings-search'
|
|
36
36
|
const ISSUES_URL = 'https://github.com/objectivex666/dsh-settings-search/issues/new/choose'
|
|
@@ -786,7 +786,15 @@ window.__ModuleLoader__.load({
|
|
|
786
786
|
const SEARCH_STYLE_EVENT = 'dsh-settings-search:style-change'
|
|
787
787
|
const SEARCH_SHORTCUT_KEY = 'dsh-settings-search:shortcut-enabled'
|
|
788
788
|
const SEARCH_SHORTCUT_EVENT = 'dsh-settings-search:shortcut-change'
|
|
789
|
-
|
|
789
|
+
/**
|
|
790
|
+
* Persisted index schema. Bumped whenever the harvest gains entries an old
|
|
791
|
+
* cache cannot contain (v2: in-page group titles such as 会话插件/全局插件),
|
|
792
|
+
* so an upgrade rebuilds instead of serving a stale index.
|
|
793
|
+
*/
|
|
794
|
+
const INDEX_STORAGE_VERSION = 2
|
|
795
|
+
const INDEX_STORAGE_KEY = 'dsh-settings-search:index:v2'
|
|
796
|
+
/** Older schema keys, removed when the index is written again. */
|
|
797
|
+
const LEGACY_INDEX_STORAGE_KEYS = ['dsh-settings-search:index:v1']
|
|
790
798
|
const INDEX_INVALID_EVENT = 'dsh-settings-search:index-invalid'
|
|
791
799
|
const INDEX_REBUILD_EVENT = 'dsh-settings-search:index-rebuild'
|
|
792
800
|
const INDEX_REBUILD_DONE_EVENT = 'dsh-settings-search:index-rebuild-done'
|
|
@@ -1341,7 +1349,7 @@ window.__ModuleLoader__.load({
|
|
|
1341
1349
|
if (typeof localStorage === 'undefined') return null
|
|
1342
1350
|
try {
|
|
1343
1351
|
const raw = JSON.parse(localStorage.getItem(INDEX_STORAGE_KEY) ?? 'null')
|
|
1344
|
-
if (!raw || raw.version !==
|
|
1352
|
+
if (!raw || raw.version !== INDEX_STORAGE_VERSION || typeof raw.fingerprint !== 'string' || raw.fingerprint === '') return null
|
|
1345
1353
|
return {
|
|
1346
1354
|
fingerprint: typeof raw.fingerprint === 'string' ? raw.fingerprint : null,
|
|
1347
1355
|
harvest: deserializeHarvest(raw.harvest),
|
|
@@ -1432,7 +1440,10 @@ window.__ModuleLoader__.load({
|
|
|
1432
1440
|
const bestTitle = (row) => {
|
|
1433
1441
|
let best = ''
|
|
1434
1442
|
let bestLen = 1e9
|
|
1435
|
-
|
|
1443
|
+
// Class matching is case-insensitive (`i`): dsh's CSS-module hashes
|
|
1444
|
+
// keep the source camelCase (`RotMhW_groupTitle`), so a lower-case
|
|
1445
|
+
// substring test would miss every one of them.
|
|
1446
|
+
const sels = ['h1,h2,h3,h4,h5', '[class*="title" i]', '[class*="heading" i]', '[class*="name" i]', '[class*="label" i]', 'label', 'strong', 'b']
|
|
1436
1447
|
for (const sel of sels) {
|
|
1437
1448
|
for (const el of row.querySelectorAll(sel)) {
|
|
1438
1449
|
if (el === row) continue
|
|
@@ -1453,6 +1464,27 @@ window.__ModuleLoader__.load({
|
|
|
1453
1464
|
seen.add(clean)
|
|
1454
1465
|
out.push({ label: clean, text })
|
|
1455
1466
|
}
|
|
1467
|
+
/**
|
|
1468
|
+
* A page-level filter box is not a setting: the Plugins page renders
|
|
1469
|
+
* its own search input inside a `<label>`, whose text would otherwise
|
|
1470
|
+
* be indexed as an in-page option named after the box.
|
|
1471
|
+
*/
|
|
1472
|
+
const isPageFilter = (el) => String(el.tagName ?? '').toUpperCase() === 'LABEL'
|
|
1473
|
+
&& el.querySelector('input[type="search"]') !== null
|
|
1474
|
+
const depthOf = (el) => {
|
|
1475
|
+
let depth = 0
|
|
1476
|
+
for (let cur = el; cur && cur !== node; cur = cur.parentElement) depth += 1
|
|
1477
|
+
return depth
|
|
1478
|
+
}
|
|
1479
|
+
// Structural headings go first: the Plugins page can render dozens
|
|
1480
|
+
// of card titles before its next group title, and `out` is capped
|
|
1481
|
+
// below. Shallower matches are the page/group headings.
|
|
1482
|
+
const heads = [...node.querySelectorAll('h1,h2,h3,h4,h5,label,[class*="title" i],[class*="heading" i],[class*="label" i]')]
|
|
1483
|
+
.filter((el) => !isPageFilter(el))
|
|
1484
|
+
.sort((a, b) => depthOf(a) - depthOf(b))
|
|
1485
|
+
for (const h of heads) {
|
|
1486
|
+
push(bestTitle(h) || titleOf(h), h)
|
|
1487
|
+
}
|
|
1456
1488
|
const ctrls = node.querySelectorAll('input[type="checkbox"], input[type="radio"], [role="switch"], [role="checkbox"], [role="radio"], button[aria-pressed], button[aria-checked]')
|
|
1457
1489
|
for (const ctl of ctrls) {
|
|
1458
1490
|
let row = ctl
|
|
@@ -1464,8 +1496,7 @@ window.__ModuleLoader__.load({
|
|
|
1464
1496
|
}
|
|
1465
1497
|
push(bestTitle(row), row)
|
|
1466
1498
|
}
|
|
1467
|
-
const
|
|
1468
|
-
for (const h of heads) push(bestTitle(h) || titleOf(h), h)
|
|
1499
|
+
for (const h of node.querySelectorAll('[class*="name" i]')) push(bestTitle(h) || titleOf(h), h)
|
|
1469
1500
|
return out.slice(0, 24)
|
|
1470
1501
|
} catch {
|
|
1471
1502
|
return []
|
|
@@ -1553,7 +1584,7 @@ window.__ModuleLoader__.load({
|
|
|
1553
1584
|
if (text === '' || text.length > 200) return ''
|
|
1554
1585
|
return trimDescription(text)
|
|
1555
1586
|
}
|
|
1556
|
-
for (const sel of ['h1,h2,h3,h4,h5', '[class*="title"]', '[class*="heading"]', 'label', 'p']) {
|
|
1587
|
+
for (const sel of ['h1,h2,h3,h4,h5', '[class*="title" i]', '[class*="heading" i]', 'label', 'p']) {
|
|
1557
1588
|
const hit = node.querySelector(sel)
|
|
1558
1589
|
if (hit) { const text = pick(hit); if (text !== '') return text }
|
|
1559
1590
|
}
|
|
@@ -1710,11 +1741,14 @@ window.__ModuleLoader__.load({
|
|
|
1710
1741
|
if (typeof localStorage === 'undefined') return false
|
|
1711
1742
|
try {
|
|
1712
1743
|
localStorage.setItem(INDEX_STORAGE_KEY, JSON.stringify({
|
|
1713
|
-
version:
|
|
1744
|
+
version: INDEX_STORAGE_VERSION,
|
|
1714
1745
|
fingerprint,
|
|
1715
1746
|
updatedAt: Date.now(),
|
|
1716
1747
|
harvest: serializeHarvest(harvest),
|
|
1717
1748
|
}))
|
|
1749
|
+
for (const key of LEGACY_INDEX_STORAGE_KEYS) {
|
|
1750
|
+
try { localStorage.removeItem(key) } catch {}
|
|
1751
|
+
}
|
|
1718
1752
|
return true
|
|
1719
1753
|
} catch {
|
|
1720
1754
|
return false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectivex666/dsh-settings-search",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "DSH settings panel search plugin – instantly filter setting sections and general items with navigation guidance. Installable via `dsh plugin add`.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -56,7 +56,8 @@
|
|
|
56
56
|
"node": ">=18"
|
|
57
57
|
},
|
|
58
58
|
"scripts": {
|
|
59
|
-
"test": "node scripts/check.mjs && node --test scripts/ai.test.mjs scripts/log.test.mjs"
|
|
59
|
+
"test": "node scripts/check.mjs && node --test scripts/ai.test.mjs scripts/log.test.mjs",
|
|
60
|
+
"compat": "node scripts/compat.mjs"
|
|
60
61
|
},
|
|
61
62
|
"publishConfig": {
|
|
62
63
|
"access": "public"
|
package/scripts/ai.test.mjs
CHANGED
|
@@ -42,6 +42,145 @@ const reply = (provider, content = '{"keywords":["Language"]}') => provider ===
|
|
|
42
42
|
? { content: [{ type: 'text', text: content }] }
|
|
43
43
|
: { choices: [{ message: { role: 'assistant', content } }] }
|
|
44
44
|
|
|
45
|
+
// --- A tiny synchronous DOM, enough for the in-page harvest (see the DSH classes below) ---
|
|
46
|
+
|
|
47
|
+
/** Parse one compound selector: an optional tag plus `[attr]`/`[attr="v"]`/`[attr*="v" i]`. */
|
|
48
|
+
const parseCompound = (token) => {
|
|
49
|
+
const tag = /^[a-z0-9]+/i.exec(token)?.[0] ?? ''
|
|
50
|
+
const attrs = []
|
|
51
|
+
const re = /\[([a-zA-Z-]+)(?:([*^$]?=)"([^"]*)"(?:\s+(i))?)?\]/g
|
|
52
|
+
let match
|
|
53
|
+
while ((match = re.exec(token)) !== null) {
|
|
54
|
+
attrs.push({ name: match[1], op: match[2], value: match[3], insensitive: match[4] === 'i' })
|
|
55
|
+
}
|
|
56
|
+
return { tag, attrs }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const attrOf = (node, name) => (name === 'class' ? node.class : node.attr[name])
|
|
60
|
+
|
|
61
|
+
const compoundMatches = (node, compound) => {
|
|
62
|
+
if (compound.tag !== '' && node.tag.toLowerCase() !== compound.tag.toLowerCase()) return false
|
|
63
|
+
for (const attr of compound.attrs) {
|
|
64
|
+
const raw = attrOf(node, attr.name)
|
|
65
|
+
if (raw === undefined) return false
|
|
66
|
+
if (attr.op === undefined) continue
|
|
67
|
+
const value = attr.insensitive ? raw.toLowerCase() : raw
|
|
68
|
+
const expected = attr.insensitive ? attr.value.toLowerCase() : attr.value
|
|
69
|
+
if (attr.op === '=' && value !== expected) return false
|
|
70
|
+
if (attr.op === '*=' && !value.includes(expected)) return false
|
|
71
|
+
}
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Collect descendants matching any compound of a comma-separated selector. */
|
|
76
|
+
const matchAll = (root, selector) => {
|
|
77
|
+
const compounds = selector.split(',')
|
|
78
|
+
.map((token) => parseCompound(token.trim()))
|
|
79
|
+
.filter((compound) => compound.tag !== '' || compound.attrs.length > 0)
|
|
80
|
+
const out = []
|
|
81
|
+
const walk = (node) => {
|
|
82
|
+
for (const child of node.children) {
|
|
83
|
+
if (compounds.some((compound) => compoundMatches(child, compound))) out.push(child)
|
|
84
|
+
walk(child)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
walk(root)
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One element of the fake DOM. `class` follows DSH's CSS-module shape
|
|
93
|
+
* (`RotMhW_groupTitle`), which is what the harvest has to recognise.
|
|
94
|
+
*/
|
|
95
|
+
const el = (tag, options = {}, children = []) => {
|
|
96
|
+
const node = {
|
|
97
|
+
tag,
|
|
98
|
+
tagName: tag.toUpperCase(),
|
|
99
|
+
class: options.class ?? '',
|
|
100
|
+
attr: options.attr ?? {},
|
|
101
|
+
text: options.text ?? '',
|
|
102
|
+
children: [],
|
|
103
|
+
parentElement: null,
|
|
104
|
+
get className() { return node.class },
|
|
105
|
+
get textContent() { return [node.text, ...node.children.map((child) => child.textContent)].filter(Boolean).join(' ') },
|
|
106
|
+
get childElementCount() { return node.children.length },
|
|
107
|
+
get firstElementChild() { return node.children[0] ?? null },
|
|
108
|
+
getAttribute(name) { return attrOf(node, name) ?? null },
|
|
109
|
+
querySelectorAll(selector) { return matchAll(node, selector) },
|
|
110
|
+
querySelector(selector) { return matchAll(node, selector)[0] ?? null },
|
|
111
|
+
}
|
|
112
|
+
node.children = children.map((child) => {
|
|
113
|
+
child.parentElement = node
|
|
114
|
+
return child
|
|
115
|
+
})
|
|
116
|
+
return node
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A document stub over one tree, with the pieces `installStyles` and `panelRoot` need. */
|
|
120
|
+
const fakeDocument = (root) => ({
|
|
121
|
+
head: { appendChild() {} },
|
|
122
|
+
body: root,
|
|
123
|
+
createElement: () => ({ dataset: {}, textContent: '', remove() {} }),
|
|
124
|
+
querySelectorAll: (selector) => matchAll(root, selector),
|
|
125
|
+
querySelector: (selector) => matchAll(root, selector)[0] ?? null,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The DSH 0.2.1 Plugins settings page as the harvest sees it: a search row plus
|
|
130
|
+
* the 会话插件 / 全局插件 groups, whose titles live in `RotMhW_groupTitle`
|
|
131
|
+
* spans (camelCase, like every DSH CSS-module class).
|
|
132
|
+
*/
|
|
133
|
+
const pluginsPageDom = () => {
|
|
134
|
+
const titleRow = (title) => el('div', { class: 'RotMhW_groupTitleRow' }, [
|
|
135
|
+
el('button', { class: 'RotMhW_groupToggle', attr: { 'aria-expanded': 'false' } }, [
|
|
136
|
+
el('span', { class: 'RotMhW_groupTitle', text: title }),
|
|
137
|
+
]),
|
|
138
|
+
])
|
|
139
|
+
const group = (scope, title, rows = 0) => el('section', { class: 'RotMhW_group', attr: { 'data-plugin-scope': scope } }, [
|
|
140
|
+
titleRow(title),
|
|
141
|
+
el('p', { class: 'RotMhW_groupSub', text: scope === 'preset' ? '由 Agent 预设按会话组成' : '系统与所有会话共用' }),
|
|
142
|
+
el('div', { class: 'RotMhW_pluginList' }, Array.from({ length: rows }, (_, i) =>
|
|
143
|
+
el('button', { class: 'RotMhW_pluginRow' }, [
|
|
144
|
+
el('strong', { class: 'RotMhW_cardTitle', text: `${scope}-plugin-${i}` }),
|
|
145
|
+
]))),
|
|
146
|
+
])
|
|
147
|
+
const page = el('div', { class: 'RotMhW_section' }, [
|
|
148
|
+
el('h2', { class: 'RotMhW_heading', text: '插件' }),
|
|
149
|
+
el('div', { class: 'RotMhW_catalog' }, [
|
|
150
|
+
el('label', { class: 'RotMhW_search' }, [
|
|
151
|
+
el('span', { class: 'RotMhW_visuallyHidden', text: '搜索插件' }),
|
|
152
|
+
el('input', { attr: { type: 'search' } }),
|
|
153
|
+
]),
|
|
154
|
+
// More card titles than the 24-entry cap, so a DOM-order harvest would
|
|
155
|
+
// truncate the second group title before seeing it.
|
|
156
|
+
group('preset', '会话插件', 30),
|
|
157
|
+
group('global', '全局插件'),
|
|
158
|
+
]),
|
|
159
|
+
])
|
|
160
|
+
const navButton = el('button', { attr: { 'aria-current': 'true' }, text: '插件' })
|
|
161
|
+
const dialog = el('div', { attr: { role: 'dialog' } }, [
|
|
162
|
+
el('div', { class: 'RotMhW_nav' }, [navButton]),
|
|
163
|
+
el('div', { attr: { 'data-slot': 'settings.section' } }, [page]),
|
|
164
|
+
])
|
|
165
|
+
return { dialog }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A ctx whose Plugins page is the active settings section. */
|
|
169
|
+
const pluginsPageCtx = () => ({
|
|
170
|
+
effect: (fn) => fn(),
|
|
171
|
+
get: () => null,
|
|
172
|
+
inject: () => {},
|
|
173
|
+
locale: { register: () => {}, bind: () => () => '', subscribe: () => () => {} },
|
|
174
|
+
slots: {
|
|
175
|
+
register: (name, id, options) => ({ name, id, options }),
|
|
176
|
+
inject: (_slot, registrar) => registrar(),
|
|
177
|
+
entries: (slot) => (slot === 'settings.section'
|
|
178
|
+
? [{ options: { id: 'plugins', order: 15, label: '插件' } }]
|
|
179
|
+
: []),
|
|
180
|
+
subscribe: () => () => {},
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
|
|
45
184
|
function load(fetchImpl = async () => json(reply('ollama'))) {
|
|
46
185
|
let handoff
|
|
47
186
|
const calls = []
|
|
@@ -75,8 +214,8 @@ test('persistent settings index reuses matching fingerprints and drops invalid r
|
|
|
75
214
|
},
|
|
76
215
|
}
|
|
77
216
|
assert.equal(Object.keys(api.serializeHarvest(new Map())).length, 0)
|
|
78
|
-
sandbox.localStorage.setItem('dsh-settings-search:index:
|
|
79
|
-
version:
|
|
217
|
+
sandbox.localStorage.setItem('dsh-settings-search:index:v2', JSON.stringify({
|
|
218
|
+
version: 2,
|
|
80
219
|
fingerprint: 'manager:test',
|
|
81
220
|
updatedAt: 1,
|
|
82
221
|
harvest: records,
|
|
@@ -114,14 +253,63 @@ test('persistent settings index reuses matching fingerprints and drops invalid r
|
|
|
114
253
|
assert.equal(model.persistIndex('manager:changed'), false)
|
|
115
254
|
})
|
|
116
255
|
|
|
117
|
-
test('readIndexCache rejects malformed persisted index payloads', () => {
|
|
256
|
+
test('readIndexCache rejects malformed and legacy persisted index payloads', () => {
|
|
118
257
|
const { api, sandbox } = load()
|
|
119
|
-
|
|
120
|
-
|
|
258
|
+
const legacy = JSON.stringify({ version: 1, fingerprint: 'manager:old', updatedAt: 1, harvest: {} })
|
|
259
|
+
for (const value of ['{', '{"version":2}', 'null', legacy]) {
|
|
260
|
+
sandbox.localStorage.setItem('dsh-settings-search:index:v2', value)
|
|
121
261
|
assert.equal(api.readIndexCache(), null)
|
|
122
262
|
}
|
|
123
263
|
})
|
|
124
264
|
|
|
265
|
+
test('writing the index uses the current schema and drops the legacy cache key', () => {
|
|
266
|
+
const { api, sandbox } = load()
|
|
267
|
+
const records = { 'settings.general.item': { row1: { title: 'Row', idx: 0, text: '', inner: [] } } }
|
|
268
|
+
sandbox.localStorage.setItem('dsh-settings-search:index:v1', JSON.stringify({ version: 1, fingerprint: 'manager:old', updatedAt: 1, harvest: records }))
|
|
269
|
+
sandbox.localStorage.setItem('dsh-settings-search:index:v2', JSON.stringify({ version: 2, fingerprint: 'manager:test', updatedAt: 1, harvest: records }))
|
|
270
|
+
api.apply({
|
|
271
|
+
effect: (fn) => fn(),
|
|
272
|
+
get: () => null,
|
|
273
|
+
inject: () => {},
|
|
274
|
+
locale: { register: () => {}, bind: () => () => '', subscribe: () => () => {} },
|
|
275
|
+
slots: {
|
|
276
|
+
register: (name, id, options) => ({ name, id, options }),
|
|
277
|
+
inject: (_slot, registrar) => registrar(),
|
|
278
|
+
entries: (slot) => (slot === 'settings.general.item' ? [{ options: { id: 'row1', order: 1 } }] : []),
|
|
279
|
+
subscribe: () => () => {},
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
const model = api.__indexTestModel
|
|
283
|
+
assert.equal(model.persistIndex('manager:next'), true)
|
|
284
|
+
assert.equal(sandbox.localStorage.getItem('dsh-settings-search:index:v1'), null)
|
|
285
|
+
const raw = JSON.parse(sandbox.localStorage.getItem('dsh-settings-search:index:v2'))
|
|
286
|
+
assert.equal(raw.version, 2)
|
|
287
|
+
assert.equal(raw.fingerprint, 'manager:next')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('in-page group titles on the Plugins page are harvested and searchable', () => {
|
|
291
|
+
const { api, sandbox } = load()
|
|
292
|
+
sandbox.document = fakeDocument(pluginsPageDom().dialog)
|
|
293
|
+
api.apply(pluginsPageCtx())
|
|
294
|
+
const model = api.__indexTestModel
|
|
295
|
+
// The warm pass visits the active section and mines what it renders; the
|
|
296
|
+
// 会话插件 / 全局插件 groups are plain DOM, not slot registrations.
|
|
297
|
+
model.runHarvest()
|
|
298
|
+
const options = model.readDeep().filter((item) => item.parentLabel === '插件')
|
|
299
|
+
const labels = options.map((item) => item.label)
|
|
300
|
+
assert.ok(labels.includes('会话插件'), `会话插件 missing from ${JSON.stringify(labels)}`)
|
|
301
|
+
assert.ok(labels.includes('全局插件'), `全局插件 missing from ${JSON.stringify(labels)}`)
|
|
302
|
+
for (const label of ['会话插件', '全局插件']) {
|
|
303
|
+
const option = options.find((item) => item.label === label)
|
|
304
|
+
assert.equal(option.kind, 'option')
|
|
305
|
+
assert.equal(option.pageLabel, '插件')
|
|
306
|
+
assert.equal(option.slot, 'settings.section')
|
|
307
|
+
}
|
|
308
|
+
// The page's own filter box is not a setting: its label text must not become
|
|
309
|
+
// an in-page option (the Plugins tab renders one).
|
|
310
|
+
assert.ok(!labels.includes('搜索插件'), `the page filter leaked into the index: ${JSON.stringify(labels)}`)
|
|
311
|
+
})
|
|
312
|
+
|
|
125
313
|
test('Ollama configuration persists and requires a model but no key', () => {
|
|
126
314
|
const { api, sandbox } = load()
|
|
127
315
|
api.setAiConfig({ ...ollama, model: ' local-model:7b ' })
|
|
@@ -622,7 +810,7 @@ const logRouteCalls = (calls) => calls.filter((call) => String(call.url).include
|
|
|
622
810
|
|
|
623
811
|
test('log entries are batched to the host log route as one formatted line each', () => {
|
|
624
812
|
const { api, calls } = load()
|
|
625
|
-
api.log('info', '应用装载', { version: '1.
|
|
813
|
+
api.log('info', '应用装载', { version: '1.14.0' })
|
|
626
814
|
api.log('error', '导入失败')
|
|
627
815
|
api.flushLogs()
|
|
628
816
|
const [call] = logRouteCalls(calls)
|
|
@@ -631,7 +819,7 @@ test('log entries are batched to the host log route as one formatted line each',
|
|
|
631
819
|
assert.deepEqual(plain(call.headers), { 'content-type': 'application/json' })
|
|
632
820
|
assert.equal(call.body.action, 'append')
|
|
633
821
|
assert.equal(call.body.lines.length, 2)
|
|
634
|
-
assert.match(call.body.lines[0], /^\d{4}-\d{2}-\d{2}T[\d:.]+Z \[info\] \[[0-9a-f]{8}:1\] 应用装载 \{"version":"1\.
|
|
822
|
+
assert.match(call.body.lines[0], /^\d{4}-\d{2}-\d{2}T[\d:.]+Z \[info\] \[[0-9a-f]{8}:1\] 应用装载 \{"version":"1\.14\.0"\}$/)
|
|
635
823
|
assert.match(call.body.lines[1], /\[error\] \[[0-9a-f]{8}:2\] 导入失败$/)
|
|
636
824
|
})
|
|
637
825
|
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Harness compatibility audit.
|
|
4
|
+
*
|
|
5
|
+
* This plugin talks to DSH through surfaces that are not part of any public npm
|
|
6
|
+
* API — client service names, Typert Remote descriptors, slot keys, theme
|
|
7
|
+
* tokens, the host Fetch route registry, and the DeepSeek adapter constants.
|
|
8
|
+
* `npm test` cannot see any of those, so run this against the Harness install
|
|
9
|
+
* you are actually targeting:
|
|
10
|
+
*
|
|
11
|
+
* node scripts/compat.mjs # auto-detect the install
|
|
12
|
+
* node scripts/compat.mjs --asar <app.asar> # explicit archive
|
|
13
|
+
* DSH_ASAR=<app.asar> node scripts/compat.mjs # or through the environment
|
|
14
|
+
*
|
|
15
|
+
* The archive is read read-only and nothing is written. The script exits
|
|
16
|
+
* non-zero as soon as an assumption breaks — that moment is exactly when a
|
|
17
|
+
* Harness upgrade needs this plugin adapted.
|
|
18
|
+
*/
|
|
19
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync } from 'node:fs'
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
|
|
23
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
24
|
+
|
|
25
|
+
/** Candidate install locations, most specific first. */
|
|
26
|
+
const DEFAULT_ASAR_CANDIDATES = [
|
|
27
|
+
'E:\\Program Files\\dsh\\resources\\app.asar',
|
|
28
|
+
'C:\\Program Files\\dsh\\resources\\app.asar',
|
|
29
|
+
'/Applications/DeepSeek Harness.app/Contents/Resources/app.asar',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const argOf = (name) => {
|
|
33
|
+
const at = process.argv.indexOf(name)
|
|
34
|
+
return at >= 0 ? process.argv[at + 1] : undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A minimal read-only Electron asar reader: a pickle-framed JSON header with
|
|
39
|
+
* `{ size, offset }` per entry, the payload starting right after the header.
|
|
40
|
+
* @param archive - absolute path to `app.asar`.
|
|
41
|
+
*/
|
|
42
|
+
const openAsar = (archive) => {
|
|
43
|
+
const fd = openSync(archive, 'r')
|
|
44
|
+
const sizeBuf = Buffer.alloc(8)
|
|
45
|
+
readSync(fd, sizeBuf, 0, 8, 0)
|
|
46
|
+
const headerSize = sizeBuf.readUInt32LE(4)
|
|
47
|
+
const headerBuf = Buffer.alloc(headerSize)
|
|
48
|
+
readSync(fd, headerBuf, 0, headerSize, 8)
|
|
49
|
+
const jsonLength = headerBuf.readUInt32LE(4)
|
|
50
|
+
const header = JSON.parse(headerBuf.toString('utf8', 8, 8 + jsonLength))
|
|
51
|
+
const dataStart = 8 + headerSize
|
|
52
|
+
const files = new Map()
|
|
53
|
+
const walk = (node, prefix) => {
|
|
54
|
+
for (const [name, entry] of Object.entries(node.files ?? {})) {
|
|
55
|
+
const path = prefix === '' ? name : `${prefix}/${name}`
|
|
56
|
+
if (entry.files !== undefined) walk(entry, path)
|
|
57
|
+
else files.set(path, { size: entry.size, offset: Number(entry.offset) })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
walk(header, '')
|
|
61
|
+
const reader = {
|
|
62
|
+
archive,
|
|
63
|
+
files,
|
|
64
|
+
has: (path) => files.has(path),
|
|
65
|
+
read(path) {
|
|
66
|
+
const entry = files.get(path)
|
|
67
|
+
if (entry === undefined) throw new Error(`no entry ${JSON.stringify(path)}`)
|
|
68
|
+
const buffer = Buffer.alloc(entry.size)
|
|
69
|
+
readSync(fd, buffer, 0, entry.size, dataStart + entry.offset)
|
|
70
|
+
return buffer.toString('utf8')
|
|
71
|
+
},
|
|
72
|
+
close: () => closeSync(fd),
|
|
73
|
+
}
|
|
74
|
+
return reader
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Locate the archive to audit. */
|
|
78
|
+
const resolveAsar = () => {
|
|
79
|
+
const explicit = argOf('--asar') ?? process.env.DSH_ASAR
|
|
80
|
+
if (explicit !== undefined && explicit !== '') {
|
|
81
|
+
if (!existsSync(explicit)) throw new Error(`archive not found at ${explicit}`)
|
|
82
|
+
return explicit
|
|
83
|
+
}
|
|
84
|
+
const found = DEFAULT_ASAR_CANDIDATES.find((candidate) => existsSync(candidate))
|
|
85
|
+
if (found === undefined) {
|
|
86
|
+
throw new Error('could not locate a Harness install — pass --asar <app.asar> or set DSH_ASAR')
|
|
87
|
+
}
|
|
88
|
+
return found
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const checks = []
|
|
92
|
+
/**
|
|
93
|
+
* Run one compatibility check. A check returns a short detail string, `true`,
|
|
94
|
+
* or `undefined` when it passes, and throws (or returns `false`) when the
|
|
95
|
+
* Harness no longer matches an assumption this plugin makes.
|
|
96
|
+
*/
|
|
97
|
+
const check = (label, run) => {
|
|
98
|
+
try {
|
|
99
|
+
const detail = run()
|
|
100
|
+
checks.push({ label, ok: detail !== false, detail: detail === true || detail === undefined ? '' : String(detail) })
|
|
101
|
+
} catch (error) {
|
|
102
|
+
checks.push({ label, ok: false, detail: String(error?.message ?? error) })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The client-half source of one package, whatever the platform file is named. */
|
|
107
|
+
const clientSourceOf = (archive, name, cache) => {
|
|
108
|
+
if (cache.has(name)) return cache.get(name)
|
|
109
|
+
const candidates = [
|
|
110
|
+
`dsh/node_modules/@deepseek-ai/${name}/lib/client.js`,
|
|
111
|
+
`dsh/node_modules/@deepseek-ai/${name}/lib/client.browser.js`,
|
|
112
|
+
]
|
|
113
|
+
const found = candidates.find((path) => archive.has(path))
|
|
114
|
+
if (found === undefined) throw new Error(`${name} ships no client bundle`)
|
|
115
|
+
const text = archive.read(found)
|
|
116
|
+
cache.set(name, text)
|
|
117
|
+
return text
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Assert one substring, reporting the missing needle. */
|
|
121
|
+
const mustInclude = (text, needle, message) => {
|
|
122
|
+
if (!text.includes(needle)) throw new Error(message ?? `missing ${JSON.stringify(needle)}`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const main = () => {
|
|
126
|
+
const archivePath = resolveAsar()
|
|
127
|
+
const archive = openAsar(archivePath)
|
|
128
|
+
const cache = new Map()
|
|
129
|
+
try {
|
|
130
|
+
const connectionManifest = JSON.parse(archive.read('dsh/node_modules/@deepseek-ai/dsh-client-connection/package.json'))
|
|
131
|
+
const source = readFileSync(join(ROOT, 'lib/client.js'), 'utf8')
|
|
132
|
+
console.log(`Harness: ${archivePath}`)
|
|
133
|
+
console.log(`Version: ${connectionManifest.version}\n`)
|
|
134
|
+
|
|
135
|
+
check('client connection service is provided', () => {
|
|
136
|
+
mustInclude(clientSourceOf(archive, 'dsh-client-connection', cache), 'provide("connection"', 'the connection handle is gone')
|
|
137
|
+
return 'ctx.connection'
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
check('Remote namespaces live at remote.<namespace>', () => {
|
|
141
|
+
const gateway = clientSourceOf(archive, 'dsh-api-gateway', cache)
|
|
142
|
+
mustInclude(gateway, 'super(ctx, "remote")', 'the `remote` service is gone')
|
|
143
|
+
mustInclude(gateway, 'remote.${namespace}', 'namespace service keys changed')
|
|
144
|
+
return 'remote.<ns>'
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
check('settings/describe answers { namespaces: [{ ns, value }] }', () => {
|
|
148
|
+
const settings = archive.read('dsh/node_modules/@deepseek-ai/dsh-api-settings-controller/lib/typert.remote-client.js')
|
|
149
|
+
mustInclude(settings, "'namespaces'", 'describe value lost `namespaces`')
|
|
150
|
+
mustInclude(settings, "'ns': z.string()", 'namespace view lost `ns`')
|
|
151
|
+
mustInclude(settings, "'value': z.json()", 'namespace view lost `value`')
|
|
152
|
+
return 'ok'
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
check('settings/describe takes no arguments', () => {
|
|
156
|
+
const settings = archive.read('dsh/node_modules/@deepseek-ai/dsh-api-settings-controller/lib/typert.remote-client.js')
|
|
157
|
+
const at = settings.indexOf("'settings/describe':")
|
|
158
|
+
if (at === -1) throw new Error('the settings/describe descriptor is gone')
|
|
159
|
+
if (!/parameters:\s*\[\s*\]/.test(settings.slice(at, at + 900))) throw new Error('settings/describe now takes parameters')
|
|
160
|
+
return 'ok'
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
check('llm/listConfigurableProviders keeps settingsNs/settingsPath', () => {
|
|
164
|
+
const llm = archive.read('dsh/node_modules/@deepseek-ai/dsh-llm/lib/typert.remote-client.js')
|
|
165
|
+
mustInclude(llm, "'settingsNs'", 'rows lost settingsNs')
|
|
166
|
+
mustInclude(llm, "'settingsPath'", 'rows lost settingsPath')
|
|
167
|
+
return 'ok'
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
check('pluginManager inventories keep the fields the fingerprint reads', () => {
|
|
171
|
+
const manager = archive.read('dsh/node_modules/@deepseek-ai/dsh-plugin-manager/lib/typert.remote-client.js')
|
|
172
|
+
for (const field of ["'name'", "'version'", "'enabled'"]) {
|
|
173
|
+
mustInclude(manager, field, `a bundle row lost ${field}`)
|
|
174
|
+
}
|
|
175
|
+
for (const field of ["'entryId'", "'moduleName'"]) {
|
|
176
|
+
mustInclude(manager, field, `a plugin row lost ${field}`)
|
|
177
|
+
}
|
|
178
|
+
return 'name/version/enabled + entryId/moduleName'
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
check('host connection still owns the exact-Fetch route registry', () => {
|
|
182
|
+
const host = archive.read('dsh/node_modules/@deepseek-ai/dsh-client-connection/lib/index.js')
|
|
183
|
+
mustInclude(host, 'registerFetchRoute', 'the host fetch registry is gone')
|
|
184
|
+
const segment = /const ENDPOINT_SEGMENT_PATTERN = \/\^\[([^\]]+)\]/.exec(host)
|
|
185
|
+
if (segment === null) throw new Error('endpoint validation pattern not found')
|
|
186
|
+
const sample = 'settings-search-log'
|
|
187
|
+
if (!new RegExp(`^[${segment[1]}]+$`).test(sample)) throw new Error(`route segment ${sample} is no longer valid`)
|
|
188
|
+
return `/api/${sample}`
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
check('every theme token this plugin uses exists in the Harness theme', () => {
|
|
192
|
+
const theme = clientSourceOf(archive, 'dsh-client-ui-theme', cache)
|
|
193
|
+
const defined = new Set([...theme.matchAll(/--dsw-[a-z0-9-]+/g)].map((match) => match[0]))
|
|
194
|
+
const used = new Set([...source.matchAll(/--dsw-[a-z0-9-]+/g)].map((match) => match[0]))
|
|
195
|
+
const missing = [...used].filter((token) => !defined.has(token))
|
|
196
|
+
if (missing.length > 0) throw new Error(`missing: ${missing.join(', ')}`)
|
|
197
|
+
return `${used.size} tokens`
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
check('the settings seats this plugin indexes still exist', () => {
|
|
201
|
+
const seats = ['settings.header', 'settings.section', 'settings.plugins.tab', 'settings.general.item']
|
|
202
|
+
const bundles = [...archive.files.keys()]
|
|
203
|
+
.filter((path) => /^dsh\/node_modules\/@deepseek-ai\/dsh-client-ui-[^/]+\/lib\/client(\.browser)?\.js$/.test(path))
|
|
204
|
+
const text = bundles.map((path) => archive.read(path)).join('\n')
|
|
205
|
+
const missing = seats.filter((seat) => !text.includes(`"${seat}"`))
|
|
206
|
+
if (missing.length > 0) throw new Error(`undeclared: ${missing.join(', ')}`)
|
|
207
|
+
return `${seats.length} seats`
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
check('the Plugins inventory still renders the 会话/全局 groups this plugin mines', () => {
|
|
211
|
+
const inventory = clientSourceOf(archive, 'dsh-client-ui-settings-plugin-inventory', cache)
|
|
212
|
+
mustInclude(inventory, '"groupTitle"', 'the group title class hook is gone')
|
|
213
|
+
mustInclude(inventory, '"data-plugin-scope"', 'the group scope attribute is gone')
|
|
214
|
+
return 'groupTitle + data-plugin-scope'
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
check('the DeepSeek adapter constants the import fallback mirrors', () => {
|
|
218
|
+
const deepseek = archive.read('dsh/node_modules/@deepseek-ai/dsh-llm-deepseek/lib/index.js')
|
|
219
|
+
mustInclude(deepseek, '"https://api.deepseek.com/anthropic"', 'PUBLIC_BASE_URL changed')
|
|
220
|
+
mustInclude(deepseek, '"DEEPSEEK_BASE_URL"', 'BASE_URL_ENV changed')
|
|
221
|
+
mustInclude(
|
|
222
|
+
archive.read('dsh/node_modules/@deepseek-ai/dsh-llm-deepseek-api-key/lib/index.js'),
|
|
223
|
+
'"deepseek-official"',
|
|
224
|
+
'the deepseek-official provider id changed',
|
|
225
|
+
)
|
|
226
|
+
mustInclude(
|
|
227
|
+
archive.read('dsh/node_modules/@deepseek-ai/dsh-llm-deepseek-account/lib/index.js'),
|
|
228
|
+
'"deepseek-account"',
|
|
229
|
+
'the deepseek-account provider id changed',
|
|
230
|
+
)
|
|
231
|
+
return 'endpoint + two provider ids'
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
check('the client module loader still accepts this plugin\u2019s bundle format', () => {
|
|
235
|
+
mustInclude(clientSourceOf(archive, 'dsh-client-modules', cache), '__ModuleLoader__', 'the __ModuleLoader__ facade is gone')
|
|
236
|
+
mustInclude(source, 'window.__ModuleLoader__.load(', 'this plugin no longer uses the facade')
|
|
237
|
+
return 'window.__ModuleLoader__.load({ id, factory })'
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const failed = checks.filter((entry) => !entry.ok)
|
|
241
|
+
const width = Math.max(...checks.map((entry) => entry.label.length))
|
|
242
|
+
for (const entry of checks) {
|
|
243
|
+
console.log(`${entry.ok ? 'PASS' : 'FAIL'} ${entry.label.padEnd(width)} ${entry.detail}`)
|
|
244
|
+
}
|
|
245
|
+
console.log(`\n${checks.length - failed.length}/${checks.length} compatibility checks passed`)
|
|
246
|
+
if (failed.length > 0) {
|
|
247
|
+
console.error('\nThis Harness needs the plugin adapted before it can be trusted here.')
|
|
248
|
+
process.exitCode = 1
|
|
249
|
+
}
|
|
250
|
+
} finally {
|
|
251
|
+
archive.close()
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
main()
|