dsh-model-doctor 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/cordis.patch.yml +4 -0
- package/lib/client/index.js +556 -0
- package/lib/index.js +731 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# dsh-model-doctor · 模型医生
|
|
2
|
+
|
|
3
|
+
DSH 桌面客户端插件:在对话输入区的**模型选择菜单**里,为每个模型选项追加两枚按钮。
|
|
4
|
+
|
|
5
|
+
| 按钮 | 功能 |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| ⟳ 连通检测 | 宿主直连该模型的网关发一次 1-token 探测,行内徽章显示结果:`✓ 832ms`(连通)/ `✗ 402 鉴权/欠费` / `✗ 429 额度/限流` / `✗ 404 不存在` / `✗ 网络/超时`;悬停徽章看完整报错原文 |
|
|
8
|
+
| 🗑 删除 | 两段式确认(先点变「确认删除?」再点执行),从 `llm-pi-ai` 用户配置里移除该模型;写配置前自动备份配置文档(保留最近 5 份 `.dsh-model-doctor-*.bak`) |
|
|
9
|
+
|
|
10
|
+
菜单顶部是「⚡ 全部检测」条:并发(4 路)探测菜单里出现的**全部**模型,汇总「N 通 / M 异常」,
|
|
11
|
+
并显示 siliconflow 与 DeepSeek 官方的**账户余额**。
|
|
12
|
+
|
|
13
|
+
## 结果会保留
|
|
14
|
+
|
|
15
|
+
- 检测结果(含单模型 ⟳ 的结果)落盘为单个快照文件:
|
|
16
|
+
`%USERPROFILE%\.dsh\model-doctor-results.json`
|
|
17
|
+
- **关掉菜单再打开**:自动回填徽章,状态条显示 `上次结果:X 通 / Y 异常(共 N)· 14:32`
|
|
18
|
+
- **重启软件**:客户端启动时读快照,开菜单即回填,无需重新检测
|
|
19
|
+
- **只保留最近一次**:单文件覆盖写,每次检测直接盖掉上一次,更早的自动消失
|
|
20
|
+
|
|
21
|
+
## 探测范围与欠费判定
|
|
22
|
+
|
|
23
|
+
1. `cordis.patch.yml` 里 `llm-pi-ai` 配置的供应商(智谱 / 硅基流动 / 阿里云百炼…);
|
|
24
|
+
2. **DeepSeek 官方**(不在 llm-pi-ai 配置里,走内置兜底路由
|
|
25
|
+
`https://api.deepseek.com/v1` + 凭据 `DEEPSEEK_API_KEY`)。
|
|
26
|
+
|
|
27
|
+
状态码含义:`200` 连通;`401/402/403` 鉴权失败或欠费(余额不足);`429` 额度耗尽或限流;
|
|
28
|
+
`404` 模型不存在;`5xx` 服务端错误;无响应记为超时。
|
|
29
|
+
密钥从 `~/.dsh/.credentials.yaml` 的 refs 段或进程环境解析,只在内存里拼进请求头,不落盘、不打印。
|
|
30
|
+
|
|
31
|
+
## 依赖与适配
|
|
32
|
+
|
|
33
|
+
- 模型菜单本体由 **dsh-reasoning-effort** 插件渲染(`.re-model-*` 行结构)。本插件是
|
|
34
|
+
行内 DOM 增强:在既有行上追加自持元素并 `stopPropagation`,不替换任何 React 节点、
|
|
35
|
+
不抢 `conversation.input.model` 座位。禁用 dsh-reasoning-effort 后按钮不出现(安全无副作用)。
|
|
36
|
+
- 宿主↔浏览器通道走 `ctx.connection.fetch.register` 注册到共享 `/api` 通道
|
|
37
|
+
(客户端裸 `fetch('/api/model-doctor/...')`),这是桌面端实测可达的机制。
|
|
38
|
+
- `package.json` 的 `dsh.client.immediately: true` 必须保留:客户端脚本包默认懒加载,
|
|
39
|
+
没有它纯 DOM 增强插件永远不会被激活。
|
|
40
|
+
|
|
41
|
+
## 安装
|
|
42
|
+
|
|
43
|
+
本目录即完整插件(无构建步骤、零依赖、无需 npm install)。
|
|
44
|
+
|
|
45
|
+
**方式一(从本包安装)**:把 `dsh-model-doctor` 整个目录放进
|
|
46
|
+
`C:\Users\lxy33\.dsh\profiles\desktop\node_modules\`,并在 profile 的
|
|
47
|
+
`C:\Users\lxy33\.dsh\profiles\desktop\cordis.patch.yml` 末尾追加:
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
- insert:
|
|
51
|
+
- id: model-doctor
|
|
52
|
+
name: 'dsh-model-doctor'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
⚠️ 必须是 `insert:` 列表格式;写成 `- id: model-doctor / name: dsh-model-doctor`
|
|
56
|
+
(config 覆盖格式)会被装载器**静默忽略**,插件不会装载且没有任何报错。
|
|
57
|
+
|
|
58
|
+
宿主半热加载即时生效;客户端半刷新 GUI 页面(`Ctrl+R`)后生效。
|
|
59
|
+
|
|
60
|
+
**方式二(从桌面 zip)**:解压 `dsh-model-doctor-v0.3.0.zip` 后按方式一放置即可。
|
|
61
|
+
|
|
62
|
+
## 恢复误删的模型
|
|
63
|
+
|
|
64
|
+
- 打开 `C:\Users\lxy33\.dsh\profiles\desktop\cordis.patch.yml.dsh-model-doctor-*.bak`
|
|
65
|
+
(时间戳最新的一份),把对应模型条目复制回 `models:` 列表;
|
|
66
|
+
- 或在 DSH 设置 → 模型供应商里重新添加;
|
|
67
|
+
- 官方内置目录(如 DeepSeek 官方)的模型不受配置文件管辖,点 🗑 会提示无法删除——这是预期行为。
|
|
68
|
+
|
|
69
|
+
## 版本
|
|
70
|
+
|
|
71
|
+
- v0.3.0 — 检测结果持久化(跨菜单开关与重启保留,只留最近一次)
|
|
72
|
+
- v0.2.1 — 按钮放大到 27px
|
|
73
|
+
- v0.2.0 — 修复结果回填映射(重扫重新入表 + 模型 id 兜底匹配)、按钮钉在选项右侧、
|
|
74
|
+
探测范围扩展到权威目录、新增 DeepSeek 官方兜底路由与余额
|
|
75
|
+
- v0.1.2/0.1.3 — 切换到 `/api` 共享通道;修复 `llm.listProviders()` 同步返回被误挂 `.catch`
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-model-doctor — 浏览器半(无框架,纯 DOM 增强)。
|
|
3
|
+
*
|
|
4
|
+
* 监听模型选择菜单(dsh-reasoning-effort 渲染的 .re-model-menu)的出现,
|
|
5
|
+
* 在每个模型选项行末尾追加两枚小按钮:
|
|
6
|
+
* ⟳ 连通检测 —— 经回环 RPC 让 Host 直连网关探测该模型(连通/欠费/限流/鉴权),
|
|
7
|
+
* 结果以行内小徽章呈现(悬停看完整报错);
|
|
8
|
+
* 🗑 删除 —— 两段式确认后让 Host 从 llm-pi-ai 配置移除该模型(Host 自动备份
|
|
9
|
+
* 配置文档),随后立即移除该行,菜单重开时即为新目录。
|
|
10
|
+
*
|
|
11
|
+
* 菜单顶部追加「全部检测」按钮与供应商余额展示(siliconflow)。
|
|
12
|
+
* 本半不替换任何 React 节点、不与 dsh-reasoning-effort 抢座位:全部增强都在
|
|
13
|
+
* 既有行内追加自持元素,事件上 stopPropagation 阻止冒泡到选项本身。
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-model-doctor/client
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
window.__ModuleLoader__.load({
|
|
19
|
+
id: 'dsh-model-doctor',
|
|
20
|
+
factory: (require) => {
|
|
21
|
+
const module = { exports: {} }
|
|
22
|
+
const exports = module.exports
|
|
23
|
+
|
|
24
|
+
const API_BASE = '/api/model-doctor'
|
|
25
|
+
const MENU_SEL = '.re-model-menu'
|
|
26
|
+
const PANE_SEL = '.re-model-pane'
|
|
27
|
+
const ROW_SEL = '.re-model-option'
|
|
28
|
+
const NAME_SEL = '.re-model-option-name'
|
|
29
|
+
const TITLE_SEL = '.re-model-group-title'
|
|
30
|
+
const DIRECTORY_TTL_MS = 30000
|
|
31
|
+
const DIRECTORY_RETRY_MS = 10000
|
|
32
|
+
|
|
33
|
+
/** 客户端半不需要上下文服务:宿主通道走 /api 裸 fetch。 */
|
|
34
|
+
const inject = []
|
|
35
|
+
|
|
36
|
+
const KIND_LABEL = {
|
|
37
|
+
ok: '连通',
|
|
38
|
+
auth: '鉴权/欠费',
|
|
39
|
+
quota: '额度/限流',
|
|
40
|
+
notfound: '不存在',
|
|
41
|
+
badrequest: '请求被拒',
|
|
42
|
+
server: '服务端错误',
|
|
43
|
+
network: '网络/超时',
|
|
44
|
+
config: '未配置',
|
|
45
|
+
unsupported: '无法探测',
|
|
46
|
+
channel: '通道异常',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const CSS = `
|
|
50
|
+
.dmd-actions{position:absolute;right:8px;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;gap:5px;z-index:2}
|
|
51
|
+
.dmd-btn{width:27px;height:27px;line-height:26px;text-align:center;font-size:16px;border-radius:8px;cursor:pointer;user-select:none;color:inherit;background:color-mix(in srgb,currentColor 8%,transparent);opacity:.72;flex-shrink:0}
|
|
52
|
+
.dmd-btn:hover{opacity:1;background:color-mix(in srgb,currentColor 18%,transparent)}
|
|
53
|
+
.dmd-btn.dmd-refresh.is-busy{opacity:1;animation:dmd-spin 0.9s linear infinite}
|
|
54
|
+
.dmd-btn.dmd-delete:hover{color:#e5484d}
|
|
55
|
+
.dmd-btn.is-armed{width:auto;padding:0 9px;color:#e5484d;opacity:1;font-size:12px;font-weight:600;line-height:26px}
|
|
56
|
+
@keyframes dmd-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
|
|
57
|
+
.dmd-chip{font-size:11px;line-height:17px;padding:0 7px;margin-left:7px;border-radius:999px;white-space:nowrap;vertical-align:middle;display:inline-block}
|
|
58
|
+
.dmd-chip.is-ok{color:#2fa96e;background:color-mix(in srgb,#2fa96e 16%,transparent)}
|
|
59
|
+
.dmd-chip.is-bad{color:#e5484d;background:color-mix(in srgb,#e5484d 14%,transparent)}
|
|
60
|
+
.dmd-chip.is-warn{color:#d08700;background:color-mix(in srgb,#d08700 16%,transparent)}
|
|
61
|
+
.dmd-allbar{display:flex;align-items:center;gap:8px;padding:6px 12px;margin:2px 6px;font-size:11px;border-radius:8px;background:color-mix(in srgb,currentColor 6%,transparent)}
|
|
62
|
+
.dmd-allbar button{font:inherit;font-size:11px;line-height:16px;padding:2px 9px;border-radius:6px;border:1px solid color-mix(in srgb,currentColor 25%,transparent);background:transparent;color:inherit;cursor:pointer}
|
|
63
|
+
.dmd-allbar button:disabled{opacity:.45;cursor:default}
|
|
64
|
+
.dmd-allbar .dmd-status{opacity:.65}
|
|
65
|
+
.dmd-allbar .dmd-balance{color:#2fa96e}
|
|
66
|
+
`
|
|
67
|
+
|
|
68
|
+
/** 调用宿主半的 /api 路由(与 dsh-memory-board 同款裸 fetch 机制);失败也返回结构化结果。 */
|
|
69
|
+
function makeDoctor() {
|
|
70
|
+
const call = async (endpoint, payload) => {
|
|
71
|
+
try {
|
|
72
|
+
const response = await fetch(`${API_BASE}/${endpoint}`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'content-type': 'application/json' },
|
|
75
|
+
body: JSON.stringify(payload ?? {}),
|
|
76
|
+
})
|
|
77
|
+
if (!response.ok) return { ok: false, error: `HTTP ${response.status}` }
|
|
78
|
+
const result = await response.json()
|
|
79
|
+
if (result && typeof result === 'object' && 'ok' in result) {
|
|
80
|
+
return result.ok ? result.value : { ok: false, error: result.error?.message ?? '请求失败' }
|
|
81
|
+
}
|
|
82
|
+
return { ok: false, error: '响应格式异常' }
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
directory: () => call('directory'),
|
|
89
|
+
check: (provider, model) => call('check', { provider, model }),
|
|
90
|
+
checkAll: () => call('checkAll'),
|
|
91
|
+
deleteModel: (provider, model) => call('deleteModel', { provider, model }),
|
|
92
|
+
resultsLoad: () => call('resultsLoad'),
|
|
93
|
+
resultsSave: (snapshot) => call('resultsSave', snapshot),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* ---------------------------------------------------------------- */
|
|
98
|
+
/* 增强状态 */
|
|
99
|
+
/* ---------------------------------------------------------------- */
|
|
100
|
+
|
|
101
|
+
let doctor = null
|
|
102
|
+
let ctxRef = null
|
|
103
|
+
let styleEl = null
|
|
104
|
+
let observer = null
|
|
105
|
+
let rafId = 0
|
|
106
|
+
let disposed = false
|
|
107
|
+
|
|
108
|
+
/** 权威目录缓存:{ providers: [{id,name,models:[{id,name}]}] } */
|
|
109
|
+
let directory = null
|
|
110
|
+
let directoryAt = 0
|
|
111
|
+
let directoryFailedAt = 0
|
|
112
|
+
/** 最近一次目录获取失败的原文(透出到状态条,便于定位卡点)。 */
|
|
113
|
+
let directoryError = ''
|
|
114
|
+
|
|
115
|
+
/** 最近一次检测结果快照 { results, balances, at };跨菜单开关与软件重启保留。 */
|
|
116
|
+
let lastRun = null
|
|
117
|
+
/** 全部检测进行中:状态条不被缓存回填覆盖。 */
|
|
118
|
+
let barBusy = false
|
|
119
|
+
|
|
120
|
+
/** 行键 -> 模型行元素,供全部检测回填徽章。 */
|
|
121
|
+
const rowByKey = new Map()
|
|
122
|
+
/** 模型 id -> 行元素(全表唯一时可用):供应商 id 空间对不上时的兜底匹配。 */
|
|
123
|
+
const rowByModel = new Map()
|
|
124
|
+
/** 本页会话内已删除的模型键,防目录未刷新时行又冒出来。 */
|
|
125
|
+
const deletedKeys = new Set()
|
|
126
|
+
|
|
127
|
+
const keyOf = (provider, model) => `${provider}::${model}`
|
|
128
|
+
|
|
129
|
+
function fetchDirectory(force = false) {
|
|
130
|
+
if (doctor === null) return Promise.resolve()
|
|
131
|
+
const now = Date.now()
|
|
132
|
+
if (!force && directory !== null && now - directoryAt < DIRECTORY_TTL_MS) return Promise.resolve()
|
|
133
|
+
if (!force && directoryFailedAt !== 0 && now - directoryFailedAt < DIRECTORY_RETRY_MS) return Promise.resolve()
|
|
134
|
+
return doctor.directory().then((result) => {
|
|
135
|
+
if (disposed) return
|
|
136
|
+
if (result && result.ok !== false && Array.isArray(result.providers)) {
|
|
137
|
+
directory = result
|
|
138
|
+
directoryAt = Date.now()
|
|
139
|
+
directoryFailedAt = 0
|
|
140
|
+
directoryError = ''
|
|
141
|
+
scheduleEnhance()
|
|
142
|
+
} else {
|
|
143
|
+
directoryFailedAt = Date.now()
|
|
144
|
+
directoryError = typeof result?.error === 'string' ? result.error : '响应里没有 providers'
|
|
145
|
+
}
|
|
146
|
+
}).catch((error) => {
|
|
147
|
+
directoryFailedAt = Date.now()
|
|
148
|
+
directoryError = error instanceof Error ? error.message : String(error)
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ---------------------------------------------------------------- */
|
|
153
|
+
/* 结果快照:加载 / 保存 / 回填 */
|
|
154
|
+
/* ---------------------------------------------------------------- */
|
|
155
|
+
|
|
156
|
+
function timeLabel(at) {
|
|
157
|
+
try {
|
|
158
|
+
return new Date(at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
|
159
|
+
} catch {
|
|
160
|
+
return ''
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function summarize(results) {
|
|
165
|
+
let ok = 0
|
|
166
|
+
let bad = 0
|
|
167
|
+
for (const value of Object.values(results ?? {})) {
|
|
168
|
+
if (value && value.ok === true) ok += 1
|
|
169
|
+
else bad += 1
|
|
170
|
+
}
|
|
171
|
+
return { ok, bad, total: ok + bad }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function updateBar(text) {
|
|
175
|
+
if (barBusy) return
|
|
176
|
+
document.querySelectorAll('.dmd-allbar .dmd-status').forEach((node) => { node.textContent = text })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function updateBalance(balances) {
|
|
180
|
+
if (!isRecord(balances)) return
|
|
181
|
+
const parts = Object.entries(balances)
|
|
182
|
+
.map(([providerId, value]) => `${providerLabel(providerId)}: ¥${value}`)
|
|
183
|
+
document.querySelectorAll('.dmd-allbar .dmd-balance').forEach((node) => { node.textContent = parts.join(' · ') })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 把上次结果回填到当前菜单的行与状态条上;返回回填的行数。 */
|
|
187
|
+
function paintLastRun(prefix) {
|
|
188
|
+
if (barBusy || lastRun === null || !isRecord(lastRun.results)) return 0
|
|
189
|
+
let painted = 0
|
|
190
|
+
for (const [key, single] of Object.entries(lastRun.results)) {
|
|
191
|
+
const separator = key.lastIndexOf('::')
|
|
192
|
+
const modelId = separator >= 0 ? key.slice(separator + 2) : key
|
|
193
|
+
const row = rowByKey.get(key) ?? rowByModel.get(modelId) ?? null
|
|
194
|
+
if (row === null) continue
|
|
195
|
+
paintChip(row, single)
|
|
196
|
+
painted += 1
|
|
197
|
+
}
|
|
198
|
+
if (painted > 0) {
|
|
199
|
+
const { ok, bad, total } = summarize(lastRun.results)
|
|
200
|
+
const when = timeLabel(lastRun.at)
|
|
201
|
+
updateBar(`${prefix}${ok} 通 / ${bad} 异常(共 ${total})${when ? ` · ${when}` : ''}`)
|
|
202
|
+
updateBalance(lastRun.balances)
|
|
203
|
+
}
|
|
204
|
+
return painted
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 记住一次检测结果(覆盖式:永远只保留最近一次)并落盘。 */
|
|
208
|
+
function rememberResults(results, balances) {
|
|
209
|
+
lastRun = { results, balances: isRecord(balances) ? balances : {}, at: Date.now() }
|
|
210
|
+
if (doctor !== null) void doctor.resultsSave(lastRun)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function loadResults() {
|
|
214
|
+
if (doctor === null) return Promise.resolve()
|
|
215
|
+
return doctor.resultsLoad().then((snapshot) => {
|
|
216
|
+
if (disposed) return
|
|
217
|
+
if (snapshot && isRecord(snapshot.results)) {
|
|
218
|
+
lastRun = snapshot
|
|
219
|
+
scheduleEnhance()
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* ---------------------------------------------------------------- */
|
|
225
|
+
/* 行内元素构造 */
|
|
226
|
+
/* ---------------------------------------------------------------- */
|
|
227
|
+
|
|
228
|
+
function chipOf(result) {
|
|
229
|
+
const chip = document.createElement('span')
|
|
230
|
+
if (!result || typeof result !== 'object' || result.ok === undefined) {
|
|
231
|
+
chip.className = 'dmd-chip is-bad'
|
|
232
|
+
chip.textContent = '✗ 通道异常'
|
|
233
|
+
return chip
|
|
234
|
+
}
|
|
235
|
+
if (result.ok === true) {
|
|
236
|
+
chip.className = 'dmd-chip is-ok'
|
|
237
|
+
chip.textContent = `✓ ${Number.isFinite(result.ms) ? result.ms : '?'}ms`
|
|
238
|
+
chip.title = '连通正常(1-token 探测)'
|
|
239
|
+
return chip
|
|
240
|
+
}
|
|
241
|
+
const kind = typeof result.kind === 'string'
|
|
242
|
+
? result.kind
|
|
243
|
+
: (typeof result.error === 'string' ? 'channel' : 'unsupported')
|
|
244
|
+
const label = KIND_LABEL[kind] ?? '失败'
|
|
245
|
+
const code = typeof result.status === 'number' && result.status > 0 ? ` ${result.status}` : ''
|
|
246
|
+
chip.className = 'dmd-chip is-bad'
|
|
247
|
+
chip.textContent = `✗${code} ${label}`
|
|
248
|
+
const detail = typeof result.message === 'string' && result.message.length > 0
|
|
249
|
+
? ` · ${result.message}`
|
|
250
|
+
: (typeof result.error === 'string' ? ` · ${result.error}` : '')
|
|
251
|
+
chip.title = `HTTP ${result.status || '-'}${detail}`
|
|
252
|
+
return chip
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function wireDelete(button, provider, model, row) {
|
|
256
|
+
let armed = false
|
|
257
|
+
let timer = 0
|
|
258
|
+
const disarm = () => {
|
|
259
|
+
armed = false
|
|
260
|
+
button.classList.remove('is-armed')
|
|
261
|
+
button.textContent = '🗑'
|
|
262
|
+
button.title = '从列表删除该模型(二次点击确认)'
|
|
263
|
+
}
|
|
264
|
+
button.addEventListener('click', (event) => {
|
|
265
|
+
event.stopPropagation()
|
|
266
|
+
event.preventDefault()
|
|
267
|
+
if (doctor === null) return
|
|
268
|
+
if (!armed) {
|
|
269
|
+
armed = true
|
|
270
|
+
button.classList.add('is-armed')
|
|
271
|
+
button.textContent = '确认删除?'
|
|
272
|
+
clearTimeout(timer)
|
|
273
|
+
timer = window.setTimeout(disarm, 2600)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
clearTimeout(timer)
|
|
277
|
+
button.classList.remove('is-armed')
|
|
278
|
+
button.textContent = '…'
|
|
279
|
+
doctor.deleteModel(provider, model).then((result) => {
|
|
280
|
+
if (disposed) return
|
|
281
|
+
if (result && result.ok === true) {
|
|
282
|
+
deletedKeys.add(keyOf(provider, model))
|
|
283
|
+
const section = row.closest('section')
|
|
284
|
+
row.remove()
|
|
285
|
+
if (section !== null && section.querySelectorAll(ROW_SEL).length === 0) section.remove()
|
|
286
|
+
fetchDirectory(true)
|
|
287
|
+
} else {
|
|
288
|
+
const message = result && typeof result.error === 'string' ? result.error : '删除失败'
|
|
289
|
+
const chip = document.createElement('span')
|
|
290
|
+
chip.className = 'dmd-chip is-bad'
|
|
291
|
+
chip.textContent = '✗ 删除失败'
|
|
292
|
+
chip.title = message
|
|
293
|
+
button.replaceWith(chip)
|
|
294
|
+
window.setTimeout(() => chip.remove(), 4000)
|
|
295
|
+
disarm()
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
button.addEventListener('mousedown', (event) => event.stopPropagation())
|
|
300
|
+
button.addEventListener('keydown', (event) => event.stopPropagation())
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 把结果徽章插到模型名后面(随文字行内排,不占右侧按钮的位置)。 */
|
|
304
|
+
function paintChip(row, result) {
|
|
305
|
+
row.querySelectorAll('.dmd-chip').forEach((chip) => chip.remove())
|
|
306
|
+
const chip = chipOf(result)
|
|
307
|
+
const name = row.querySelector(NAME_SEL)
|
|
308
|
+
if (name !== null && name.parentNode !== null) name.parentNode.insertBefore(chip, name.nextSibling)
|
|
309
|
+
else row.appendChild(chip)
|
|
310
|
+
return chip
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function enhanceRow(row, providerId, model) {
|
|
314
|
+
const key = keyOf(providerId, model.id)
|
|
315
|
+
if (deletedKeys.has(key)) {
|
|
316
|
+
row.remove()
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
// 映射登记放在早退之前:已挂过按钮的行每次扫描也要重新入表。
|
|
320
|
+
rowByKey.set(key, row)
|
|
321
|
+
const previous = rowByModel.get(model.id)
|
|
322
|
+
if (previous === undefined) rowByModel.set(model.id, row)
|
|
323
|
+
else if (previous !== row) rowByModel.set(model.id, null)
|
|
324
|
+
if (row.querySelector('.dmd-actions') !== null) return
|
|
325
|
+
|
|
326
|
+
// 按钮钉在选项右侧:行本身需要是定位上下文,并留出右侧空间。
|
|
327
|
+
const computed = window.getComputedStyle(row)
|
|
328
|
+
if (computed.position === 'static') row.style.position = 'relative'
|
|
329
|
+
if (Number.parseFloat(computed.paddingRight) < 88) row.style.paddingRight = '88px'
|
|
330
|
+
|
|
331
|
+
const actions = document.createElement('span')
|
|
332
|
+
actions.className = 'dmd-actions'
|
|
333
|
+
actions.dataset.dmdKey = key
|
|
334
|
+
|
|
335
|
+
const refresh = document.createElement('span')
|
|
336
|
+
refresh.className = 'dmd-btn dmd-refresh'
|
|
337
|
+
refresh.setAttribute('role', 'button')
|
|
338
|
+
refresh.textContent = '⟳'
|
|
339
|
+
refresh.title = '检测该模型连通状况 / 是否欠费'
|
|
340
|
+
refresh.addEventListener('click', (event) => {
|
|
341
|
+
event.stopPropagation()
|
|
342
|
+
event.preventDefault()
|
|
343
|
+
if (doctor === null || refresh.classList.contains('is-busy')) return
|
|
344
|
+
refresh.classList.add('is-busy')
|
|
345
|
+
row.querySelectorAll('.dmd-chip').forEach((chip) => chip.remove())
|
|
346
|
+
doctor.check(providerId, model.id).then((result) => {
|
|
347
|
+
if (disposed) return
|
|
348
|
+
refresh.classList.remove('is-busy')
|
|
349
|
+
paintChip(row, result)
|
|
350
|
+
rememberResults({ ...(lastRun?.results ?? {}), [key]: result }, lastRun?.balances)
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
refresh.addEventListener('mousedown', (event) => event.stopPropagation())
|
|
354
|
+
|
|
355
|
+
const del = document.createElement('span')
|
|
356
|
+
del.className = 'dmd-btn dmd-delete'
|
|
357
|
+
del.setAttribute('role', 'button')
|
|
358
|
+
del.textContent = '🗑'
|
|
359
|
+
del.title = '从列表删除该模型(二次点击确认)'
|
|
360
|
+
wireDelete(del, providerId, model.id, row)
|
|
361
|
+
|
|
362
|
+
actions.appendChild(refresh)
|
|
363
|
+
actions.appendChild(del)
|
|
364
|
+
row.appendChild(actions)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/* ---------------------------------------------------------------- */
|
|
368
|
+
/* 「全部检测」条 */
|
|
369
|
+
/* ---------------------------------------------------------------- */
|
|
370
|
+
|
|
371
|
+
function ensureAllBar(pane, hint) {
|
|
372
|
+
const existing = pane.querySelector('.dmd-allbar')
|
|
373
|
+
if (existing !== null) {
|
|
374
|
+
if (hint) {
|
|
375
|
+
const status = existing.querySelector('.dmd-status')
|
|
376
|
+
if (status !== null && status.textContent === '') status.textContent = hint
|
|
377
|
+
}
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
const firstSection = pane.querySelector('section')
|
|
381
|
+
if (firstSection === null) return
|
|
382
|
+
const bar = document.createElement('div')
|
|
383
|
+
bar.className = 'dmd-allbar'
|
|
384
|
+
const button = document.createElement('button')
|
|
385
|
+
button.type = 'button'
|
|
386
|
+
button.textContent = '⚡ 全部检测'
|
|
387
|
+
button.title = '并发探测列表里全部模型的连通状况与额度'
|
|
388
|
+
const status = document.createElement('span')
|
|
389
|
+
status.className = 'dmd-status'
|
|
390
|
+
const balance = document.createElement('span')
|
|
391
|
+
balance.className = 'dmd-balance'
|
|
392
|
+
button.addEventListener('click', (event) => {
|
|
393
|
+
event.stopPropagation()
|
|
394
|
+
event.preventDefault()
|
|
395
|
+
if (doctor === null || button.disabled) return
|
|
396
|
+
button.disabled = true
|
|
397
|
+
barBusy = true
|
|
398
|
+
status.textContent = '探测中…'
|
|
399
|
+
balance.textContent = ''
|
|
400
|
+
rowByKey.forEach((row) => {
|
|
401
|
+
row.querySelectorAll('.dmd-chip').forEach((chip) => chip.remove())
|
|
402
|
+
})
|
|
403
|
+
doctor.checkAll().then((result) => {
|
|
404
|
+
if (disposed) return
|
|
405
|
+
button.disabled = false
|
|
406
|
+
barBusy = false
|
|
407
|
+
if (!result || result.ok === false || !isRecord(result.results)) {
|
|
408
|
+
status.textContent = `失败:${result && typeof result.error === 'string' ? result.error : '通道异常'}`
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
rememberResults(result.results, result.balances)
|
|
412
|
+
let okCount = 0
|
|
413
|
+
let badCount = 0
|
|
414
|
+
let unmatched = 0
|
|
415
|
+
const entries = Object.entries(result.results)
|
|
416
|
+
for (const [key, single] of entries) {
|
|
417
|
+
const separator = key.lastIndexOf('::')
|
|
418
|
+
const modelId = separator >= 0 ? key.slice(separator + 2) : key
|
|
419
|
+
const row = rowByKey.get(key) ?? rowByModel.get(modelId) ?? null
|
|
420
|
+
if (row === null) unmatched += 1
|
|
421
|
+
else paintChip(row, single)
|
|
422
|
+
if (single && single.ok === true) okCount += 1
|
|
423
|
+
else badCount += 1
|
|
424
|
+
}
|
|
425
|
+
const suffix = unmatched > 0 ? ` · 未匹配 ${unmatched}` : ''
|
|
426
|
+
const when = timeLabel(lastRun?.at ?? Date.now())
|
|
427
|
+
status.textContent = `完成:${okCount} 通 / ${badCount} 异常(共 ${entries.length})${suffix}${when ? ` · ${when}` : ''}`
|
|
428
|
+
if (isRecord(result.balances)) {
|
|
429
|
+
const parts = Object.entries(result.balances)
|
|
430
|
+
.map(([providerId, value]) => `${providerLabel(providerId)}: ¥${value}`)
|
|
431
|
+
balance.textContent = parts.join(' · ')
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
button.addEventListener('mousedown', (event) => event.stopPropagation())
|
|
436
|
+
bar.appendChild(button)
|
|
437
|
+
bar.appendChild(status)
|
|
438
|
+
bar.appendChild(balance)
|
|
439
|
+
if (hint) status.textContent = hint
|
|
440
|
+
firstSection.parentNode.insertBefore(bar, firstSection)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function providerLabel(providerId) {
|
|
444
|
+
const provider = directory?.providers?.find((p) => p.id === providerId)
|
|
445
|
+
return provider?.name ?? providerId
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/* ---------------------------------------------------------------- */
|
|
449
|
+
/* 菜单增强主逻辑 */
|
|
450
|
+
/* ---------------------------------------------------------------- */
|
|
451
|
+
|
|
452
|
+
function enhance() {
|
|
453
|
+
if (disposed) return
|
|
454
|
+
const menu = document.querySelector(MENU_SEL)
|
|
455
|
+
if (menu === null) return
|
|
456
|
+
|
|
457
|
+
if (directory === null || Date.now() - directoryAt >= DIRECTORY_TTL_MS) {
|
|
458
|
+
void fetchDirectory()
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
rowByKey.clear()
|
|
462
|
+
rowByModel.clear()
|
|
463
|
+
const panes = menu.querySelectorAll(PANE_SEL)
|
|
464
|
+
panes.forEach((pane) => {
|
|
465
|
+
const sections = pane.querySelectorAll('section')
|
|
466
|
+
if (sections.length === 0) return
|
|
467
|
+
let wired = 0
|
|
468
|
+
sections.forEach((section) => {
|
|
469
|
+
const title = section.querySelector(TITLE_SEL)?.textContent?.trim()
|
|
470
|
+
if (title === undefined || title.length === 0) return
|
|
471
|
+
const rows = Array.from(section.querySelectorAll(ROW_SEL))
|
|
472
|
+
const group = directory?.providers?.find((p) => p.name === title || p.id === title)
|
|
473
|
+
if (group !== undefined && rows.length === group.models.length) {
|
|
474
|
+
rows.forEach((row, index) => {
|
|
475
|
+
const model = group.models[index]
|
|
476
|
+
const name = row.querySelector(NAME_SEL)?.textContent?.trim()
|
|
477
|
+
if (name !== undefined && name !== model.name) return
|
|
478
|
+
enhanceRow(row, group.id, model)
|
|
479
|
+
wired += 1
|
|
480
|
+
})
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
// 兜底:供应商显示名对不上时,按「模型显示名在全目录里唯一」反查
|
|
484
|
+
rows.forEach((row) => {
|
|
485
|
+
const name = row.querySelector(NAME_SEL)?.textContent?.trim()
|
|
486
|
+
if (name === undefined || name.length === 0) return
|
|
487
|
+
const hits = []
|
|
488
|
+
for (const provider of directory?.providers ?? []) {
|
|
489
|
+
for (const model of provider.models) {
|
|
490
|
+
if (model.name === name) hits.push({ provider, model })
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (hits.length !== 1) return
|
|
494
|
+
enhanceRow(row, hits[0].provider.id, hits[0].model)
|
|
495
|
+
wired += 1
|
|
496
|
+
})
|
|
497
|
+
})
|
|
498
|
+
let hint = ''
|
|
499
|
+
if (directory === null) {
|
|
500
|
+
hint = directoryFailedAt !== 0
|
|
501
|
+
? `模型医生:目录获取失败 · ${directoryError || '与宿主半通信异常'}`
|
|
502
|
+
: '模型医生:目录获取中…'
|
|
503
|
+
} else if (wired === 0) {
|
|
504
|
+
hint = '模型医生:未匹配到模型行(目录与菜单不一致)'
|
|
505
|
+
}
|
|
506
|
+
ensureAllBar(pane, hint)
|
|
507
|
+
if (wired > 0) paintLastRun('上次结果:')
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function scheduleEnhance() {
|
|
512
|
+
if (rafId !== 0 || disposed) return
|
|
513
|
+
rafId = window.requestAnimationFrame(() => {
|
|
514
|
+
rafId = 0
|
|
515
|
+
enhance()
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/* ---------------------------------------------------------------- */
|
|
520
|
+
/* apply */
|
|
521
|
+
/* ---------------------------------------------------------------- */
|
|
522
|
+
|
|
523
|
+
function isRecord(value) {
|
|
524
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function apply(ctx) {
|
|
528
|
+
ctxRef = ctx
|
|
529
|
+
doctor = makeDoctor()
|
|
530
|
+
|
|
531
|
+
ctx.effect(() => {
|
|
532
|
+
styleEl = document.createElement('style')
|
|
533
|
+
styleEl.dataset.plugin = 'dsh-model-doctor'
|
|
534
|
+
styleEl.textContent = CSS
|
|
535
|
+
document.head.appendChild(styleEl)
|
|
536
|
+
return () => styleEl?.remove()
|
|
537
|
+
}, 'model-doctor: styles')
|
|
538
|
+
|
|
539
|
+
ctx.effect(() => {
|
|
540
|
+
observer = new MutationObserver(scheduleEnhance)
|
|
541
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
542
|
+
scheduleEnhance()
|
|
543
|
+
return () => {
|
|
544
|
+
disposed = true
|
|
545
|
+
observer?.disconnect()
|
|
546
|
+
if (rafId !== 0) window.cancelAnimationFrame(rafId)
|
|
547
|
+
}
|
|
548
|
+
}, 'model-doctor: observer')
|
|
549
|
+
|
|
550
|
+
void loadResults()
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
module.exports = { apply, inject }
|
|
554
|
+
return module.exports
|
|
555
|
+
},
|
|
556
|
+
})
|