@sidady/dsh-steward 0.0.0-stage → 0.9.14

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/lib/intent.js ADDED
@@ -0,0 +1,298 @@
1
+ // L2 意图解析:一句话 → 一个「要去哪」的结论。
2
+ //
3
+ // 三条硬约束,写在这里是为了以后别被改坏:
4
+ //
5
+ // 1. 零 token、零网络、纯函数。这个模块不调用任何模型,只做规则匹配。
6
+ // 面板要的是「毫秒级、离线可用、结果可复现」,模型理解走聊天窗口那条路。
7
+ // 2. 只产出意图,绝不执行。所有意图最终都落到既有链路:
8
+ // plan(零写入预览)→ confirm:true + 一次性指纹 → audit.jsonl。
9
+ // 这里没有、也不许出现任何 execute 的直接调用。
10
+ // 3. 听不懂就说听不懂,不猜。歧义或没命中时返回 clarify,让用户补一句;
11
+ // 宁可多问一句,也不要因为「猜对过一次」就养成替用户拍板的习惯。
12
+
13
+ const RULES = [
14
+ {
15
+ kind: 'clean',
16
+ words: ['清理', '清一下', '清缓存', '清垃圾', '腾空间', '腾出', '释放空间',
17
+ '占空间', '太大了', '清掉', '删除缓存', '瘦身', 'clean'],
18
+ },
19
+ {
20
+ kind: 'backup',
21
+ // 只收「动作词」。「易失」「危险数据」这类是名词描述,不构成指令:
22
+ // 用户说「静音易失数据那条提醒」时,命中的应该是 ack,不是 backup。
23
+ // 名词单独出现时就该回去问,而不是替用户决定要备份。
24
+ words: ['备份', '加密备份', '存档', '存一份', '打包', '备份一下', 'backup'],
25
+ },
26
+ {
27
+ kind: 'disable',
28
+ words: ['禁用', '停用', '关掉', '关闭', '禁掉', '停掉', 'disable'],
29
+ },
30
+ {
31
+ kind: 'enable',
32
+ words: ['启用', '开启', '打开', '重新启用', '恢复启用', 'enable'],
33
+ },
34
+ {
35
+ kind: 'knowledge',
36
+ words: ['沉淀', '记录', '记一下', '记住这次', '留个记录', '存档结论', 'knowledge'],
37
+ },
38
+ {
39
+ kind: 'ack',
40
+ words: ['静音', '别提醒', '不用管', '忽略', '别再报', 'ack'],
41
+ },
42
+ {
43
+ kind: 'unack',
44
+ words: ['恢复提醒', '取消静音', '别静音', '恢复显示', 'unack'],
45
+ },
46
+ {
47
+ // 只读导航:不碰任何动作,只是把面板切到对应 Tab。
48
+ // 截图里那句「去到审计趋势」就该落在这里——它不是 intent 解析不了,
49
+ // 而是之前根本没有「去哪一页」这个意图。
50
+ kind: 'goto',
51
+ words: ['审计趋势', '审计', '台账', '趋势', '操作记录', '动作中心', '总览', '首页'],
52
+ },
53
+ {
54
+ // 只读意图:命中后直接重新体检,不走动作链路。
55
+ kind: 'report',
56
+ words: ['看看', '体检', '检查', '怎么样', '有问题吗', '什么问题', '毛病', '情况', '状态',
57
+ '健康', '报告', '还好吗', 'report', 'status'],
58
+ },
59
+ ];
60
+
61
+ // 否定前缀:出现在命中词之前 3 个字符内,视为「不要做这件事」。
62
+ // 注意 '别提醒' / '不用管' 这类本身就是意图词,它们匹配段在否定词之后,
63
+ // 所以不会被自己的否定前缀误伤(前缀窗口取的是命中词之前)。
64
+ const NEGATORS = ['不要', '不用', '别', '勿', '无需', '不必', '算了', '先不'];
65
+
66
+ // 同分时的偏向:动作类优先于只读类(「看看有什么能清理的」→ 清理,不是体检)。
67
+ const ACTION_KINDS = new Set(['clean', 'backup', 'disable', 'enable', 'knowledge', 'ack', 'unack']);
68
+
69
+ // 长词优先,避免「审计趋势」里的「趋势」抢在前面导致同义不同 Tab。
70
+ const TAB_WORDS = [
71
+ ['audit', ['审计趋势', '审计', '台账', '操作记录', '趋势']],
72
+ ['actions', ['动作中心', '动作']],
73
+ ['overview', ['总览', '首页']],
74
+ ];
75
+
76
+ function tabOf(text) {
77
+ for (const [tab, words] of TAB_WORDS) {
78
+ for (const word of words) if (text.includes(word)) return tab;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ const EXAMPLES = [
84
+ '清理一下缓存',
85
+ '给易失数据做个加密备份',
86
+ '停用 dsh-guizang-design',
87
+ '有什么问题吗',
88
+ '静音易失数据那条提醒',
89
+ ];
90
+
91
+ export function normalize(text) {
92
+ return String(text == null ? '' : text).trim().toLowerCase().replace(/\s+/g, ' ');
93
+ }
94
+
95
+ function negatedBefore(text, index) {
96
+ const window = text.slice(Math.max(0, index - 3), index);
97
+ return NEGATORS.some((n) => window.includes(n));
98
+ }
99
+
100
+ function collectHits(text) {
101
+ const hits = [];
102
+ for (const rule of RULES) {
103
+ for (const word of rule.words) {
104
+ let from = 0;
105
+ for (;;) {
106
+ const at = text.indexOf(word, from);
107
+ if (at < 0) break;
108
+ if (!negatedBefore(text, at)) hits.push({ kind: rule.kind, word, at, len: word.length });
109
+ from = at + word.length;
110
+ }
111
+ }
112
+ }
113
+ return hits;
114
+ }
115
+
116
+ // 打分:命中词长度求和。长词(「加密备份」)压过短词(「备份」),
117
+ // 这样「加密备份」不会被算成两次普通备份。
118
+ function rankByKind(hits) {
119
+ const scores = new Map();
120
+ for (const hit of hits) {
121
+ const cur = scores.get(hit.kind) || { score: 0, words: [] };
122
+ cur.score += hit.len;
123
+ cur.words.push(hit.word);
124
+ scores.set(hit.kind, cur);
125
+ }
126
+ let best = null;
127
+ for (const [kind, value] of scores) {
128
+ if (!best) { best = { kind, ...value }; continue; }
129
+ if (value.score > best.score) { best = { kind, ...value }; continue; }
130
+ if (value.score === best.score) {
131
+ // 同分:动作类赢只读类;同为动作类则真的歧义,交回去问。
132
+ if (ACTION_KINDS.has(kind) && !ACTION_KINDS.has(best.kind)) best = { kind, ...value };
133
+ }
134
+ }
135
+ return { best, ambiguous: [...scores.entries()].filter(([k, v]) => best && v.score === best.score && k !== best.kind) };
136
+ }
137
+
138
+ // 从一句话里挑出插件名:拿真实 bundle 列表做最长匹配,比正则猜要稳。
139
+ // 没给列表时退回一个保守的 token 形状(@scope/name 或 name),不猜相似度。
140
+ function pickPlugin(text, bundles) {
141
+ const list = Array.isArray(bundles) ? bundles : [];
142
+ let matched = null;
143
+ for (const entry of list) {
144
+ const name = String(entry && entry.name ? entry.name : '').toLowerCase();
145
+ if (!name || name.length < 3) continue;
146
+ if (!text.includes(name)) continue;
147
+ if (!matched || name.length > matched.name.length) {
148
+ matched = { name: entry.name, enabled: entry.enabled !== false, len: name.length };
149
+ }
150
+ }
151
+ if (matched) return { plugin: matched.name, existed: true, enabled: matched.enabled };
152
+ const token = /(?:^|\s)([@a-z0-9][\w./@-]{2,})/i.exec(text);
153
+ if (token && /[a-z]/i.test(token[1]) && token[1].includes('-')) {
154
+ return { plugin: token[1], existed: false, enabled: null };
155
+ }
156
+ return null;
157
+ }
158
+
159
+ function pickFinding(text, report) {
160
+ const findings = report && Array.isArray(report.findings) ? report.findings : [];
161
+ const pool = findings.filter((f) => !f.muted);
162
+ // 标题匹配取两档前缀:用户很少逐字念全文,通常只说头四个字
163
+ // (「静音易失数据」对「易失数据不在默认备份分区内」)。先长后短,
164
+ // 长前缀优先,避免短前缀把另一条抢先命中。
165
+ const hit = pool.find((f) => {
166
+ const title = String(f.title || '').toLowerCase();
167
+ if (!title) return false;
168
+ return text.includes(title.slice(0, 6)) || text.includes(title.slice(0, 4))
169
+ || text.includes(String(f.id || '').toLowerCase());
170
+ });
171
+ return hit || null;
172
+ }
173
+
174
+ function replyFor(kind, target) {
175
+ switch (kind) {
176
+ case 'goto':
177
+ return ['这就带你过去。'];
178
+ case 'clean':
179
+ return ['我理解成「清理纯缓存」。', '先给你出一份零写入预览:会删哪些、能回收多少,看清了再点执行。'];
180
+ case 'backup':
181
+ return ['我理解成「给易失数据做加密备份」。', '先出预览(分区与体积)。加密口令请在动作卡里填,不要发在聊天里。'];
182
+ case 'disable':
183
+ return ['我理解成「停用 ' + target + '」。',
184
+ '注意:停用要过归因门——只有最近一次崩溃点名了它、且那次崩溃发生在本实例启动之后,才会放行。'];
185
+ case 'enable':
186
+ return ['我理解成「启用 ' + target + '」。', '启用只对当前已停用的插件有意义,先出预览。'];
187
+ case 'knowledge':
188
+ return ['我理解成「把这次体检结论沉淀下来」。', '只增不改,写入本地知识文件;项目知识库仍要你确认。'];
189
+ case 'ack':
190
+ return ['我理解成「静音这条提醒」。', '静音只影响展示与排序,不改判定结论,随时能恢复。'];
191
+ case 'unack':
192
+ return ['我理解成「恢复这条提醒的显示」。'];
193
+ case 'report':
194
+ return ['这就给你重新体检一次。'];
195
+ default:
196
+ return [];
197
+ }
198
+ }
199
+
200
+ /**
201
+ * @param {string} raw 用户那句话
202
+ * @param {{bundles?: Array<{name:string, enabled?:boolean}>, report?: object}} ctx
203
+ */
204
+ export function parseIntent(raw, ctx) {
205
+ const options = ctx || {};
206
+ const text = normalize(raw);
207
+ if (!text) {
208
+ return { ok: true, intent: null, clarify: '先跟我说一句吧,比如「清理一下缓存」。', suggestions: EXAMPLES };
209
+ }
210
+
211
+ const hits = collectHits(text);
212
+ if (!hits.length) {
213
+ return {
214
+ ok: true, intent: null,
215
+ clarify: '这句我没听懂——我不猜。你可以换个说法,或者直接用下面的动作卡。',
216
+ suggestions: EXAMPLES,
217
+ };
218
+ }
219
+
220
+ const { best, ambiguous } = rankByKind(hits);
221
+ if (ambiguous && ambiguous.length) {
222
+ return {
223
+ ok: true, intent: null,
224
+ clarify: '这句里同时提到两件事(' + [best.kind, ...ambiguous.map(([k]) => k)].join(' / ')
225
+ + '),我不替你选。分开说一句就行。',
226
+ suggestions: EXAMPLES,
227
+ };
228
+ }
229
+
230
+ const intent = { kind: best.kind, matched: best.words, score: best.score, params: {} };
231
+
232
+ if (best.kind === 'goto') {
233
+ const tab = tabOf(text);
234
+ if (!tab) {
235
+ return {
236
+ ok: true, intent: null,
237
+ clarify: '要看哪一页?说「体检总览」「动作中心」或「审计趋势」都行。',
238
+ suggestions: EXAMPLES,
239
+ };
240
+ }
241
+ intent.params.tab = tab;
242
+ }
243
+
244
+ if (best.kind === 'disable' || best.kind === 'enable') {
245
+ const found = pickPlugin(text, options.bundles);
246
+ if (!found) {
247
+ return {
248
+ ok: true, intent: null,
249
+ clarify: '要' + (best.kind === 'disable' ? '停用' : '启用') + '哪个插件?把包名带上,比如「停用 dsh-example-plugin」。',
250
+ suggestions: EXAMPLES,
251
+ };
252
+ }
253
+ if (!found.existed) {
254
+ return {
255
+ ok: true, intent: null,
256
+ clarify: '我按形状猜到「' + found.plugin + '」,但它不在当前已装列表里,不敢照着停/启。请确认完整包名。',
257
+ suggestions: EXAMPLES,
258
+ };
259
+ }
260
+ intent.params.plugin = found.plugin;
261
+ if (best.kind === 'enable' && found.enabled) {
262
+ return {
263
+ ok: true, intent: null,
264
+ clarify: '「' + found.plugin + '」现在就是启用状态——启用动作对它没有意义。要停用它请说「停用 ' + found.plugin + '」。',
265
+ suggestions: EXAMPLES,
266
+ };
267
+ }
268
+ if (best.kind === 'disable' && !found.enabled) {
269
+ return {
270
+ ok: true, intent: null,
271
+ clarify: '「' + found.plugin + '」已经是停用状态了。要恢复请说「启用 ' + found.plugin + '」。',
272
+ suggestions: EXAMPLES,
273
+ };
274
+ }
275
+ }
276
+
277
+ if (best.kind === 'ack' || best.kind === 'unack') {
278
+ const finding = pickFinding(text, options.report);
279
+ if (!finding) {
280
+ return {
281
+ ok: true, intent: null,
282
+ clarify: '要' + (best.kind === 'ack' ? '静音' : '恢复') + '哪一条?说个关键词就行,比如「静音易失数据」。',
283
+ suggestions: EXAMPLES,
284
+ };
285
+ }
286
+ intent.params.id = finding.id;
287
+ intent.params.title = finding.title;
288
+ }
289
+
290
+ return {
291
+ ok: true,
292
+ intent,
293
+ reply: replyFor(best.kind, intent.params.plugin || ''),
294
+ suggestions: EXAMPLES,
295
+ };
296
+ }
297
+
298
+ export default parseIntent;
package/lib/models.js ADDED
@@ -0,0 +1,212 @@
1
+ // 模型健康:巡检、台账、只标不判死。
2
+ //
3
+ // 三件事,一个文件,因为它们共用同一套「判据」——判据分开写,三处口径迟早漂。
4
+ //
5
+ // 1. 哨兵(sentinel):只探默认模型和每个 provider 的头几个模型,不探全量。
6
+ // 232 个模型全探一遍要几十分钟,那不叫体检,那叫拿用户的机器跑压测。
7
+ // 2. 台账(ledger):每次巡检把「哪些 provider 现在就答不上来」记进 trend,
8
+ // 多次对比才能看出是偶发还是真的坏了。
9
+ // 3. 只标不判死:只有明确的「这个模型不存在」才算死。429/5xx/超时/401
10
+ // 都只是「此刻不行」,一律记 busy —— 把忙当死会把好模型从选择器里赶走,
11
+ // 比不巡检更糟。
12
+ //
13
+ // 本模块零外部依赖(只用宿主注入的 llm.stream),不 import 任何其它插件的内部
14
+ // 模块 —— 跨包版本漂移正是这个项目要防的头号问题。
15
+
16
+ /** 一次探测结果的有效期。和 dsh-freellmapi 的 PROBE_TTL_MS 同量级。 */
17
+ export const MODEL_VERDICT_TTL_MS = 3 * 60 * 60 * 1000;
18
+
19
+ /** 单次探测超时。免费模型经常几十秒才吐第一个字,10 秒会把它们全判死。 */
20
+ export const MODEL_PROBE_TIMEOUT_MS = 45_000;
21
+
22
+ /**
23
+ * 只有这两个状态码表示「上游确实不再提供这个模型」。
24
+ * 其余(429 配额、5xx 网关、401 凭据、超时、空响应)都是「此刻」,不是「事实」。
25
+ */
26
+ export const DEAD_STATUS = new Set([404, 422]);
27
+
28
+ const PROBE_PROMPT = 'Reply with exactly OK';
29
+
30
+ /** 把各种失败形态压成三态之一:ok / dead / busy。绝不返回第四态。 */
31
+ export function classifyProbe(err) {
32
+ if (!err) return 'ok';
33
+ const raw = err.status != null ? err.status : err.statusCode;
34
+ const status = Number(raw);
35
+ if (Number.isFinite(status)) return DEAD_STATUS.has(status) ? 'dead' : 'busy';
36
+ const text = String(err.code || err.message || err || '');
37
+ // 适配器常把状态码塞进 message:「404 model not found」
38
+ const m = /(?:^|\D)(404|422|429|401|403|5\d\d)(?:\D|$)/.exec(text);
39
+ if (m) return DEAD_STATUS.has(Number(m[1])) ? 'dead' : 'busy';
40
+ return 'busy';
41
+ }
42
+
43
+ /**
44
+ * 探一个模型:问它一句最短的话,看有没有字回来。
45
+ *
46
+ * 三种可见结局:
47
+ * ok 有非空回复
48
+ * busy 抛错(状态码决定 dead 还是 busy)或什么都没有返回
49
+ * 空回复单独处理:cline 的 nemotron 就出现过「一个字也不吐」,那是模型这次没
50
+ * 干活,不代表它不存在 —— 记 busy,不记 dead。
51
+ */
52
+ export async function probeModel(deps, target) {
53
+ const llm = deps && deps.llm;
54
+ const provider = target && target.provider;
55
+ const model = target && target.model;
56
+ const at = Date.now();
57
+ if (!llm || typeof llm.stream !== 'function') {
58
+ return { provider, model, verdict: 'busy', at, note: '宿主没有 llm 服务' };
59
+ }
60
+ if (!provider || !model) {
61
+ return { provider, model, verdict: 'busy', at, note: '缺少 provider/model' };
62
+ }
63
+ const timeoutMs = (target && target.timeoutMs) || MODEL_PROBE_TIMEOUT_MS;
64
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
65
+ const timer = controller ? setTimeout(() => { try { controller.abort(); } catch { /* noop */ } }, timeoutMs) : null;
66
+ let text = '';
67
+ let failure = null;
68
+ try {
69
+ for await (const chunk of llm.stream({
70
+ provider,
71
+ model,
72
+ messages: [{ role: 'user', content: [{ type: 'text', text: PROBE_PROMPT }] }],
73
+ maxTokens: 16,
74
+ signal: controller ? controller.signal : undefined,
75
+ })) {
76
+ if (!chunk || typeof chunk !== 'object') continue;
77
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') { text += chunk.text; continue; }
78
+ if (chunk.type === 'error') { failure = chunk.error || chunk; }
79
+ }
80
+ } catch (err) {
81
+ failure = err;
82
+ } finally {
83
+ if (timer) clearTimeout(timer);
84
+ }
85
+ const ms = Date.now() - at;
86
+ if (failure) {
87
+ return {
88
+ provider, model, verdict: classifyProbe(failure), at, ms,
89
+ note: String((failure && (failure.message || failure.code)) || 'stream failed').slice(0, 160),
90
+ };
91
+ }
92
+ if (text.trim().length === 0) {
93
+ // 200 但没有内容:不算死。给一个明确的 note,面板上能看出区别。
94
+ return { provider, model, verdict: 'busy', at, ms, note: '没有返回内容' };
95
+ }
96
+ return { provider, model, verdict: 'ok', at, ms };
97
+ }
98
+
99
+ /**
100
+ * 挑出这次要探哪些模型(哨兵集)。
101
+ *
102
+ * 规则:
103
+ * - 默认模型必探(它是「用户此刻真的在用」的那一个)
104
+ * - 每个 provider 前 perProvider 个(目录顺序即其推荐顺序)
105
+ * - 总量上限 maxTotal,防止某个 provider 有 30 个模型时把预算吃光
106
+ * 不去重同名跨 provider 的情况:同名不同 provider 是两个不同的服务。
107
+ */
108
+ export function planSentinel(catalog, options = {}) {
109
+ // 注意不能用 `Number(x) || 2`:0 是合法值(只探默认模型自己),
110
+ // 但 || 把 0 当 falsy 换成 2,设置里那个「设 0 = 只探默认模型」就永远是空话。
111
+ const rawPer = Number(options.perProvider);
112
+ const perProvider = Math.max(0, Number.isFinite(rawPer) ? rawPer : 2);
113
+ const maxTotal = Math.max(1, Number(options.maxTotal) || 12);
114
+ const out = [];
115
+ const seen = new Set();
116
+ const push = (provider, model) => {
117
+ if (!provider || !model) return;
118
+ const key = provider + '|' + model;
119
+ if (seen.has(key)) return;
120
+ seen.add(key);
121
+ out.push({ provider, model });
122
+ };
123
+ const def = catalog && catalog.default;
124
+ const providers = Array.isArray(catalog && catalog.providers) ? catalog.providers : [];
125
+ if (def && def.provider && def.model) push(def.provider, def.model);
126
+ for (const p of providers) {
127
+ const models = Array.isArray(p && p.models) ? p.models : [];
128
+ for (const m of models.slice(0, perProvider)) push(p.id, m && m.id);
129
+ if (out.length >= maxTotal) break;
130
+ }
131
+ return out.slice(0, maxTotal);
132
+ }
133
+
134
+ /**
135
+ * 汇总探测结果 + 与上一轮台账对比。
136
+ *
137
+ * 台账口径:per provider 记 ok/dead/busy 计数与「哪些模型明确死」。
138
+ * 只有当某个 provider 连续两轮都是 0 ok 且至少 1 dead 时,才算「整体不可用」——
139
+ * 单轮全 busy 更可能是网断了或者都在冷却,不该报。
140
+ */
141
+ export function summarize(probes, previous, now = new Date()) {
142
+ const list = Array.isArray(probes) ? probes.filter(Boolean) : [];
143
+ const byProvider = new Map();
144
+ for (const p of list) {
145
+ const key = String(p.provider || '');
146
+ if (!byProvider.has(key)) byProvider.set(key, { provider: key, ok: 0, dead: 0, busy: 0, deadModels: [] });
147
+ const row = byProvider.get(key);
148
+ if (p.verdict === 'ok') row.ok += 1;
149
+ else if (p.verdict === 'dead') { row.dead += 1; row.deadModels.push(p.model); }
150
+ else row.busy += 1;
151
+ }
152
+ const prevRows = new Map();
153
+ const prevList = previous && Array.isArray(previous.providers) ? previous.providers : [];
154
+ for (const r of prevList) if (r && r.provider) prevRows.set(String(r.provider), r);
155
+
156
+ const providers = Array.from(byProvider.values()).map((row) => {
157
+ const prev = prevRows.get(row.provider);
158
+ // 「上一轮也没一个能用」= 上一轮 ok === 0。曾经写成 dead === 0 是反的:
159
+ // 上一轮若真有一个 dead,反而被判成「不满足」,连续两轮全灭永远报不出来。
160
+ const prevOffline = !!(prev && prev.ok === 0);
161
+ return {
162
+ ...row,
163
+ // 两轮都没有一个能用的,且这轮有明确的死模型 —— 这才是「坏了」。
164
+ offlineTwoRounds: row.ok === 0 && row.dead > 0 && prevOffline,
165
+ at: now.toISOString ? now.toISOString() : String(now),
166
+ };
167
+ });
168
+
169
+ const okCount = list.filter((p) => p.verdict === 'ok').length;
170
+ const deadCount = list.filter((p) => p.verdict === 'dead').length;
171
+ const busyCount = list.filter((p) => p.verdict === 'busy').length;
172
+ const deadModels = list.filter((p) => p.verdict === 'dead')
173
+ .map((p) => ({ provider: p.provider, model: p.model, note: p.note || '', status: p.status }));
174
+
175
+ return {
176
+ probed: list.length,
177
+ ok: okCount,
178
+ dead: deadCount,
179
+ busy: busyCount,
180
+ deadModels,
181
+ providers,
182
+ at: now.toISOString ? now.toISOString() : String(now),
183
+ };
184
+ }
185
+
186
+ /** 默认模型的结论:ok / dead / busy / unprobed / missing。 */
187
+ export function defaultVerdict(probes, defaultSelection) {
188
+ if (!defaultSelection || !defaultSelection.provider || !defaultSelection.model) return { state: 'missing' };
189
+ const hit = (Array.isArray(probes) ? probes : []).find(
190
+ (p) => p && p.provider === defaultSelection.provider && p.model === defaultSelection.model,
191
+ );
192
+ if (!hit) return { state: 'unprobed' };
193
+ if (hit.verdict === 'ok') return { state: 'ok', ms: hit.ms };
194
+ if (hit.verdict === 'dead') return { state: 'dead', note: hit.note || '', status: hit.status };
195
+ return { state: 'busy', note: hit.note || '' };
196
+ }
197
+
198
+ /**
199
+ * 从哪里能换一个可用的默认模型。
200
+ *
201
+ * 不给「随便挑一个」:优先同一个 provider 里这轮探测 ok 的其它模型(换服务商
202
+ * 是更大的动作),其次别的 provider 里 ok 的。都没有就返回空,让面板说
203
+ * 「现在没有可推荐的替代」——这比推荐一个同样不可用的强。
204
+ */
205
+ export function suggestDefault(probes, current) {
206
+ const list = (Array.isArray(probes) ? probes : []).filter((p) => p && p.verdict === 'ok');
207
+ const sameProvider = list.filter((p) => p.provider === (current && current.provider) && p.model !== (current && current.model));
208
+ const pool = sameProvider.length > 0 ? sameProvider : list.filter((p) => p.model !== (current && current.model));
209
+ if (pool.length === 0) return null;
210
+ const pick = pool[0];
211
+ return { provider: pick.provider, model: pick.model, sameProvider: sameProvider.length > 0, ms: pick.ms };
212
+ }