dsh-provider-show 0.0.0-stage → 0.1.1
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 +125 -2
- package/cordis.patch.yml +24 -0
- package/icon.svg +12 -0
- package/lib/client.js +226 -0
- package/lib/index.js +357 -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,126 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-provider-show
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/MosCloud/dsh-provider-show/actions/workflows/check.yml)
|
|
4
|
+
|
|
5
|
+
在 DSH 输入框右下角的「模型 · 推理强度」前面,补上**当前模型所属的供应商**。
|
|
6
|
+
已被其他插件接管的供应商(例如 WorkBuddy)保持原样,不重复显示。
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
未接管(火山方舟) 火山方舟 DeepSeek V4.1 Flash · Default ← 新增「火山方舟」
|
|
10
|
+
已接管(WorkBuddy) DeepSeek-V4.1-Flash · x0.11 … ← 本插件不出现
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
dsh plugin --profile <你的-profile> add dsh-provider-show
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
也可以在插件管理页的「添加插件」里填包名或 GitHub 仓库地址。安装后**刷新页面**
|
|
20
|
+
(客户端模块是新增的,必须刷新才会加载)。
|
|
21
|
+
|
|
22
|
+
从源码开发时改用本地目录,改动即时生效:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
dsh plugin --profile <你的-profile> add /path/to/dsh-provider-show
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
卸载通过插件管理页,或从 profile 的插件清单移除。
|
|
29
|
+
|
|
30
|
+
> npm 12 会留下一个 `0.0.0-stage` 占位版本(339 字节),是其暂存发布机制的产物;
|
|
31
|
+
> `latest` 指向真正的发布版本,安装不受影响。
|
|
32
|
+
|
|
33
|
+
## 配置
|
|
34
|
+
|
|
35
|
+
写在 profile 的 `cordis.patch.yml` 的插件条目下。**每项都有默认值,但一处配置会替换整个
|
|
36
|
+
`config` 对象,所以只写需要改的键,其余自动取默认。**
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
- id: dsh-provider-show
|
|
40
|
+
name: dsh-provider-show
|
|
41
|
+
config:
|
|
42
|
+
enabled: true # 总开关,默认 true
|
|
43
|
+
|
|
44
|
+
shortNames: # 供应商短名,覆盖目录里的显示名
|
|
45
|
+
volcano-api: 火山
|
|
46
|
+
|
|
47
|
+
overrides: # 强制某个供应商的处置
|
|
48
|
+
some-connector-provider: third-party # 让位给接管它的插件
|
|
49
|
+
another-provider: mine # 即使探测说被接管也显示
|
|
50
|
+
noisy-provider: hide # 不显示,但不声称被别人接管
|
|
51
|
+
|
|
52
|
+
coreNamespaces: # 宿主自有的 settings 命名空间,宿主升级可扩展
|
|
53
|
+
- llm-pi-ai
|
|
54
|
+
- llm-deepseek
|
|
55
|
+
- llm-deepseek-account
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 判定规则
|
|
59
|
+
|
|
60
|
+
「是否已被第三方插件接管」由 Host 半读取 LLM 目录后判定,优先级自上而下:
|
|
61
|
+
|
|
62
|
+
| 顺序 | 规则 | 结果 |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| 1 | `overrides[providerId]` 显式配置 | `third-party` 让位 / `mine` 显示 / `hide` 不显示 |
|
|
65
|
+
| 2 | `settingsNs` 不在 `coreNamespaces` 内 | 判定为**已被插件接管**,让位 |
|
|
66
|
+
| 3 | 目录里查不到,或目录不可读 | 判定为**未知**,**照常显示**(fail-open) |
|
|
67
|
+
|
|
68
|
+
`settingsNs` 是「注册该 provider 的 Loader entry id」:
|
|
69
|
+
|
|
70
|
+
- `llm-pi-ai` —— 你在设置里自建的 API 路由(火山、OpenRouter、任何 OpenAI 兼容服务)
|
|
71
|
+
- `llm-deepseek` / `llm-deepseek-account` —— 官方 DeepSeek 两条路由
|
|
72
|
+
- 其他任何值 —— 某个插件用**自己的** entry id 注册的,例如 `dsh-connect-workbuddy`
|
|
73
|
+
|
|
74
|
+
第 3 条选 fail-open 是因为两种错误代价不对等:漏显只是信息不全,而误判会让**所有**供应商都不显示。
|
|
75
|
+
|
|
76
|
+
**新会话显示什么**:会话还没有任何模型选择时(没切过模型、也没发过消息),`modelSelection`
|
|
77
|
+
投影是空的 —— 此时显示的是**部署默认模型所属的供应商**。这个默认值由 Host 从
|
|
78
|
+
`agentDefaultModel` 服务读出,与模型座位同源,所以芯片和座位不会各说各话。
|
|
79
|
+
发出第一条消息或切一次模型之后,改由该会话自己的选择决定。
|
|
80
|
+
默认通道若被第三方插件接管,仍然让位。
|
|
81
|
+
|
|
82
|
+
## 架构
|
|
83
|
+
|
|
84
|
+
| 文件 | 职责 |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `lib/ownership.js` | 归属判定与文案选择,纯函数、零 import,Host/Client 共用 |
|
|
87
|
+
| `lib/index.js` | Host 半:读 LLM 目录 → 决策 → 只读路由 `GET /plugins/dsh-provider-show/providers` |
|
|
88
|
+
| `lib/client.js` | Client 半:注册 `conversation.input.right`,读 Host 文档并渲染 |
|
|
89
|
+
|
|
90
|
+
分层原则:**浏览器看不到的运行时事实(`settingsNs`)在 Host 决策,客户端只消费结论**,
|
|
91
|
+
规则只有一份实现。设计取舍见 [docs/design.md](docs/design.md)。
|
|
92
|
+
|
|
93
|
+
## 开发
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npm test # 单元 + 行为测试(纯 Node,不需要 DSH 运行时)
|
|
97
|
+
npm run check # 语法检查 + 静态校验 + 全部测试
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`scripts/verify.mjs` 的每项检查都对应一条「否则只会在运行时才暴露」的规则:manifest 完整性、
|
|
101
|
+
图标合法性、locale 非占位文案、客户端不 import Harness Client 包、样式只用主题 token 且有宽度上界、
|
|
102
|
+
路由是只读 GET 且由 `scope.effect` 持有、归属规则只有一份实现、排版度量与宿主一致且禁止位移补偿。
|
|
103
|
+
|
|
104
|
+
零外部依赖,所以 CI 没有安装步骤——干净 checkout 直接跑同一套检查(Node 22 / 24)。
|
|
105
|
+
|
|
106
|
+
## 已知限制
|
|
107
|
+
|
|
108
|
+
- 芯片会让模型名更早折叠为图标(`max-width: 140px`,宿主按整行宽度判定折叠)。
|
|
109
|
+
- `conversation.input.activity` 展开整行时,宿主会隐藏整组 `standardControls`,芯片随之隐藏。
|
|
110
|
+
- 供应商显示名默认取目录里的 `displayName`;想更短就配 `shortNames`。
|
|
111
|
+
- 判定依赖第三方插件用自有 `settingsNs` 注册 provider;若某插件复用核心命名空间,
|
|
112
|
+
请用 `overrides` 显式标注。
|
|
113
|
+
- 只读路由仅接受本页同源请求;它是 `/plugins/` 下的 exact 路由,优先级高于客户端模块的
|
|
114
|
+
`/plugins` 前缀路由,两者不冲突。
|
|
115
|
+
|
|
116
|
+
## 文档
|
|
117
|
+
|
|
118
|
+
| 文件 | 内容 |
|
|
119
|
+
|---|---|
|
|
120
|
+
| [docs/lessons.md](docs/lessons.md) | 开发经验与踩坑记录——插槽生命周期、验证方法论、发布流程。做同类插件前先读 |
|
|
121
|
+
| [docs/design.md](docs/design.md) | 架构决策(ADR)、边界约束、验收标准、渲染与排版细节、返工留档 |
|
|
122
|
+
| [docs/research.md](docs/research.md) | 技术考据:槽位拓扑、归属判定信号、排版实测数据,均带源码文件与行号 |
|
|
123
|
+
|
|
124
|
+
## 许可
|
|
125
|
+
|
|
126
|
+
[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,226 @@
|
|
|
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
|
+
* Which provider is in effect is decided by a three-leg chain, strongest
|
|
148
|
+
* first: a pending selection, the last logged request header, then the
|
|
149
|
+
* deployment default. The `modelSelection` projection carries the first two
|
|
150
|
+
* (`next` is `pending ?? lastUsed`); the third exists only on the Host,
|
|
151
|
+
* because it is read from the `agentDefaultModel` service — the same one the
|
|
152
|
+
* model seat falls back to. That is why the document ships a `default`:
|
|
153
|
+
* without it a brand-new Session, which has no selection and no request yet,
|
|
154
|
+
* would show no provider at all while the seat already shows its model.
|
|
155
|
+
*
|
|
156
|
+
* Reading the seat's own authority is what keeps the two in agreement; this
|
|
157
|
+
* half still only looks a row up, and still draws nothing while the document
|
|
158
|
+
* is in flight, so a label that is about to be suppressed cannot flash.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} props - slot props; `useProjection` and `t` are supplied by the slot.
|
|
161
|
+
* @returns {import('react').ReactElement | null} the label, or null.
|
|
162
|
+
*/
|
|
163
|
+
function ProviderLabel(props) {
|
|
164
|
+
const { useProjection, sessionId, t } = props
|
|
165
|
+
const selection = useProjection('modelSelection')
|
|
166
|
+
const [document, setDocument] = useState(undefined)
|
|
167
|
+
|
|
168
|
+
// Fetch unconditionally, including when nothing is selected: on a fresh
|
|
169
|
+
// Session the document's `default` is the ONLY source for this label.
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
let live = true
|
|
172
|
+
readDocument().then((next) => {
|
|
173
|
+
if (live) setDocument(next)
|
|
174
|
+
})
|
|
175
|
+
return () => {
|
|
176
|
+
live = false
|
|
177
|
+
}
|
|
178
|
+
}, [sessionId])
|
|
179
|
+
|
|
180
|
+
const providerId = selection?.next?.provider ?? document?.default?.provider ?? ''
|
|
181
|
+
if (providerId === '') return null
|
|
182
|
+
const row = rowFor(document, providerId)
|
|
183
|
+
// `visible` is decided by the Host: it carries the plugin switch, the
|
|
184
|
+
// user's overrides, and the third-party check in one boolean.
|
|
185
|
+
if (row === undefined || row.visible !== true) return null
|
|
186
|
+
const label = typeof row.label === 'string' ? row.label.trim() : ''
|
|
187
|
+
if (label === '') return null
|
|
188
|
+
|
|
189
|
+
return h(
|
|
190
|
+
'span',
|
|
191
|
+
{
|
|
192
|
+
className: 'dpsp-root',
|
|
193
|
+
title: t('chip.aria', { provider: label }),
|
|
194
|
+
'aria-label': t('chip.aria', { provider: label }),
|
|
195
|
+
},
|
|
196
|
+
h('span', { className: 'dpsp-name' }, label),
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
// Only what the component actually needs, spelled the way the cordis
|
|
202
|
+
// Loader expects (a plain array of REQUIRED service keys — the Loader has
|
|
203
|
+
// no "optional" inject form, and a key listed here parks the plugin until
|
|
204
|
+
// that service exists). `remote` is deliberately absent: this half reads
|
|
205
|
+
// its own Host route, which works in every composition and keeps the
|
|
206
|
+
// ownership rule on the side that can evaluate it. The deployment default
|
|
207
|
+
// arrives inside that document for the same reason: the browser cannot see
|
|
208
|
+
// the Host's `agentDefaultModel`.
|
|
209
|
+
inject: ['slots', 'locale'],
|
|
210
|
+
apply(ctx) {
|
|
211
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-provider-show: dictionaries')
|
|
212
|
+
ctx.effect(installStyles, 'dsh-provider-show: stylesheet')
|
|
213
|
+
ctx.slots.inject(SLOT, () => ctx.slots.register({
|
|
214
|
+
name: SLOT,
|
|
215
|
+
id: ENTRY_ID,
|
|
216
|
+
// `input.right` is a list slot rendered immediately before the model
|
|
217
|
+
// seat. Order only matters if another plugin also occupies this slot;
|
|
218
|
+
// a connector that draws its own supplier chip suppresses ours for its
|
|
219
|
+
// providers, so in practice the two never render together.
|
|
220
|
+
order: 6,
|
|
221
|
+
locale: NS,
|
|
222
|
+
}, ProviderLabel))
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
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: {
|
|
12
|
+
* providers: [{ provider, displayName, label, owner, reason }],
|
|
13
|
+
* default: { provider, model } | null,
|
|
14
|
+
* } }
|
|
15
|
+
*
|
|
16
|
+
* Each row is decided HERE by the shared pure resolver, so the ownership rule
|
|
17
|
+
* has exactly one implementation and the client half stays a lookup. No Client
|
|
18
|
+
* package, no `remote.llm` dependency, and no second copy of the rule that could
|
|
19
|
+
* drift from this one.
|
|
20
|
+
*
|
|
21
|
+
* `default` is the deployment's own default selection, read from the same
|
|
22
|
+
* `agentDefaultModel` service the composer's model seat falls back to. It is
|
|
23
|
+
* published because a brand-new Session has no model selection yet — the
|
|
24
|
+
* `modelSelection` projection stays `{ lastUsed: null, next: null }` until the
|
|
25
|
+
* first pick or the first request — and the browser has no other way to learn
|
|
26
|
+
* which provider is in effect before then. Handing over the VALUE instead of the
|
|
27
|
+
* service keeps the rule on this side: the client still only looks a row up.
|
|
28
|
+
*
|
|
29
|
+
* A Host-only bundle would need the route's consumer to exist; the Client half
|
|
30
|
+
* is what consumes it, so the two ship together in one bundle.
|
|
31
|
+
*
|
|
32
|
+
* @module dsh-provider-show
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_CORE_NAMESPACES,
|
|
37
|
+
providerLabel,
|
|
38
|
+
resolveOwnership,
|
|
39
|
+
shouldRender,
|
|
40
|
+
} from './ownership.js'
|
|
41
|
+
|
|
42
|
+
/** Route this plugin owns, namespaced under `/plugins/` like other bundles. */
|
|
43
|
+
const PROVIDERS_PATH = '/plugins/dsh-provider-show/providers'
|
|
44
|
+
|
|
45
|
+
/** How long one directory snapshot is reused, in milliseconds. */
|
|
46
|
+
const CACHE_MS = 5000
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The plugin's configuration shape, described once as plain data.
|
|
50
|
+
*
|
|
51
|
+
* Deliberately NOT a Schemastery schema. A bundle installed by linking this
|
|
52
|
+
* directory resolves its imports through the profile's `node_modules`, and the
|
|
53
|
+
* shipped Harness packages are NOT there for a bundle that declares no
|
|
54
|
+
* dependency on them — so importing a schema library from the Harness tree
|
|
55
|
+
* would make the whole Host half fail to load. The Loader treats a missing
|
|
56
|
+
* `Config` export as "no schema" and passes the row's config through
|
|
57
|
+
* (`resolveConfig` returns early when `runtime.Config` is absent), so the
|
|
58
|
+
* defaults are applied here instead, in {@link effectiveConfig}, where they cost
|
|
59
|
+
* no import.
|
|
60
|
+
*
|
|
61
|
+
* The shapes below are documented for the patch author and validated at use.
|
|
62
|
+
*/
|
|
63
|
+
export const CONFIG_KEYS = Object.freeze({
|
|
64
|
+
enabled: 'boolean — draw the label (default true)',
|
|
65
|
+
shortNames: 'Record<providerId, string> — a shorter label than the catalog name',
|
|
66
|
+
overrides: 'Record<providerId, "mine" | "third-party" | "hide"> — force one decision',
|
|
67
|
+
coreNamespaces: 'string[] — settings namespaces the Harness itself owns',
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
/** The ownership decision values `overrides` accepts. */
|
|
71
|
+
const OVERRIDE_VALUES = Object.freeze(['mine', 'third-party', 'hide'])
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Coerce one unknown value to a string record.
|
|
75
|
+
*
|
|
76
|
+
* Configuration arrives from YAML, so a value can be a number, a list, or
|
|
77
|
+
* absent. Anything unusable is dropped rather than throwing inside a request.
|
|
78
|
+
*
|
|
79
|
+
* @param {unknown} value - raw configured value.
|
|
80
|
+
* @returns {Record<string, string>} only the string-valued entries.
|
|
81
|
+
*/
|
|
82
|
+
function stringRecord(value) {
|
|
83
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
84
|
+
const out = {}
|
|
85
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
86
|
+
if (typeof key !== 'string' || key === '') continue
|
|
87
|
+
if (typeof entry !== 'string') continue
|
|
88
|
+
out[key] = entry
|
|
89
|
+
}
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the row's configuration into the struct the rest of this file uses.
|
|
95
|
+
*
|
|
96
|
+
* Every field falls back to a defined behaviour, because `cordis.patch.yml`
|
|
97
|
+
* replaces the WHOLE `config` object when it names one key: a user overriding
|
|
98
|
+
* `shortNames` must not thereby disable the default core namespace list.
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} raw - the row's config as the Loader supplied it.
|
|
101
|
+
* @returns {{ enabled: boolean, shortNames: Record<string,string>, overrides: Record<string,string>, coreNamespaces: string[] }}
|
|
102
|
+
* effective configuration.
|
|
103
|
+
*/
|
|
104
|
+
export function effectiveConfig(raw) {
|
|
105
|
+
const base = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
|
|
106
|
+
const shortNames = stringRecord(base.shortNames)
|
|
107
|
+
const overrides = {}
|
|
108
|
+
for (const [key, value] of Object.entries(stringRecord(base.overrides))) {
|
|
109
|
+
// An unrecognised override would silently do nothing; dropping it keeps the
|
|
110
|
+
// resolver's single authority intact and the behaviour predictable.
|
|
111
|
+
if (OVERRIDE_VALUES.includes(value)) overrides[key] = value
|
|
112
|
+
}
|
|
113
|
+
const coreNamespaces = Array.isArray(base.coreNamespaces)
|
|
114
|
+
? base.coreNamespaces.filter((entry) => typeof entry === 'string' && entry.trim() !== '').map((entry) => entry.trim())
|
|
115
|
+
: []
|
|
116
|
+
return {
|
|
117
|
+
enabled: base.enabled !== false,
|
|
118
|
+
shortNames,
|
|
119
|
+
overrides,
|
|
120
|
+
coreNamespaces: coreNamespaces.length > 0 ? coreNamespaces : [...DEFAULT_CORE_NAMESPACES],
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Read the live LLM registry into the raw rows the resolver consumes.
|
|
126
|
+
*
|
|
127
|
+
* Both calls are optional in a composition. A profile without an LLM service
|
|
128
|
+
* simply produces no rows, which the resolver reports as `unknown` — ours.
|
|
129
|
+
*
|
|
130
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
131
|
+
* @returns {{ rows: unknown[], providers: unknown[] }} directory rows and registered routes.
|
|
132
|
+
*/
|
|
133
|
+
function readDirectory(ctx) {
|
|
134
|
+
const llm = ctx.get('llm')
|
|
135
|
+
if (llm === undefined || llm === null) return { rows: [], providers: [] }
|
|
136
|
+
const rows = read(() => llm.listConfigurableProviders?.())
|
|
137
|
+
const providers = read(() => llm.listProviders?.())
|
|
138
|
+
return {
|
|
139
|
+
rows: Array.isArray(rows) ? rows : [],
|
|
140
|
+
providers: Array.isArray(providers) ? providers : [],
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Call one optional registry method, treating any failure as "not available".
|
|
146
|
+
*
|
|
147
|
+
* @param {() => unknown} read - the call.
|
|
148
|
+
* @returns {unknown} its result, or undefined.
|
|
149
|
+
*/
|
|
150
|
+
function read(call) {
|
|
151
|
+
try {
|
|
152
|
+
return call()
|
|
153
|
+
} catch {
|
|
154
|
+
return undefined
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Read the deployment's default model selection.
|
|
160
|
+
*
|
|
161
|
+
* This is the third and last leg of the chain that decides which provider is in
|
|
162
|
+
* effect — `pending selection → last logged request header → deployment default`
|
|
163
|
+
* — and the only leg the browser cannot see. The model seat resolves it through
|
|
164
|
+
* the same service (`buildModelCatalog` defaults to
|
|
165
|
+
* `ctx.agentDefaultModel.currentSelection()`), so publishing this value is what
|
|
166
|
+
* makes the chip agree with the seat instead of guessing at it.
|
|
167
|
+
*
|
|
168
|
+
* The service is optional: a composition without `agent-default-model` has no
|
|
169
|
+
* default to publish, and a failing read is not a failing route. Both degrade to
|
|
170
|
+
* `null`, which the client renders as "draw nothing" — the failure mode this
|
|
171
|
+
* plugin has always chosen over showing a wrong label.
|
|
172
|
+
*
|
|
173
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
174
|
+
* @returns {{ provider: string, model: string } | null} the default selection.
|
|
175
|
+
*/
|
|
176
|
+
function readDefaultSelection(ctx) {
|
|
177
|
+
try {
|
|
178
|
+
const service = ctx.get('agentDefaultModel')
|
|
179
|
+
const picked = service?.currentSelection?.()
|
|
180
|
+
if (picked === null || typeof picked !== 'object') return null
|
|
181
|
+
const { provider, model } = picked
|
|
182
|
+
if (typeof provider !== 'string' || provider === '') return null
|
|
183
|
+
if (typeof model !== 'string' || model === '') return null
|
|
184
|
+
return { provider, model }
|
|
185
|
+
} catch {
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build the document the client half reads.
|
|
192
|
+
*
|
|
193
|
+
* Registered routes decide WHICH providers exist; the configurable directory
|
|
194
|
+
* decides WHO registers each one. The two can legitimately differ — a route
|
|
195
|
+
* may be registered without a directory entry and vice versa — so they are
|
|
196
|
+
* unioned, and a route with no directory row resolves to `unknown`, which
|
|
197
|
+
* renders our label.
|
|
198
|
+
*
|
|
199
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
200
|
+
* @param {ReturnType<typeof effectiveConfig>} config - effective configuration.
|
|
201
|
+
* @returns {{ providers: Array<{provider: string, displayName: string, label: string, owner: string, reason: string}>, coreNamespaces: string[], default: { provider: string, model: string } | null }}
|
|
202
|
+
* one decided row per provider route, plus the deployment default.
|
|
203
|
+
*/
|
|
204
|
+
export function describeProviders(ctx, config) {
|
|
205
|
+
const { rows, providers } = readDirectory(ctx)
|
|
206
|
+
const coreNamespaces = config.coreNamespaces
|
|
207
|
+
|
|
208
|
+
const ids = []
|
|
209
|
+
const seen = new Set()
|
|
210
|
+
const claim = (id) => {
|
|
211
|
+
if (typeof id !== 'string' || id === '' || seen.has(id)) return
|
|
212
|
+
seen.add(id)
|
|
213
|
+
ids.push(id)
|
|
214
|
+
}
|
|
215
|
+
for (const provider of providers) claim(provider?.id)
|
|
216
|
+
for (const row of rows) claim(row?.provider)
|
|
217
|
+
|
|
218
|
+
const out = []
|
|
219
|
+
for (const providerId of ids) {
|
|
220
|
+
const decision = resolveOwnership({
|
|
221
|
+
providerId,
|
|
222
|
+
directoryRows: rows,
|
|
223
|
+
coreNamespaces,
|
|
224
|
+
overrides: config.overrides,
|
|
225
|
+
})
|
|
226
|
+
const label = providerLabel({
|
|
227
|
+
providerId,
|
|
228
|
+
displayName: decision.displayName,
|
|
229
|
+
shortNames: config.shortNames,
|
|
230
|
+
})
|
|
231
|
+
out.push({
|
|
232
|
+
provider: providerId,
|
|
233
|
+
displayName: decision.displayName ?? providerId,
|
|
234
|
+
label,
|
|
235
|
+
// The client draws the label only when `visible`. Both decisions — who
|
|
236
|
+
// owns the provider, and whether to draw anything — are made here so the
|
|
237
|
+
// page never has to re-derive them.
|
|
238
|
+
visible: label !== '' && shouldRender(decision, config),
|
|
239
|
+
owner: decision.owner,
|
|
240
|
+
reason: decision.reason,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
return { providers: out, coreNamespaces, default: readDefaultSelection(ctx) }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Send one JSON reply that no cache should reuse.
|
|
248
|
+
*
|
|
249
|
+
* @param {import('node:http').ServerResponse} res - response to write.
|
|
250
|
+
* @param {number} status - HTTP status code.
|
|
251
|
+
* @param {unknown} body - JSON-serialisable payload.
|
|
252
|
+
* @returns {void}
|
|
253
|
+
*/
|
|
254
|
+
function json(res, status, body) {
|
|
255
|
+
res.writeHead(status, {
|
|
256
|
+
'content-type': 'application/json; charset=utf-8',
|
|
257
|
+
'cache-control': 'no-store',
|
|
258
|
+
})
|
|
259
|
+
res.end(JSON.stringify(body))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Accept only requests issued by the page this Host itself serves.
|
|
264
|
+
*
|
|
265
|
+
* The document exposes provider topology, so the route is held to the loopback
|
|
266
|
+
* rule other plugin routes use: the Host header must name this listener, and a
|
|
267
|
+
* present Origin must be loopback too. A same-origin `fetch` from the served
|
|
268
|
+
* page satisfies both.
|
|
269
|
+
*
|
|
270
|
+
* @param {import('node:http').IncomingMessage} req - incoming request.
|
|
271
|
+
* @returns {boolean} whether the request may be served.
|
|
272
|
+
*/
|
|
273
|
+
function fromThisPage(req) {
|
|
274
|
+
const local = req.socket?.localAddress
|
|
275
|
+
const host = req.headers.host
|
|
276
|
+
if (typeof host === 'string' && local !== undefined) {
|
|
277
|
+
const address = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')
|
|
278
|
+
if (!(address === local || address === 'localhost' || address === '127.0.0.1' || address === '::1')) {
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const origin = req.headers.origin
|
|
283
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
284
|
+
try {
|
|
285
|
+
const { hostname } = new URL(origin)
|
|
286
|
+
if (!(hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === local)) {
|
|
287
|
+
return false
|
|
288
|
+
}
|
|
289
|
+
} catch {
|
|
290
|
+
return false
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return true
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Host entry point: register the read-only provider directory route.
|
|
298
|
+
*
|
|
299
|
+
* The route is registered inside `ctx.effect`, so unloading the plugin removes
|
|
300
|
+
* it and drops the snapshot with it.
|
|
301
|
+
*
|
|
302
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
303
|
+
* @param {unknown} [rawConfig] - the row's configuration.
|
|
304
|
+
* @returns {void}
|
|
305
|
+
*/
|
|
306
|
+
export function apply(ctx, rawConfig) {
|
|
307
|
+
const config = effectiveConfig(rawConfig)
|
|
308
|
+
let snapshot
|
|
309
|
+
let snapshotAt = 0
|
|
310
|
+
|
|
311
|
+
// `ctx.inject` — not `ctx.get` — because the web server is an ordinary service
|
|
312
|
+
// in the composition: at plugin activation it may not exist yet, and a
|
|
313
|
+
// one-shot `get` would silently leave the route unregistered forever.
|
|
314
|
+
//
|
|
315
|
+
// `scope.effect` — not a returned value — because a plugin's return value is
|
|
316
|
+
// DISCARDED by the Loader; only an effect disposer is run on unload. Returning
|
|
317
|
+
// the route disposer would register the route and then leak it forever.
|
|
318
|
+
ctx.inject(['webServer'], (scope) => {
|
|
319
|
+
const webServer = scope.webServer
|
|
320
|
+
if (webServer === undefined || typeof webServer.register !== 'function') return
|
|
321
|
+
scope.effect(() => webServer.register({
|
|
322
|
+
kind: 'exact',
|
|
323
|
+
path: PROVIDERS_PATH,
|
|
324
|
+
handler: async (req, res) => {
|
|
325
|
+
if (req.method !== 'GET') {
|
|
326
|
+
json(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
if (!fromThisPage(req)) {
|
|
330
|
+
json(res, 403, { ok: false, error: 'not-this-page' })
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
const now = Date.now()
|
|
334
|
+
if (snapshot === undefined || now - snapshotAt >= CACHE_MS) {
|
|
335
|
+
try {
|
|
336
|
+
snapshot = describeProviders(ctx, config)
|
|
337
|
+
snapshotAt = now
|
|
338
|
+
} catch (error) {
|
|
339
|
+
// A failed read is not a failed route: the client treats an absent
|
|
340
|
+
// answer as "draw nothing", and the reason is reported rather than
|
|
341
|
+
// thrown into the request lifecycle.
|
|
342
|
+
json(res, 200, {
|
|
343
|
+
ok: false,
|
|
344
|
+
error: 'directory-unavailable',
|
|
345
|
+
message: error instanceof Error ? error.message : String(error),
|
|
346
|
+
})
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
json(res, 200, {
|
|
351
|
+
ok: true,
|
|
352
|
+
value: { ...snapshot, enabled: config.enabled },
|
|
353
|
+
})
|
|
354
|
+
},
|
|
355
|
+
}), 'dsh-provider-show: provider directory route')
|
|
356
|
+
})
|
|
357
|
+
}
|
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.1",
|
|
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
|
+
}
|