dsh-provider-show 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +224 -2
- package/cordis.patch.yml +24 -0
- package/icon.svg +12 -0
- package/lib/client.js +222 -0
- package/lib/index.js +314 -0
- package/lib/ownership.js +216 -0
- package/locale/en.json +9 -0
- package/locale/zh.json +9 -0
- package/package.json +64 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MosCloud
|
|
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
CHANGED
|
@@ -1,3 +1,225 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-provider-show
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/MosCloud/dsh-provider-show/actions/workflows/check.yml)
|
|
4
|
+
|
|
5
|
+
在 DSH 输入框右下角的「模型 · 推理强度」前面,补上**当前模型所属的供应商**。
|
|
6
|
+
|
|
7
|
+
已被其他插件接管的供应商(例如 WorkBuddy)保持原样,不重复显示。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 它解决什么问题
|
|
12
|
+
|
|
13
|
+
DSH 的模型选择器(composer 模型位)自带两项信息:
|
|
14
|
+
|
|
15
|
+
| 位置 | 内容 | 由谁渲染 |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| 模型名 | `DeepSeek-V4.1-Flash` | 宿主,经 `conversation.input.model.label` slot |
|
|
18
|
+
| 推理强度 | `Default` / `High` | 宿主,非 slot |
|
|
19
|
+
|
|
20
|
+
**缺的是第三项:这是哪个供应商的模型。**
|
|
21
|
+
|
|
22
|
+
第三方插件接管某个供应商后会自己补上这一项(WorkBuddy 画 `WB CN · 2,987`、倍率、促销角标),
|
|
23
|
+
但没有任何插件接管的供应商(例如你在设置里自建的火山方舟路由)就只有「模型 · 推理强度」,
|
|
24
|
+
看不出模型来自哪里。
|
|
25
|
+
|
|
26
|
+
本插件补齐这个缺口,并遵守一条让位规则:
|
|
27
|
+
|
|
28
|
+
> **供应商已被第三方插件接管 ⇒ 我方完全不渲染;否则 ⇒ 我方显示供应商名。**
|
|
29
|
+
|
|
30
|
+
## 效果
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
未接管(火山方舟) 火山方舟 DeepSeek V4.1 Flash · Default ← 我方新增「火山方舟」
|
|
34
|
+
已接管(WorkBuddy) DeepSeek-V4.1-Flash · x0.11 … ← 我方不出现
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
芯片挂在模型选择器**左侧、同一条 flex 行**上,只显示供应商名:
|
|
38
|
+
模型名与推理强度宿主已经画了,重复即噪声。
|
|
39
|
+
|
|
40
|
+
## 安装
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
dsh plugin --profile <你的-profile> add /path/to/dsh-provider-show
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
或在插件管理页安装该本地目录。安装后**刷新页面**。
|
|
47
|
+
|
|
48
|
+
卸载同样通过插件管理页,或从 profile 的插件清单移除。
|
|
49
|
+
|
|
50
|
+
## 判定规则
|
|
51
|
+
|
|
52
|
+
「是否已被第三方插件接管」由 Host 半读取 LLM 目录后判定,优先级从上到下:
|
|
53
|
+
|
|
54
|
+
| 顺序 | 规则 | 结果 |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| 1 | `overrides[providerId]` 显式配置 | `third-party` 让位 / `mine` 显示 / `hide` 不显示 |
|
|
57
|
+
| 2 | `settingsNs` 不在 `coreNamespaces` 内 | 判定为**已被插件接管**,让位 |
|
|
58
|
+
| 3 | 目录里查不到该 provider,或目录不可读 | 判定为**未知**,**照常显示**(fail-open) |
|
|
59
|
+
|
|
60
|
+
`settingsNs` 是「注册该 provider 的 Loader entry id」:
|
|
61
|
+
|
|
62
|
+
- `llm-pi-ai` —— 你在设置里自建的 API 路由(火山、OpenRouter、任何 OpenAI 兼容服务)
|
|
63
|
+
- `llm-deepseek` / `llm-deepseek-account` —— 官方 DeepSeek 两条路由
|
|
64
|
+
- 其他任何值 —— 某个插件用**自己的** entry id 注册的,例如 `dsh-connect-workbuddy`
|
|
65
|
+
|
|
66
|
+
### 为什么探测失败时选择「显示」而不是「隐藏」
|
|
67
|
+
|
|
68
|
+
漏显只是信息不全,与插件没装时一样;
|
|
69
|
+
误判为「已接管」会让**所有**供应商都不显示,属于明显回归。
|
|
70
|
+
两种错误代价不对等,所以默认显示。
|
|
71
|
+
|
|
72
|
+
## 配置
|
|
73
|
+
|
|
74
|
+
写在 profile 的 `cordis.patch.yml`,或插件条目的 `config`。**每一项都有默认值,
|
|
75
|
+
但一处配置会替换整个 `config` 对象,所以只写需要改的键即可,其余键自动取默认。**
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
- id: dsh-provider-show
|
|
79
|
+
name: dsh-provider-show
|
|
80
|
+
config:
|
|
81
|
+
# 总开关(默认 true)
|
|
82
|
+
enabled: true
|
|
83
|
+
|
|
84
|
+
# 供应商短名,覆盖目录里的显示名
|
|
85
|
+
shortNames:
|
|
86
|
+
volcano-api: 火山
|
|
87
|
+
|
|
88
|
+
# 强制某个 provider 的处置
|
|
89
|
+
overrides:
|
|
90
|
+
some-connector-provider: third-party # 让位给接管它的插件
|
|
91
|
+
another-provider: mine # 即使探测说被接管,也照常显示
|
|
92
|
+
noisy-provider: hide # 不显示,但不声称它被别人接管
|
|
93
|
+
|
|
94
|
+
# 宿主自有的 settings 命名空间;宿主升级新增内置 provider 时可扩展
|
|
95
|
+
coreNamespaces:
|
|
96
|
+
- llm-pi-ai
|
|
97
|
+
- llm-deepseek
|
|
98
|
+
- llm-deepseek-account
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## 架构
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
lib/ownership.js 纯函数:归属判定 + 文案选择(无任何 import,两侧共用)
|
|
105
|
+
lib/index.js Host 半:读 LLM 目录 → 决策 → GET /plugins/dsh-provider-show/providers
|
|
106
|
+
lib/client.js Client 半:注册 conversation.input.right,读宿主文档并渲染
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 三个关键设计取舍
|
|
110
|
+
|
|
111
|
+
**1. 用 `conversation.input.right`,不用 `conversation.input.model.label`**
|
|
112
|
+
|
|
113
|
+
`input.model.label` 是 `single` slot:同优先级二次注册会**直接抛错**,
|
|
114
|
+
且 workbuddy 已用 `priority: -0.5` 占位、压制了宿主的 1017 徽标。
|
|
115
|
+
去抢那一格必然与第三方插件冲突。
|
|
116
|
+
|
|
117
|
+
`input.right` 是 `list` slot(`replaceRisk: "none"`),用独有 id 叠加,
|
|
118
|
+
渲染位置恰好就在模型选择器左边同一条 flex 行(`ui-conversation` 中
|
|
119
|
+
`.standardControls` 的子节点顺序即 `input.right` → `input.model`)。
|
|
120
|
+
|
|
121
|
+
代价:芯片宽度计入 `.trailing`,会让模型名**更早**折叠成图标,
|
|
122
|
+
所以芯片有 `max-width: 140px` 与省略号截断,并用 `margin-inline-end:-10px`
|
|
123
|
+
抵消宿主的 12px 栅格间距。
|
|
124
|
+
|
|
125
|
+
**排版对齐:靠"同度量",不靠位移**(由 `scripts/verify.mjs` 守住)
|
|
126
|
+
|
|
127
|
+
| 约束 | 原因 |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `font-size:13px` + `font-weight:400` + `line-height:20px` + `height:28px` + `align-items:center`,且**不声明 `font-family`** | 与模型触发器 `.htuFrW_trigger` 完全同一套度量。行盒内基线位置是 `half-leading + ascent`,**由字号与字体决定、与 `line-height` 无关**;度量相同 ⇒ 基线自动重合,不需要任何对齐技巧。 |
|
|
130
|
+
| `margin-inline-end:-10px`、自身 `padding:0` | 宿主 `.standardControls` 有 12px `gap`,模型触发器又有 8px 左内边距;不补偿则两标签相距约 20px,补偿后约 10px,读作一行。 |
|
|
131
|
+
| **禁止 `translateY`** | 见下节。任何人再想加中文补偿,`verify.mjs` 会直接判红。 |
|
|
132
|
+
|
|
133
|
+
### 中文曾经"不居中"的真相(有实测数据)
|
|
134
|
+
|
|
135
|
+
早期我在 12px 字号下看到中文偏高,推断是"汉字占满 em 框、无下伸部"导致的,于是加了位移补偿。
|
|
136
|
+
**这个推断在字号不一致的前提下才成立,字号对齐后就不成立了。**
|
|
137
|
+
|
|
138
|
+
从 2 倍截图里量每个文字块的墨迹质心(1 CSS px = 2 图像 px):
|
|
139
|
+
|
|
140
|
+
| 文字块 | 墨迹行范围 | 质心 |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `火山` | 19..43 | **33.12** |
|
|
143
|
+
| `DeepSeek` | 19..42 | 29.80 |
|
|
144
|
+
| `Flash` | 19..38 | 28.90 |
|
|
145
|
+
| `×0.20` | 19..38 | 28.76 |
|
|
146
|
+
| `High` | 19..42 | 30.02 |
|
|
147
|
+
|
|
148
|
+
拉丁质心均值约 **29.4**。加上 `+2px` 补偿后汉字质心 33.12,比拉丁**低 3.7 图像 px ≈ 1.85 CSS px**;
|
|
149
|
+
去掉补偿正好落到 29.1,与拉丁齐平。**所以 13px 下的最优补偿是 0。**
|
|
150
|
+
|
|
151
|
+
这也解释了为什么纯拉丁标签一直正常:它们的位移恒为 0,从未被补偿影响。
|
|
152
|
+
|
|
153
|
+
### 为什么层级靠颜色而不是字号
|
|
154
|
+
|
|
155
|
+
宿主自己就是这么做的:模型名 `label-secondary`、推理强度 `label-caption`,**两者都是 13px**。
|
|
156
|
+
供应商作为第三层用 `label-tertiary`、同为 13px,于是整行读作「供应商 · 模型 · 强度」一个视觉单位。
|
|
157
|
+
|
|
158
|
+
**2. 归属判定只在 Host 侧做一次**
|
|
159
|
+
|
|
160
|
+
浏览器看不到 `settingsNs`:模型目录的 provider group 只有 `{ id, name, models }`,
|
|
161
|
+
`prepareRoutes` 在进入目录前就把适配器元数据裁掉了。
|
|
162
|
+
所以规则放在 Host,客户端只做查表 —— 一处实现,不会自我漂移。
|
|
163
|
+
|
|
164
|
+
**3. Host 半不 import 任何 Harness 包**
|
|
165
|
+
|
|
166
|
+
本地目录安装会把本仓库链接进 profile,其模块解析走 profile 的 `node_modules`,
|
|
167
|
+
而那里**没有**内置 Harness 包。一个 `import Schema from '@deepseek-ai/schemastery'`
|
|
168
|
+
就会让整个 Host 半加载失败。
|
|
169
|
+
因此配置用纯数据描述(`CONFIG_KEYS` / `effectiveConfig`),不导出 `Config`;
|
|
170
|
+
Loader 在没有 `Config` 时原样透传配置,默认值由本插件自己补。
|
|
171
|
+
|
|
172
|
+
## 开发
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
npm test # 27 项单元 + 行为测试(纯 Node,不需要 DSH 运行时)
|
|
176
|
+
npm run check # 语法检查 + 19 项静态校验 + 全部测试
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`scripts/verify.mjs` 的 19 项检查各自对应一条「否则只会在运行时才暴露」的规则:
|
|
180
|
+
manifest 完整性、图标合法性与体积、locale 非占位文案、客户端只通过
|
|
181
|
+
`__ModuleLoader__` 注册且不 import Harness Client 包、样式只用主题 token 且有宽度上界、
|
|
182
|
+
路由是只读 GET 且在 `ctx.effect` 内注册、归属规则只有一份实现等。
|
|
183
|
+
|
|
184
|
+
## 已知限制
|
|
185
|
+
|
|
186
|
+
- 需要 Host 半的只读路由(`GET /plugins/dsh-provider-show/providers`)可用;
|
|
187
|
+
该路由仅接受本页同源请求。该路径是 `/plugins/` 下的 **exact** 路由,
|
|
188
|
+
优先级高于 `/plugins` 前缀路由(webserver 先查 exact 表),因此不与客户端模块路由冲突。
|
|
189
|
+
- 芯片会让模型名更早折叠为图标(见上文取舍 1)。
|
|
190
|
+
- 当 `conversation.input.activity` 展开整行时,宿主会隐藏整组 `standardControls`,
|
|
191
|
+
芯片随之隐藏 —— 这是宿主行为。
|
|
192
|
+
- 供应商显示名默认取目录里的 `displayName`(即你在设置里填的名字);
|
|
193
|
+
想更短就配 `shortNames`。
|
|
194
|
+
- 归属判定的可靠性取决于第三方插件是否用自有 `settingsNs` 注册 provider。
|
|
195
|
+
若某插件复用核心命名空间,请用 `overrides` 显式标注。
|
|
196
|
+
|
|
197
|
+
## 状态
|
|
198
|
+
|
|
199
|
+
**已完成:安装 → 运行 → 视觉验收。全部验收项通过。**
|
|
200
|
+
|
|
201
|
+
| 项 | 结果 |
|
|
202
|
+
|---|---|
|
|
203
|
+
| 安装 | 进入 profile bundles 与依赖(本地目录 `link:`) |
|
|
204
|
+
| Host 行 | `fiberPhase: active` |
|
|
205
|
+
| 只读路由 | `GET /plugins/dsh-provider-show/providers` 实测 200,决策正确 |
|
|
206
|
+
| 真实判定 | `火山API` → 显示;`workbuddy` / `workbuddy-global` → 让位 |
|
|
207
|
+
| 视觉验收 | 供应商与模型/强度齐平、间距合适、纯拉丁与中文均正常 |
|
|
208
|
+
| 测试 | 38 项(归属规则、Host 路由行为、Client 渲染) |
|
|
209
|
+
| 静态校验 | 23 项,逐条做过反证测试 |
|
|
210
|
+
| CI | GitHub Actions 在 Node 22 / 24 上跑同一套检查 |
|
|
211
|
+
|
|
212
|
+
### 卸载
|
|
213
|
+
|
|
214
|
+
插件管理页禁用该 bundle,或从 profile 依赖里移除 `dsh-provider-show`。
|
|
215
|
+
|
|
216
|
+
## 文档
|
|
217
|
+
|
|
218
|
+
| 文件 | 内容 |
|
|
219
|
+
|---|---|
|
|
220
|
+
| [docs/design.md](docs/design.md) | 需求确认、架构决策(ADR)、边界约束、验收标准、返工记录 |
|
|
221
|
+
| [docs/research.md](docs/research.md) | 技术考据:槽位拓扑、归属判定信号、排版实测数据,均带源码文件与行号 |
|
|
222
|
+
|
|
223
|
+
## 许可
|
|
224
|
+
|
|
225
|
+
[MIT](LICENSE)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# dsh-provider-show — bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# Inserts one plugin row. The row's id is also the settings namespace the Host
|
|
4
|
+
# half's route is namespaced under, and the `provider` namespaces it treats as
|
|
5
|
+
# core by default come from the shipped dsh-base patch, not from this row.
|
|
6
|
+
#
|
|
7
|
+
# Configuration (optional; every key has a default, and this patch replaces the
|
|
8
|
+
# whole `config` object when it names one):
|
|
9
|
+
#
|
|
10
|
+
# - id: dsh-provider-show
|
|
11
|
+
# name: dsh-provider-show
|
|
12
|
+
# config:
|
|
13
|
+
# enabled: true
|
|
14
|
+
# shortNames:
|
|
15
|
+
# volcano-api: 火山
|
|
16
|
+
# overrides:
|
|
17
|
+
# some-connector-provider: third-party
|
|
18
|
+
# coreNamespaces:
|
|
19
|
+
# - llm-pi-ai
|
|
20
|
+
# - llm-deepseek
|
|
21
|
+
# - llm-deepseek-account
|
|
22
|
+
- insert:
|
|
23
|
+
- id: dsh-provider-show
|
|
24
|
+
name: dsh-provider-show
|
package/icon.svg
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Provider Label">
|
|
2
|
+
<title>Provider Label</title>
|
|
3
|
+
<!-- A tag/label silhouette: the chip this plugin draws beside the model seat.
|
|
4
|
+
Two tags, the smaller behind, read as "the model, and the provider it
|
|
5
|
+
came from". Rendered in the product's brand blue and a muted second tier
|
|
6
|
+
so it stays legible on both the light and dark plugin panels, which have
|
|
7
|
+
their own surface colours rather than the page's. -->
|
|
8
|
+
<rect x="6" y="18" width="34" height="24" rx="6" fill="#5686fe" opacity="0.35"/>
|
|
9
|
+
<path d="M24 12h22a6 6 0 0 1 6 6v14a6 6 0 0 1-6 6H34l-10-8 10-8z" fill="#5686fe"/>
|
|
10
|
+
<circle cx="46" cy="21" r="3" fill="#ffffff"/>
|
|
11
|
+
<rect x="24" y="30" width="20" height="3" rx="1.5" fill="#ffffff" opacity="0.85"/>
|
|
12
|
+
</svg>
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-provider-show',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
const { useState, useEffect } = React
|
|
7
|
+
|
|
8
|
+
const NS = 'dsh-provider-show'
|
|
9
|
+
const SLOT = 'conversation.input.right'
|
|
10
|
+
const ENTRY_ID = 'dsh-provider-show-provider'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The Host half's read-only document.
|
|
14
|
+
*
|
|
15
|
+
* Every ownership decision is already made there — this half performs a
|
|
16
|
+
* lookup, never a rule. That asymmetry is deliberate: the rule needs the
|
|
17
|
+
* Host's LLM directory (`settingsNs`), which the browser cannot see, and
|
|
18
|
+
* one authoritative copy cannot drift from itself.
|
|
19
|
+
*/
|
|
20
|
+
const PROVIDERS_PATH = '/plugins/dsh-provider-show/providers'
|
|
21
|
+
|
|
22
|
+
/** How long one document is reused, in milliseconds. */
|
|
23
|
+
const CACHE_MS = 5000
|
|
24
|
+
|
|
25
|
+
const zh = {
|
|
26
|
+
'chip.aria': '当前供应商:{provider}',
|
|
27
|
+
'meta.title': '供应商信息补全',
|
|
28
|
+
'meta.description': '在输入框右下角补全当前模型所属的供应商;已被其他插件接管的供应商不再重复显示。',
|
|
29
|
+
}
|
|
30
|
+
const en = {
|
|
31
|
+
'chip.aria': 'Current provider: {provider}',
|
|
32
|
+
'meta.title': 'Provider Label',
|
|
33
|
+
'meta.description': 'Shows which provider the selected model belongs to in the composer row, and stays silent for a provider another plugin already presents.',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The chip's own stylesheet; theme tokens only, so light/dark both work. */
|
|
37
|
+
const CSS = [
|
|
38
|
+
// 13px / 400 / 20px / 28px are the model trigger's own metrics
|
|
39
|
+
// (`.htuFrW_trigger`), reproduced exactly and with no `font-family` of our
|
|
40
|
+
// own so both inherit one stack. That is the whole alignment mechanism:
|
|
41
|
+
// inside a 20px line box the baseline sits at `half-leading + ascent`,
|
|
42
|
+
// which is fixed by font-size and font — NOT by line-height — so equal
|
|
43
|
+
// metrics put both labels on one baseline with no nudging.
|
|
44
|
+
//
|
|
45
|
+
// Nothing else is needed, and a `translateY` for Han labels was measured
|
|
46
|
+
// out of the design: with the size matched, a screenshot showed the Han
|
|
47
|
+
// ink centroid sitting 1.85 CSS px LOW once nudged, and level with the
|
|
48
|
+
// Latin centroid at zero. Han glyphs extending above the Latin cap line is
|
|
49
|
+
// normal mixed-script typesetting, not a misalignment.
|
|
50
|
+
//
|
|
51
|
+
// The host separates tiers by COLOUR, not size: the model name is
|
|
52
|
+
// label-secondary and the effort is label-caption, both 13px. The provider
|
|
53
|
+
// is the third tier, label-tertiary at the same size.
|
|
54
|
+
'.dpsp-root{display:inline-flex;align-items:center;box-sizing:border-box;',
|
|
55
|
+
'height:28px;min-width:0;max-width:140px;padding:0;',
|
|
56
|
+
// The row spaces its children 12px apart, and the model trigger carries
|
|
57
|
+
// 8px of its own leading padding, so an unpadded chip would sit ~20px from
|
|
58
|
+
// the model text. Pulling the pair together makes them read as one line.
|
|
59
|
+
'margin-inline-end:-10px;',
|
|
60
|
+
'font-size:13px;font-weight:400;line-height:20px;',
|
|
61
|
+
'color:var(--dsw-alias-label-tertiary);white-space:nowrap;user-select:none}',
|
|
62
|
+
'.dpsp-name{min-width:0;overflow:hidden;text-overflow:ellipsis}',
|
|
63
|
+
].join('')
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mount the stylesheet once.
|
|
67
|
+
*
|
|
68
|
+
* @returns {() => void} the disposer that removes it on unload.
|
|
69
|
+
*/
|
|
70
|
+
function installStyles() {
|
|
71
|
+
if (typeof document === 'undefined') return () => {}
|
|
72
|
+
const tagId = 'dsh-provider-show/chip.css'
|
|
73
|
+
if (document.querySelector(`style[data-plugin-css=${JSON.stringify(tagId)}]`) !== null) return () => {}
|
|
74
|
+
const tag = document.createElement('style')
|
|
75
|
+
tag.dataset.plugin = 'dsh-provider-show'
|
|
76
|
+
tag.dataset.pluginCss = tagId
|
|
77
|
+
tag.textContent = CSS
|
|
78
|
+
document.head.appendChild(tag)
|
|
79
|
+
return () => {
|
|
80
|
+
tag.remove()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Last document read from the Host, shared by every mounted composer so a
|
|
86
|
+
* session switch reuses one answer instead of re-asking.
|
|
87
|
+
*/
|
|
88
|
+
const cache = { value: undefined, at: 0, inflight: undefined }
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read the Host's provider document, at most once per cache window.
|
|
92
|
+
*
|
|
93
|
+
* Any failure returns `undefined`, which the component renders as "draw
|
|
94
|
+
* nothing": a missing label is a smaller fault than a wrong one, and this
|
|
95
|
+
* path only runs when the Host is unreachable anyway.
|
|
96
|
+
*
|
|
97
|
+
* @returns {Promise<{ providers: Array<object>, enabled: boolean } | undefined>} the document.
|
|
98
|
+
*/
|
|
99
|
+
async function readDocument() {
|
|
100
|
+
const now = Date.now()
|
|
101
|
+
if (cache.value !== undefined && now - cache.at < CACHE_MS) return cache.value
|
|
102
|
+
if (cache.inflight !== undefined) return cache.inflight
|
|
103
|
+
const operation = (async () => {
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetch(PROVIDERS_PATH, {
|
|
106
|
+
headers: { accept: 'application/json' },
|
|
107
|
+
credentials: 'same-origin',
|
|
108
|
+
})
|
|
109
|
+
if (!response.ok) return undefined
|
|
110
|
+
const body = await response.json()
|
|
111
|
+
if (body?.ok !== true) return undefined
|
|
112
|
+
const value = body.value
|
|
113
|
+
if (value === null || typeof value !== 'object' || !Array.isArray(value.providers)) return undefined
|
|
114
|
+
cache.value = value
|
|
115
|
+
cache.at = Date.now()
|
|
116
|
+
return value
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
})()
|
|
121
|
+
cache.inflight = operation
|
|
122
|
+
try {
|
|
123
|
+
return await operation
|
|
124
|
+
} finally {
|
|
125
|
+
if (cache.inflight === operation) cache.inflight = undefined
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* One provider's decided row, or undefined when the Host has no opinion.
|
|
131
|
+
*
|
|
132
|
+
* @param {{ providers: Array<object> } | undefined} document - Host document.
|
|
133
|
+
* @param {string} providerId - the selected provider route.
|
|
134
|
+
* @returns {{ label?: string, visible?: boolean } | undefined} the row.
|
|
135
|
+
*/
|
|
136
|
+
function rowFor(document, providerId) {
|
|
137
|
+
if (document === undefined || providerId === '') return undefined
|
|
138
|
+
for (const row of document.providers) {
|
|
139
|
+
if (row !== null && typeof row === 'object' && row.provider === providerId) return row
|
|
140
|
+
}
|
|
141
|
+
return undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The provider label beside the model seat.
|
|
146
|
+
*
|
|
147
|
+
* Reads the session's durable `modelSelection` projection — the same value
|
|
148
|
+
* the Host stamps on outgoing requests — so the label cannot disagree with
|
|
149
|
+
* the model seat about which provider is selected. It renders nothing while
|
|
150
|
+
* the document is in flight, because showing a label that is about to be
|
|
151
|
+
* suppressed (the provider belongs to another plugin) would flash.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} props - slot props; `useProjection` and `t` are supplied by the slot.
|
|
154
|
+
* @returns {import('react').ReactElement | null} the label, or null.
|
|
155
|
+
*/
|
|
156
|
+
function ProviderLabel(props) {
|
|
157
|
+
const { useProjection, sessionId, t } = props
|
|
158
|
+
const selection = useProjection('modelSelection')
|
|
159
|
+
const providerId = selection?.next?.provider ?? selection?.lastUsed?.provider ?? ''
|
|
160
|
+
const [document, setDocument] = useState(undefined)
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (providerId === '') {
|
|
164
|
+
setDocument(undefined)
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
let live = true
|
|
168
|
+
readDocument().then((next) => {
|
|
169
|
+
if (live) setDocument(next)
|
|
170
|
+
})
|
|
171
|
+
return () => {
|
|
172
|
+
live = false
|
|
173
|
+
}
|
|
174
|
+
}, [providerId, sessionId])
|
|
175
|
+
|
|
176
|
+
if (providerId === '') return null
|
|
177
|
+
const row = rowFor(document, providerId)
|
|
178
|
+
// `visible` is decided by the Host: it carries the plugin switch, the
|
|
179
|
+
// user's overrides, and the third-party check in one boolean.
|
|
180
|
+
if (row === undefined || row.visible !== true) return null
|
|
181
|
+
const label = typeof row.label === 'string' ? row.label.trim() : ''
|
|
182
|
+
if (label === '') return null
|
|
183
|
+
|
|
184
|
+
return h(
|
|
185
|
+
'span',
|
|
186
|
+
{
|
|
187
|
+
className: 'dpsp-root',
|
|
188
|
+
title: t('chip.aria', { provider: label }),
|
|
189
|
+
'aria-label': t('chip.aria', { provider: label }),
|
|
190
|
+
},
|
|
191
|
+
h('span', { className: 'dpsp-name' }, label),
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
// Only what the component actually needs. `remote` is deliberately absent:
|
|
197
|
+
// this half reads its own Host route, which works in every composition and
|
|
198
|
+
// keeps the ownership rule on the side that can evaluate it.
|
|
199
|
+
// Only what the component actually needs, spelled the way the cordis
|
|
200
|
+
// Loader expects (a plain array of REQUIRED service keys — the Loader has
|
|
201
|
+
// no "optional" inject form, and a key listed here parks the plugin until
|
|
202
|
+
// that service exists). `remote` is deliberately absent: this half reads
|
|
203
|
+
// its own Host route, which works in every composition and keeps the
|
|
204
|
+
// ownership rule on the side that can evaluate it.
|
|
205
|
+
inject: ['slots', 'locale'],
|
|
206
|
+
apply(ctx) {
|
|
207
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-provider-show: dictionaries')
|
|
208
|
+
ctx.effect(installStyles, 'dsh-provider-show: stylesheet')
|
|
209
|
+
ctx.slots.inject(SLOT, () => ctx.slots.register({
|
|
210
|
+
name: SLOT,
|
|
211
|
+
id: ENTRY_ID,
|
|
212
|
+
// `input.right` is a list slot rendered immediately before the model
|
|
213
|
+
// seat. Order only matters if another plugin also occupies this slot;
|
|
214
|
+
// a connector that draws its own supplier chip suppresses ours for its
|
|
215
|
+
// providers, so in practice the two never render together.
|
|
216
|
+
order: 6,
|
|
217
|
+
locale: NS,
|
|
218
|
+
}, ProviderLabel))
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of dsh-provider-show.
|
|
3
|
+
*
|
|
4
|
+
* The browser can see the model catalog, but it cannot see WHO registered a
|
|
5
|
+
* provider route: the catalog group carries only `{ id, name, models }`, and the
|
|
6
|
+
* registry strips adapter metadata before it reaches the page. That answer
|
|
7
|
+
* lives in the Host's LLM directory, so this half reads it and publishes one
|
|
8
|
+
* small, already-decided document:
|
|
9
|
+
*
|
|
10
|
+
* GET /plugins/dsh-provider-show/providers
|
|
11
|
+
* { ok: true, value: { providers: [{ provider, displayName, label, owner, reason }] } }
|
|
12
|
+
*
|
|
13
|
+
* Each row is decided HERE by the shared pure resolver, so the ownership rule
|
|
14
|
+
* has exactly one implementation and the client half stays a lookup. No Client
|
|
15
|
+
* package, no `remote.llm` dependency, and no second copy of the rule that could
|
|
16
|
+
* drift from this one.
|
|
17
|
+
*
|
|
18
|
+
* A Host-only bundle would need the route's consumer to exist; the Client half
|
|
19
|
+
* is what consumes it, so the two ship together in one bundle.
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-provider-show
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_CORE_NAMESPACES,
|
|
26
|
+
providerLabel,
|
|
27
|
+
resolveOwnership,
|
|
28
|
+
shouldRender,
|
|
29
|
+
} from './ownership.js'
|
|
30
|
+
|
|
31
|
+
/** Route this plugin owns, namespaced under `/plugins/` like other bundles. */
|
|
32
|
+
const PROVIDERS_PATH = '/plugins/dsh-provider-show/providers'
|
|
33
|
+
|
|
34
|
+
/** How long one directory snapshot is reused, in milliseconds. */
|
|
35
|
+
const CACHE_MS = 5000
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The plugin's configuration shape, described once as plain data.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately NOT a Schemastery schema. A bundle installed by linking this
|
|
41
|
+
* directory resolves its imports through the profile's `node_modules`, and the
|
|
42
|
+
* shipped Harness packages are NOT there for a bundle that declares no
|
|
43
|
+
* dependency on them — so importing a schema library from the Harness tree
|
|
44
|
+
* would make the whole Host half fail to load. The Loader treats a missing
|
|
45
|
+
* `Config` export as "no schema" and passes the row's config through
|
|
46
|
+
* (`resolveConfig` returns early when `runtime.Config` is absent), so the
|
|
47
|
+
* defaults are applied here instead, in {@link effectiveConfig}, where they cost
|
|
48
|
+
* no import.
|
|
49
|
+
*
|
|
50
|
+
* The shapes below are documented for the patch author and validated at use.
|
|
51
|
+
*/
|
|
52
|
+
export const CONFIG_KEYS = Object.freeze({
|
|
53
|
+
enabled: 'boolean — draw the label (default true)',
|
|
54
|
+
shortNames: 'Record<providerId, string> — a shorter label than the catalog name',
|
|
55
|
+
overrides: 'Record<providerId, "mine" | "third-party" | "hide"> — force one decision',
|
|
56
|
+
coreNamespaces: 'string[] — settings namespaces the Harness itself owns',
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/** The ownership decision values `overrides` accepts. */
|
|
60
|
+
const OVERRIDE_VALUES = Object.freeze(['mine', 'third-party', 'hide'])
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Coerce one unknown value to a string record.
|
|
64
|
+
*
|
|
65
|
+
* Configuration arrives from YAML, so a value can be a number, a list, or
|
|
66
|
+
* absent. Anything unusable is dropped rather than throwing inside a request.
|
|
67
|
+
*
|
|
68
|
+
* @param {unknown} value - raw configured value.
|
|
69
|
+
* @returns {Record<string, string>} only the string-valued entries.
|
|
70
|
+
*/
|
|
71
|
+
function stringRecord(value) {
|
|
72
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
73
|
+
const out = {}
|
|
74
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
75
|
+
if (typeof key !== 'string' || key === '') continue
|
|
76
|
+
if (typeof entry !== 'string') continue
|
|
77
|
+
out[key] = entry
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the row's configuration into the struct the rest of this file uses.
|
|
84
|
+
*
|
|
85
|
+
* Every field falls back to a defined behaviour, because `cordis.patch.yml`
|
|
86
|
+
* replaces the WHOLE `config` object when it names one key: a user overriding
|
|
87
|
+
* `shortNames` must not thereby disable the default core namespace list.
|
|
88
|
+
*
|
|
89
|
+
* @param {unknown} raw - the row's config as the Loader supplied it.
|
|
90
|
+
* @returns {{ enabled: boolean, shortNames: Record<string,string>, overrides: Record<string,string>, coreNamespaces: string[] }}
|
|
91
|
+
* effective configuration.
|
|
92
|
+
*/
|
|
93
|
+
export function effectiveConfig(raw) {
|
|
94
|
+
const base = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
|
|
95
|
+
const shortNames = stringRecord(base.shortNames)
|
|
96
|
+
const overrides = {}
|
|
97
|
+
for (const [key, value] of Object.entries(stringRecord(base.overrides))) {
|
|
98
|
+
// An unrecognised override would silently do nothing; dropping it keeps the
|
|
99
|
+
// resolver's single authority intact and the behaviour predictable.
|
|
100
|
+
if (OVERRIDE_VALUES.includes(value)) overrides[key] = value
|
|
101
|
+
}
|
|
102
|
+
const coreNamespaces = Array.isArray(base.coreNamespaces)
|
|
103
|
+
? base.coreNamespaces.filter((entry) => typeof entry === 'string' && entry.trim() !== '').map((entry) => entry.trim())
|
|
104
|
+
: []
|
|
105
|
+
return {
|
|
106
|
+
enabled: base.enabled !== false,
|
|
107
|
+
shortNames,
|
|
108
|
+
overrides,
|
|
109
|
+
coreNamespaces: coreNamespaces.length > 0 ? coreNamespaces : [...DEFAULT_CORE_NAMESPACES],
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read the live LLM registry into the raw rows the resolver consumes.
|
|
115
|
+
*
|
|
116
|
+
* Both calls are optional in a composition. A profile without an LLM service
|
|
117
|
+
* simply produces no rows, which the resolver reports as `unknown` — ours.
|
|
118
|
+
*
|
|
119
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
120
|
+
* @returns {{ rows: unknown[], providers: unknown[] }} directory rows and registered routes.
|
|
121
|
+
*/
|
|
122
|
+
function readDirectory(ctx) {
|
|
123
|
+
const llm = ctx.get('llm')
|
|
124
|
+
if (llm === undefined || llm === null) return { rows: [], providers: [] }
|
|
125
|
+
const rows = read(() => llm.listConfigurableProviders?.())
|
|
126
|
+
const providers = read(() => llm.listProviders?.())
|
|
127
|
+
return {
|
|
128
|
+
rows: Array.isArray(rows) ? rows : [],
|
|
129
|
+
providers: Array.isArray(providers) ? providers : [],
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Call one optional registry method, treating any failure as "not available".
|
|
135
|
+
*
|
|
136
|
+
* @param {() => unknown} read - the call.
|
|
137
|
+
* @returns {unknown} its result, or undefined.
|
|
138
|
+
*/
|
|
139
|
+
function read(call) {
|
|
140
|
+
try {
|
|
141
|
+
return call()
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build the document the client half reads.
|
|
149
|
+
*
|
|
150
|
+
* Registered routes decide WHICH providers exist; the configurable directory
|
|
151
|
+
* decides WHO registers each one. The two can legitimately differ — a route
|
|
152
|
+
* may be registered without a directory entry and vice versa — so they are
|
|
153
|
+
* unioned, and a route with no directory row resolves to `unknown`, which
|
|
154
|
+
* renders our label.
|
|
155
|
+
*
|
|
156
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
157
|
+
* @param {ReturnType<typeof effectiveConfig>} config - effective configuration.
|
|
158
|
+
* @returns {{ providers: Array<{provider: string, displayName: string, label: string, owner: string, reason: string}>, coreNamespaces: string[] }}
|
|
159
|
+
* one decided row per provider route.
|
|
160
|
+
*/
|
|
161
|
+
export function describeProviders(ctx, config) {
|
|
162
|
+
const { rows, providers } = readDirectory(ctx)
|
|
163
|
+
const coreNamespaces = config.coreNamespaces
|
|
164
|
+
|
|
165
|
+
const ids = []
|
|
166
|
+
const seen = new Set()
|
|
167
|
+
const claim = (id) => {
|
|
168
|
+
if (typeof id !== 'string' || id === '' || seen.has(id)) return
|
|
169
|
+
seen.add(id)
|
|
170
|
+
ids.push(id)
|
|
171
|
+
}
|
|
172
|
+
for (const provider of providers) claim(provider?.id)
|
|
173
|
+
for (const row of rows) claim(row?.provider)
|
|
174
|
+
|
|
175
|
+
const out = []
|
|
176
|
+
for (const providerId of ids) {
|
|
177
|
+
const decision = resolveOwnership({
|
|
178
|
+
providerId,
|
|
179
|
+
directoryRows: rows,
|
|
180
|
+
coreNamespaces,
|
|
181
|
+
overrides: config.overrides,
|
|
182
|
+
})
|
|
183
|
+
const label = providerLabel({
|
|
184
|
+
providerId,
|
|
185
|
+
displayName: decision.displayName,
|
|
186
|
+
shortNames: config.shortNames,
|
|
187
|
+
})
|
|
188
|
+
out.push({
|
|
189
|
+
provider: providerId,
|
|
190
|
+
displayName: decision.displayName ?? providerId,
|
|
191
|
+
label,
|
|
192
|
+
// The client draws the label only when `visible`. Both decisions — who
|
|
193
|
+
// owns the provider, and whether to draw anything — are made here so the
|
|
194
|
+
// page never has to re-derive them.
|
|
195
|
+
visible: label !== '' && shouldRender(decision, config),
|
|
196
|
+
owner: decision.owner,
|
|
197
|
+
reason: decision.reason,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
return { providers: out, coreNamespaces }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Send one JSON reply that no cache should reuse.
|
|
205
|
+
*
|
|
206
|
+
* @param {import('node:http').ServerResponse} res - response to write.
|
|
207
|
+
* @param {number} status - HTTP status code.
|
|
208
|
+
* @param {unknown} body - JSON-serialisable payload.
|
|
209
|
+
* @returns {void}
|
|
210
|
+
*/
|
|
211
|
+
function json(res, status, body) {
|
|
212
|
+
res.writeHead(status, {
|
|
213
|
+
'content-type': 'application/json; charset=utf-8',
|
|
214
|
+
'cache-control': 'no-store',
|
|
215
|
+
})
|
|
216
|
+
res.end(JSON.stringify(body))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Accept only requests issued by the page this Host itself serves.
|
|
221
|
+
*
|
|
222
|
+
* The document exposes provider topology, so the route is held to the loopback
|
|
223
|
+
* rule other plugin routes use: the Host header must name this listener, and a
|
|
224
|
+
* present Origin must be loopback too. A same-origin `fetch` from the served
|
|
225
|
+
* page satisfies both.
|
|
226
|
+
*
|
|
227
|
+
* @param {import('node:http').IncomingMessage} req - incoming request.
|
|
228
|
+
* @returns {boolean} whether the request may be served.
|
|
229
|
+
*/
|
|
230
|
+
function fromThisPage(req) {
|
|
231
|
+
const local = req.socket?.localAddress
|
|
232
|
+
const host = req.headers.host
|
|
233
|
+
if (typeof host === 'string' && local !== undefined) {
|
|
234
|
+
const address = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')
|
|
235
|
+
if (!(address === local || address === 'localhost' || address === '127.0.0.1' || address === '::1')) {
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const origin = req.headers.origin
|
|
240
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
241
|
+
try {
|
|
242
|
+
const { hostname } = new URL(origin)
|
|
243
|
+
if (!(hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === local)) {
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Host entry point: register the read-only provider directory route.
|
|
255
|
+
*
|
|
256
|
+
* The route is registered inside `ctx.effect`, so unloading the plugin removes
|
|
257
|
+
* it and drops the snapshot with it.
|
|
258
|
+
*
|
|
259
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
260
|
+
* @param {unknown} [rawConfig] - the row's configuration.
|
|
261
|
+
* @returns {void}
|
|
262
|
+
*/
|
|
263
|
+
export function apply(ctx, rawConfig) {
|
|
264
|
+
const config = effectiveConfig(rawConfig)
|
|
265
|
+
let snapshot
|
|
266
|
+
let snapshotAt = 0
|
|
267
|
+
|
|
268
|
+
// `ctx.inject` — not `ctx.get` — because the web server is an ordinary service
|
|
269
|
+
// in the composition: at plugin activation it may not exist yet, and a
|
|
270
|
+
// one-shot `get` would silently leave the route unregistered forever.
|
|
271
|
+
//
|
|
272
|
+
// `scope.effect` — not a returned value — because a plugin's return value is
|
|
273
|
+
// DISCARDED by the Loader; only an effect disposer is run on unload. Returning
|
|
274
|
+
// the route disposer would register the route and then leak it forever.
|
|
275
|
+
ctx.inject(['webServer'], (scope) => {
|
|
276
|
+
const webServer = scope.webServer
|
|
277
|
+
if (webServer === undefined || typeof webServer.register !== 'function') return
|
|
278
|
+
scope.effect(() => webServer.register({
|
|
279
|
+
kind: 'exact',
|
|
280
|
+
path: PROVIDERS_PATH,
|
|
281
|
+
handler: async (req, res) => {
|
|
282
|
+
if (req.method !== 'GET') {
|
|
283
|
+
json(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
if (!fromThisPage(req)) {
|
|
287
|
+
json(res, 403, { ok: false, error: 'not-this-page' })
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
const now = Date.now()
|
|
291
|
+
if (snapshot === undefined || now - snapshotAt >= CACHE_MS) {
|
|
292
|
+
try {
|
|
293
|
+
snapshot = describeProviders(ctx, config)
|
|
294
|
+
snapshotAt = now
|
|
295
|
+
} catch (error) {
|
|
296
|
+
// A failed read is not a failed route: the client treats an absent
|
|
297
|
+
// answer as "draw nothing", and the reason is reported rather than
|
|
298
|
+
// thrown into the request lifecycle.
|
|
299
|
+
json(res, 200, {
|
|
300
|
+
ok: false,
|
|
301
|
+
error: 'directory-unavailable',
|
|
302
|
+
message: error instanceof Error ? error.message : String(error),
|
|
303
|
+
})
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
json(res, 200, {
|
|
308
|
+
ok: true,
|
|
309
|
+
value: { ...snapshot, enabled: config.enabled },
|
|
310
|
+
})
|
|
311
|
+
},
|
|
312
|
+
}), 'dsh-provider-show: provider directory route')
|
|
313
|
+
})
|
|
314
|
+
}
|
package/lib/ownership.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership resolution for the composer's provider chip.
|
|
3
|
+
*
|
|
4
|
+
* The composer's model seat already renders "model name · reasoning effort"
|
|
5
|
+
* itself. What it cannot show is WHICH PROVIDER the selection belongs to,
|
|
6
|
+
* because the catalog name is the only provider-derived text it draws. A
|
|
7
|
+
* third-party plugin may already own a provider's whole presentation (the
|
|
8
|
+
* WorkBuddy connector draws its own supplier chip, credit readout, promotion
|
|
9
|
+
* badge and effort marker in the same row), and duplicating that is worse than
|
|
10
|
+
* staying silent.
|
|
11
|
+
*
|
|
12
|
+
* This module answers exactly one question, as a pure function of directory
|
|
13
|
+
* rows and configuration:
|
|
14
|
+
*
|
|
15
|
+
* Is this provider's presentation already owned elsewhere?
|
|
16
|
+
*
|
|
17
|
+
* It is deliberately free of DSH imports so it can be unit-tested with plain
|
|
18
|
+
* Node: the Host and Client halves both consume this same function.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-provider-show/ownership
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Provider routes the Harness itself registers, keyed by the settings namespace
|
|
25
|
+
* that configures each one.
|
|
26
|
+
*
|
|
27
|
+
* These come from the shipped `dsh-base` bundle patch, where a Loader row's id
|
|
28
|
+
* is also the settings namespace its provider directory entry names:
|
|
29
|
+
* `llm-pi-ai` (user-configured API-key routes), and the two official DeepSeek
|
|
30
|
+
* routes. A provider whose `settingsNs` is one of these is core; anything else
|
|
31
|
+
* means some plugin registered the route under its own namespace.
|
|
32
|
+
*
|
|
33
|
+
* Kept as data rather than a switch so a deployment can extend it through the
|
|
34
|
+
* plugin's Config when a future Harness release adds another built-in route.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_CORE_NAMESPACES = Object.freeze([
|
|
37
|
+
'llm-pi-ai',
|
|
38
|
+
'llm-deepseek',
|
|
39
|
+
'llm-deepseek-account',
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
/** The three answers {@link resolveOwnership} can give. */
|
|
43
|
+
export const OWNERSHIP = Object.freeze({
|
|
44
|
+
/** Core Harness route: no third-party plugin presents it, so show our chip. */
|
|
45
|
+
CORE: 'core',
|
|
46
|
+
/** Another plugin registered this route and presents it; we stay silent. */
|
|
47
|
+
THIRD_PARTY: 'third-party',
|
|
48
|
+
/** No directory row for this route: treat it as ours (fail-open). */
|
|
49
|
+
UNKNOWN: 'unknown',
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Normalise a configuration list of namespaces.
|
|
54
|
+
*
|
|
55
|
+
* Configuration arrives from YAML, so a missing field, `null`, a scalar, or a
|
|
56
|
+
* list with non-string members must all degrade to the default rather than
|
|
57
|
+
* throwing inside a render path. Duplicates and empty strings are dropped
|
|
58
|
+
* because a namespace is compared by equality.
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} value - raw configured value.
|
|
61
|
+
* @param {readonly string[]} fallback - list used when nothing usable was given.
|
|
62
|
+
* @returns {string[]} a usable namespace list.
|
|
63
|
+
*/
|
|
64
|
+
export function normalizeNamespaces(value, fallback = DEFAULT_CORE_NAMESPACES) {
|
|
65
|
+
if (!Array.isArray(value)) return [...fallback]
|
|
66
|
+
const seen = new Set()
|
|
67
|
+
const out = []
|
|
68
|
+
for (const entry of value) {
|
|
69
|
+
if (typeof entry !== 'string') continue
|
|
70
|
+
const name = entry.trim()
|
|
71
|
+
if (name === '' || seen.has(name)) continue
|
|
72
|
+
seen.add(name)
|
|
73
|
+
out.push(name)
|
|
74
|
+
}
|
|
75
|
+
return out.length === 0 ? [...fallback] : out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read one provider's directory row out of a `listConfigurableProviders()` reply.
|
|
80
|
+
*
|
|
81
|
+
* The remote reply is untrusted JSON (a Host generation may have changed shape),
|
|
82
|
+
* so every field is checked before use.
|
|
83
|
+
*
|
|
84
|
+
* @param {unknown} rows - raw reply.
|
|
85
|
+
* @param {string} providerId - provider route to find.
|
|
86
|
+
* @returns {{ provider: string, displayName?: string, settingsNs?: string, settingsPath?: readonly string[] } | undefined}
|
|
87
|
+
* the matching row, or undefined when the reply is unusable.
|
|
88
|
+
*/
|
|
89
|
+
export function findProviderRow(rows, providerId) {
|
|
90
|
+
if (!Array.isArray(rows) || typeof providerId !== 'string' || providerId === '') return undefined
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
if (row === null || typeof row !== 'object') continue
|
|
93
|
+
if (row.provider !== providerId) continue
|
|
94
|
+
return {
|
|
95
|
+
provider: providerId,
|
|
96
|
+
...(typeof row.displayName === 'string' && row.displayName !== ''
|
|
97
|
+
? { displayName: row.displayName }
|
|
98
|
+
: {}),
|
|
99
|
+
...(typeof row.settingsNs === 'string' && row.settingsNs !== ''
|
|
100
|
+
? { settingsNs: row.settingsNs }
|
|
101
|
+
: {}),
|
|
102
|
+
...(Array.isArray(row.settingsPath) ? { settingsPath: row.settingsPath } : {}),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Decide who presents one provider route.
|
|
110
|
+
*
|
|
111
|
+
* Order of authority, strongest first:
|
|
112
|
+
*
|
|
113
|
+
* 1. The user's explicit override. A person who has looked at the composer and
|
|
114
|
+
* said "this one is somebody else's" outranks every inference below, because
|
|
115
|
+
* it is the only signal that cannot be wrong about intent.
|
|
116
|
+
* 2. The directory row's `settingsNs`. A namespace outside the core list means
|
|
117
|
+
* a plugin registered the route under its own Loader entry id.
|
|
118
|
+
* 3. Fail-open. A route with no row — or a directory that could not be read —
|
|
119
|
+
* is treated as ours. The asymmetry is deliberate: showing a provider that
|
|
120
|
+
* was in fact owned elsewhere costs a duplicate label, while hiding one that
|
|
121
|
+
* was not owned costs the entire feature, silently, for every provider.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} input - resolution input.
|
|
124
|
+
* @param {string} input.providerId - selected provider route.
|
|
125
|
+
* @param {unknown} [input.directoryRows] - `listConfigurableProviders()` reply.
|
|
126
|
+
* @param {readonly string[]} [input.coreNamespaces] - namespaces the Harness owns.
|
|
127
|
+
* @param {Record<string, string>} [input.overrides] - provider id → decision.
|
|
128
|
+
* @returns {{ owner: string, reason: string, settingsNs?: string, displayName?: string }}
|
|
129
|
+
* the decision plus the evidence that produced it.
|
|
130
|
+
*/
|
|
131
|
+
export function resolveOwnership(input) {
|
|
132
|
+
const providerId = input?.providerId
|
|
133
|
+
if (typeof providerId !== 'string' || providerId === '') {
|
|
134
|
+
return { owner: OWNERSHIP.UNKNOWN, reason: 'no-provider' }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const overrides = input.overrides
|
|
138
|
+
const override = overrides !== null && typeof overrides === 'object' && !Array.isArray(overrides)
|
|
139
|
+
? overrides[providerId]
|
|
140
|
+
: undefined
|
|
141
|
+
if (override === 'third-party') return { owner: OWNERSHIP.THIRD_PARTY, reason: 'override' }
|
|
142
|
+
if (override === 'mine') return { owner: OWNERSHIP.CORE, reason: 'override' }
|
|
143
|
+
// `hide` is not an ownership claim: it says "draw nothing" without asserting
|
|
144
|
+
// that somebody else owns the provider. It rides along on the decision so
|
|
145
|
+
// there is still exactly ONE reader of the overrides table.
|
|
146
|
+
if (override === 'hide') return { owner: OWNERSHIP.CORE, reason: 'override-hidden', hidden: true }
|
|
147
|
+
|
|
148
|
+
const row = findProviderRow(input.directoryRows, providerId)
|
|
149
|
+
if (row === undefined) return { owner: OWNERSHIP.UNKNOWN, reason: 'no-directory-row' }
|
|
150
|
+
|
|
151
|
+
const core = normalizeNamespaces(input.coreNamespaces)
|
|
152
|
+
const settingsNs = row.settingsNs
|
|
153
|
+
const carries = {
|
|
154
|
+
...(settingsNs === undefined ? {} : { settingsNs }),
|
|
155
|
+
...(row.displayName === undefined ? {} : { displayName: row.displayName }),
|
|
156
|
+
}
|
|
157
|
+
if (settingsNs === undefined) {
|
|
158
|
+
return { owner: OWNERSHIP.UNKNOWN, reason: 'no-settings-namespace', ...carries }
|
|
159
|
+
}
|
|
160
|
+
if (core.includes(settingsNs)) {
|
|
161
|
+
return { owner: OWNERSHIP.CORE, reason: 'core-namespace', ...carries }
|
|
162
|
+
}
|
|
163
|
+
return { owner: OWNERSHIP.THIRD_PARTY, reason: 'plugin-namespace', ...carries }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Whether our chip should render at all for one selection.
|
|
168
|
+
*
|
|
169
|
+
* Three things suppress it, and only three: the plugin switch, an explicit
|
|
170
|
+
* `hide` override, and a confirmed third-party owner. `UNKNOWN` renders, for the
|
|
171
|
+
* reason given on {@link resolveOwnership}: a duplicate label is a cosmetic
|
|
172
|
+
* fault, a missing feature is the whole request.
|
|
173
|
+
*
|
|
174
|
+
* This function deliberately does NOT re-read `overrides`. Every decision that
|
|
175
|
+
* needs that table is made once, inside {@link resolveOwnership}, and arrives
|
|
176
|
+
* here on the decision object — two readers of one table could disagree after an
|
|
177
|
+
* edit, and the disagreement would be invisible until a user saw it.
|
|
178
|
+
*
|
|
179
|
+
* @param {{ owner: string, hidden?: boolean }} decision - result of {@link resolveOwnership}.
|
|
180
|
+
* @param {{ enabled?: boolean }} [config] - plugin configuration.
|
|
181
|
+
* @returns {boolean} whether to draw the chip.
|
|
182
|
+
*/
|
|
183
|
+
export function shouldRender(decision, config = {}) {
|
|
184
|
+
if (config?.enabled === false) return false
|
|
185
|
+
if (decision?.hidden === true) return false
|
|
186
|
+
return decision?.owner !== OWNERSHIP.THIRD_PARTY
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The text our chip shows for one provider.
|
|
191
|
+
*
|
|
192
|
+
* Model name and reasoning effort are already drawn by the composer's own model
|
|
193
|
+
* seat, so repeating them here would print each fact twice in one row. The chip
|
|
194
|
+
* therefore carries the one missing fact: the provider.
|
|
195
|
+
*
|
|
196
|
+
* @param {object} input - label input.
|
|
197
|
+
* @param {string} input.providerId - selected provider route.
|
|
198
|
+
* @param {string} [input.displayName] - provider name from the directory row.
|
|
199
|
+
* @param {Record<string, string>} [input.shortNames] - configured short names.
|
|
200
|
+
* @returns {string} the label, or an empty string when nothing usable exists.
|
|
201
|
+
*/
|
|
202
|
+
export function providerLabel(input) {
|
|
203
|
+
const providerId = input?.providerId
|
|
204
|
+
const shortNames = input?.shortNames
|
|
205
|
+
if (shortNames !== null && typeof shortNames === 'object' && !Array.isArray(shortNames)) {
|
|
206
|
+
const short = typeof providerId === 'string' ? shortNames[providerId] : undefined
|
|
207
|
+
if (typeof short === 'string' && short.trim() !== '') return short.trim()
|
|
208
|
+
}
|
|
209
|
+
const displayName = input?.displayName
|
|
210
|
+
if (typeof displayName === 'string' && displayName.trim() !== '') return displayName.trim()
|
|
211
|
+
// A provider id is the last resort: it is always present and always accurate,
|
|
212
|
+
// and showing `volcano-cn` beats showing an empty chip. Callers that would
|
|
213
|
+
// rather show nothing pass fallbackToId: false.
|
|
214
|
+
if (input?.fallbackToId === false) return ''
|
|
215
|
+
return typeof providerId === 'string' ? providerId : ''
|
|
216
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Provider Label",
|
|
4
|
+
"description": "Shows which provider the selected model belongs to in the composer row, and stays silent for a provider another plugin already presents."
|
|
5
|
+
},
|
|
6
|
+
"chip": {
|
|
7
|
+
"aria": "Current provider: {provider}"
|
|
8
|
+
}
|
|
9
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-provider-show",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shows which provider the selected model belongs to in the composer row, and stays silent for providers another plugin already presents.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./locale/*.json": "./locale/*.json",
|
|
12
|
+
"./ownership": "./lib/ownership.js",
|
|
13
|
+
"./icon": "./icon.svg",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"icon": "./icon.svg",
|
|
17
|
+
"files": [
|
|
18
|
+
"lib",
|
|
19
|
+
"locale",
|
|
20
|
+
"icon.svg",
|
|
21
|
+
"cordis.patch.yml",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"dsh": {
|
|
26
|
+
"bundle": {
|
|
27
|
+
"patch": "./cordis.patch.yml"
|
|
28
|
+
},
|
|
29
|
+
"client": {
|
|
30
|
+
"platform": "web",
|
|
31
|
+
"inject": [
|
|
32
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
33
|
+
"@deepseek-ai/dsh-client-locale"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node --test",
|
|
39
|
+
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/ownership.js && node scripts/verify.mjs && node --test"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"dsh-plugin",
|
|
43
|
+
"deepseek-harness",
|
|
44
|
+
"provider",
|
|
45
|
+
"composer"
|
|
46
|
+
],
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
50
|
+
},
|
|
51
|
+
"author": {
|
|
52
|
+
"name": "MosCloud"
|
|
53
|
+
},
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/MosCloud/dsh-provider-show.git"
|
|
57
|
+
},
|
|
58
|
+
"homepage": "https://github.com/MosCloud/dsh-provider-show#readme",
|
|
59
|
+
"bugs": {
|
|
60
|
+
"url": "https://github.com/MosCloud/dsh-provider-show/issues"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public",
|
|
64
|
+
"registry": "https://registry.npmjs.org/"
|
|
65
|
+
}
|
|
66
|
+
}
|