dsh-model-pool 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,2847 @@
1
+ /**
2
+ * dsh-model-pool — Client half.
3
+ *
4
+ * One Settings section (`settings.section`, id `model-pool`, order 45) that
5
+ * edits the pool's strategy and failover behaviour and shows the aggregated
6
+ * model pool with per-route health.
7
+ *
8
+ * Reads come from this bundle's own HTTP routes (`GET /state`,
9
+ * `POST /refresh`, `POST /reset-health`). Writes go through the browser's
10
+ * `configForms` service — the sanctioned settings path — never through HTTP.
11
+ *
12
+ * Hand-written ModuleLoader bundle: no build step, and `react` is the only
13
+ * module required. Everything visual uses `--dsw-alias-*` tokens so the page
14
+ * follows the Harness theme.
15
+ *
16
+ * @module dsh-model-pool/client
17
+ */
18
+
19
+ window.__ModuleLoader__.load({
20
+ id: 'dsh-model-pool',
21
+ factory(require) {
22
+ const React = require('react');
23
+ const h = React.createElement;
24
+
25
+ /** Host patch entry id owned by this bundle; also the settings namespace. */
26
+ const ENTRY_ID = 'model-pool';
27
+ /** Locale namespace for this page's copy. */
28
+ const NS = 'model-pool';
29
+ /** Pool strategies, in picker order (`lib/strategies.js` STRATEGIES). */
30
+ const STRATEGIES = ['priority', 'round-robin', 'weighted-random', 'least-used', 'fastest', 'cheapest'];
31
+ /** Health states the page knows how to render. */
32
+ const STATE_IDS = ['ok', 'degraded', 'unavailable', 'unknown'];
33
+ /** Modality id for text input; the floor every route accepts. */
34
+ const TEXT_MODALITY = 'text';
35
+ /** Modality id the router filters on when a call carries an image. */
36
+ const IMAGE_MODALITY = 'image';
37
+ /** Locale key per input modality this page has copy for; others render raw. */
38
+ const MODALITY_KEYS = { text: 'modalityText', image: 'modalityImage' };
39
+ /** Fallbacks mirroring the Host Config defaults. */
40
+ const DEFAULT_COOLDOWN_MS = 60000;
41
+ const DEFAULT_MAX_ATTEMPTS = 3;
42
+ /** Calendar background-refresh cadence (§3.6), when the payload omits it. */
43
+ const DEFAULT_UPDATE_DAYS = 7;
44
+ /** Read timeout for the cached state route. */
45
+ const STATE_TIMEOUT_MS = 8000;
46
+ /** Longer budget for the two POSTs: they re-read provider catalogs. */
47
+ const ACTION_TIMEOUT_MS = 20000;
48
+
49
+ const EN = {
50
+ nav: 'Model Pool',
51
+ title: 'Model Pool',
52
+ intro:
53
+ 'Every registered provider is aggregated into one pool. Pick a pooled model in the composer and the strategy below chooses the concrete provider for each call.',
54
+ version: 'Version {version}',
55
+ refresh: 'Refresh',
56
+ refreshing: 'Refreshing…',
57
+ resetHealth: 'Reset health',
58
+ resetting: 'Resetting…',
59
+ loading: 'Loading pool state…',
60
+ loadFailed: 'Could not read the pool state.',
61
+ timeout: 'The request timed out.',
62
+ noSnapshot: 'The pool has not been read yet. Refresh to build it.',
63
+ catalogError: 'The catalog read failed: {message}',
64
+ emptyPool: 'No models are in the pool yet. Check the provider filters in the plugin configuration.',
65
+ takenAt: 'Read at {time}',
66
+ poolRoute: 'Virtual route: {provider} ({id})',
67
+ formLoading: 'Loading configuration…',
68
+ formUnavailable: 'This deployment does not serve the plugin configuration, so these controls are read-only.',
69
+ readOnly: 'This deployment stores settings read-only.',
70
+ saveFailed: 'The change was not saved.',
71
+ noticeRefreshed: 'Pool state refreshed.',
72
+ noticeHealthReset: 'Route health was reset.',
73
+ strategyTitle: 'Strategy',
74
+ strategyHint: 'How one pooled call picks its concrete provider.',
75
+ 'strategy.priority': 'Priority',
76
+ 'strategy.priority.desc': 'Try providers in the configured priority order, highest priority first.',
77
+ 'strategy.round-robin': 'Round-robin',
78
+ 'strategy.round-robin.desc': 'Rotate the starting provider on every call so load spreads evenly.',
79
+ 'strategy.weighted-random': 'Weighted random',
80
+ 'strategy.weighted-random.desc': 'Draw a provider at random, honouring each route weight.',
81
+ 'strategy.least-used': 'Least used',
82
+ 'strategy.least-used.desc': 'Prefer the provider that has served the fewest calls.',
83
+ 'strategy.fastest': 'Fastest',
84
+ 'strategy.fastest.desc': 'Prefer the provider with the lowest recent time to first chunk.',
85
+ 'strategy.cheapest': 'Cheapest',
86
+ 'strategy.cheapest.desc': 'Prefer the provider with the lowest declared cost hint.',
87
+ behaviourTitle: 'Behaviour',
88
+ behaviourHint: 'Failover and cooldown rules applied to every pooled call.',
89
+ fallback: 'Fail over to the next provider',
90
+ fallbackHint: 'When an attempt fails before producing output, try the next candidate.',
91
+ skipUnavailable: 'Skip unavailable routes',
92
+ skipUnavailableHint: 'Leave out routes that are still inside their cooldown window.',
93
+ sticky: 'Keep a session on one route',
94
+ stickyHint: 'A conversation keeps the provider it started on, so its prompt cache stays warm. A new conversation picks again with the strategy.',
95
+ cooldownMs: 'Cooldown (ms)',
96
+ cooldownMsHint: 'How long a failed route stays out of rotation.',
97
+ maxAttempts: 'Max attempts',
98
+ maxAttemptsHint: 'Upper bound on providers tried for one call.',
99
+ invalidNumber: 'Enter a whole number between {min} and {max}.',
100
+ poolTitle: 'Model pool',
101
+ poolSummary: '{models} models from {providers} providers',
102
+ dockRoute: 'Routed to',
103
+ dockEffortMapped: 'This route has no "{requested}" tier; it ran "{actual}".',
104
+ peakTitle: 'Peak and idle periods',
105
+ peakHint: 'The Priority strategy can rank providers differently inside and outside working hours. Everything outside the windows below is idle.',
106
+ peakNowPeak: 'Peak',
107
+ peakNowIdle: 'Idle',
108
+ peakNowDisabled: 'Period ranking is off, so every call keeps the plain provider priority.',
109
+ peakNoReading: 'The Host has not reported the current period yet.',
110
+ peakAt: '{clock} · {zone}',
111
+ peakNextLabel: 'Next change',
112
+ peakNextValue: '{time} → {period}',
113
+ peakHolidayWarning: 'Today is a statutory holiday: the whole day is idle.',
114
+ peakMakeupWarning: 'Today is a make-up workday, but the policy still treats it as idle.',
115
+ peakEnabled: 'Rank by period',
116
+ peakEnabledHint: 'Off means every call keeps the plain provider priority.',
117
+ peakTimeZone: 'Time zone',
118
+ peakTimeZoneHint: 'IANA zone the windows are written in, e.g. Asia/Shanghai. An unusable zone falls back to Beijing time.',
119
+ peakWindows: 'Peak windows',
120
+ peakWindowsNone: 'none, so the pool never reaches peak',
121
+ peakWindowsHint: 'One HH:MM-HH:MM range per line, in the time zone above. No line means the pool never reaches peak.',
122
+ peakWeekdays: 'Peak weekdays',
123
+ peakWeekdaysNone: 'none, so the pool never reaches peak',
124
+ peakWeekdaysHint: 'The weekdays the windows apply to. No day selected means the pool never reaches peak.',
125
+ peakMakeupWorkdays: 'Make-up workdays',
126
+ peakMakeupWorkdaysHint: 'One YYYY-MM-DD per line. A listed day counts as a working day even at the weekend.',
127
+ holidayCalendarTitle: 'Holiday calendar',
128
+ holidayCalendarHint: 'Statutory holidays and make-up workdays come from a maintained public dataset that this plugin fetches and caches. No one types dates here.',
129
+ holidayStatusOk: 'Synced',
130
+ holidayStatusStale: 'Stale',
131
+ holidayStatusNever: 'Not synced yet',
132
+ holidayStatusFailed: 'Sync failed',
133
+ holidayNoReading: 'The Host has not reported the calendar yet.',
134
+ holidayYearLabel: '{year}',
135
+ holidayCounts: '{holidays} holidays · {makeup} make-up workdays',
136
+ holidayCurrentPending: 'Not published yet',
137
+ holidayTotalsLabel: 'Cached totals',
138
+ holidaySyncError: 'The last sync failed: {message}',
139
+ holidayUpdated: 'Last updated',
140
+ holidayUpdatedNever: 'never',
141
+ holidayCadence: 'Checked every {days} day(s) in the background. Use Sync now to refresh immediately.',
142
+ holidaySourceLabel: 'Source',
143
+ holidayPapersLabel: 'Official notice',
144
+ holidaySyncNow: 'Sync now',
145
+ holidaySyncing: 'Syncing…',
146
+ noticeCalendarSynced: 'Holiday calendar synced.',
147
+ holidaySync: 'Keep the calendar up to date automatically',
148
+ holidaySyncHint: 'Off still uses the cached calendar, but nothing refreshes it.',
149
+ holidayAdvanced: 'Advanced: manual correction',
150
+ holidayOverride: 'Extra idle dates',
151
+ holidayOverrideHint: 'One YYYY-MM-DD per line, layered on top of the fetched calendar. For a one-off correction only; it survives every refresh.',
152
+ peakPeakOrder: 'Peak ranking',
153
+ peakPeakOrderHint: 'Highest first while the pool is at peak. Leave unset to inherit the provider priority.',
154
+ peakOffPeakOrder: 'Idle ranking',
155
+ peakOffPeakOrderHint: 'Highest first while the pool is idle. Leave unset to inherit the provider priority.',
156
+ peakOrderEmpty: 'No provider currently serves the pool.',
157
+ peakOrderReset: 'Clear ranking',
158
+ peakOrderSave: 'Save ranking',
159
+ peakOrderSaved: 'Period ranking saved.',
160
+ peakSeparator: ', ',
161
+ peakWeekdayRange: '{from}–{to}',
162
+ 'peakWeekday.mon': 'Mon',
163
+ 'peakWeekday.tue': 'Tue',
164
+ 'peakWeekday.wed': 'Wed',
165
+ 'peakWeekday.thu': 'Thu',
166
+ 'peakWeekday.fri': 'Fri',
167
+ 'peakWeekday.sat': 'Sat',
168
+ 'peakWeekday.sun': 'Sun',
169
+ priorityTitle: 'Provider priority',
170
+ priorityHint: 'Highest first. Used when the strategy is Priority. Only providers that currently serve the pool are listed; the rest follow in registration order.',
171
+ priorityEmpty: 'No provider currently serves the pool. Pool routes fall back to registration order.',
172
+ priorityReset: 'Reset to registration order',
173
+ priorityUp: 'Move up',
174
+ priorityDown: 'Move down',
175
+ priorityRemove: 'Remove',
176
+ priorityUnknown: 'Not registered right now',
177
+ prioritySave: 'Save priority',
178
+ prioritySaved: 'Provider priority saved.',
179
+ mergeTitle: 'Merged groups',
180
+ mergeHint: 'The same model often has a different id on each provider. A merged group lists those ids together and shows them as one row in the pool.',
181
+ mergeEmpty: 'No merged groups. Every provider id is its own row.',
182
+ mergeAdd: 'Add group',
183
+ mergeRemove: 'Remove',
184
+ mergeName: 'Display name',
185
+ mergeId: 'Pool id',
186
+ mergeModels: 'Model ids or patterns',
187
+ mergeModelsHint: 'One per line. Matches a concrete id, a provider/model pair, or the normalized id. * and ? wildcards allowed, e.g. deepseek-v4.1-flash*',
188
+ mergeMode: 'Merge into one row',
189
+ mergeModeHint: 'When on, the claimed routes leave their single-id rows. When off, this is an extra curated pool and the single-id rows stay.',
190
+ mergeClaimed: '{count} route(s)',
191
+ mergeNoMatch: 'No route matches these selectors yet.',
192
+ mergeSave: 'Save groups',
193
+ mergeSaved: 'Merged groups saved.',
194
+ colModel: 'Model',
195
+ colProviders: 'Providers',
196
+ colDepths: 'Depths',
197
+ colState: 'State',
198
+ providersCount: '{count} providers',
199
+ depthsCount: '{count} thinking depths',
200
+ 'state.ok': 'OK',
201
+ 'state.degraded': 'Degraded',
202
+ 'state.unavailable': 'Unavailable',
203
+ 'state.unknown': 'Unknown',
204
+ reason: 'Reason: {reason}',
205
+ latency: '{ms} ms to first chunk',
206
+ contextWindow: '{tokens} context',
207
+ weightLabel: 'weight {weight}',
208
+ defaultEffortTag: 'default',
209
+ modalityText: 'Text',
210
+ modalityImage: 'Image',
211
+ modalityTextOnlyTag: 'text only',
212
+ modalityRouteHint: 'Input this route accepts. Image-capable routes are the only ones a screenshot can reach.',
213
+ modalityRouteTextOnlyHint: 'This route does not accept image input. The pool still offers images when another candidate takes them, and then this route is skipped for that call.',
214
+ modalityPoolHint: 'This pooled model accepts image input: at least one candidate route can see a screenshot.',
215
+ noEfforts: 'No thinking depths',
216
+ noRoutes: 'This model has no candidate routes.',
217
+ failuresTitle: 'Provider catalog failures',
218
+ failuresHint: 'These providers could not be read and are missing from the pool.',
219
+ };
220
+
221
+ const ZH = {
222
+ nav: '模型池',
223
+ title: '模型池',
224
+ intro:
225
+ '所有已注册的提供方都会聚合进同一个模型池。在输入框中选择池内模型,下面的策略会为每次调用挑选具体的提供方。',
226
+ version: '版本 {version}',
227
+ refresh: '刷新',
228
+ refreshing: '正在刷新…',
229
+ resetHealth: '重置健康状态',
230
+ resetting: '正在重置…',
231
+ loading: '正在读取模型池状态…',
232
+ loadFailed: '无法读取模型池状态。',
233
+ timeout: '请求超时。',
234
+ noSnapshot: '模型池尚未读取,请点击刷新。',
235
+ catalogError: '读取模型目录失败:{message}',
236
+ emptyPool: '模型池中还没有模型。请检查插件配置中的提供方过滤条件。',
237
+ takenAt: '读取于 {time}',
238
+ poolRoute: '虚拟路由:{provider}({id})',
239
+ formLoading: '正在读取配置…',
240
+ formUnavailable: '当前部署未提供插件配置,这些控件为只读。',
241
+ readOnly: '当前部署的设置为只读。',
242
+ saveFailed: '设置未保存。',
243
+ noticeRefreshed: '模型池状态已刷新。',
244
+ noticeHealthReset: '路由健康状态已重置。',
245
+ strategyTitle: '策略',
246
+ strategyHint: '每次池调用如何挑选具体的提供方。',
247
+ 'strategy.priority': '优先级',
248
+ 'strategy.priority.desc': '按配置的优先级顺序尝试提供方,优先级最高的先试。',
249
+ 'strategy.round-robin': '轮询',
250
+ 'strategy.round-robin.desc': '每次调用轮换起始提供方,让负载均匀分布。',
251
+ 'strategy.weighted-random': '加权随机',
252
+ 'strategy.weighted-random.desc': '按每条路由的权重随机抽取提供方。',
253
+ 'strategy.least-used': '最少使用',
254
+ 'strategy.least-used.desc': '优先选择已服务调用次数最少的提供方。',
255
+ 'strategy.fastest': '最快',
256
+ 'strategy.fastest.desc': '优先选择最近首字延迟最低的提供方。',
257
+ 'strategy.cheapest': '最便宜',
258
+ 'strategy.cheapest.desc': '优先选择声明的成本提示最低的提供方。',
259
+ behaviourTitle: '行为',
260
+ behaviourHint: '作用于每次池调用的故障转移与冷却规则。',
261
+ fallback: '失败时切换到下一个提供方',
262
+ fallbackHint: '一次尝试在产出任何内容之前失败时,继续尝试下一个候选。',
263
+ skipUnavailable: '跳过不可用的路由',
264
+ skipUnavailableHint: '跳过仍在冷却窗口内的路由。',
265
+ sticky: '同一会话固定使用同一路由',
266
+ stickyHint: '一个对话会保持首次使用的提供商,让它的提示词缓存保持有效。新建对话时重新按策略选择。',
267
+ cooldownMs: '冷却时间(毫秒)',
268
+ cooldownMsHint: '失败的路由退出轮转的时长。',
269
+ maxAttempts: '最大尝试次数',
270
+ maxAttemptsHint: '一次调用最多尝试的提供方数量。',
271
+ invalidNumber: '请输入 {min} 到 {max} 之间的整数。',
272
+ poolTitle: '模型池',
273
+ poolSummary: '{models} 个模型,来自 {providers} 个提供方',
274
+ dockRoute: '实际路由',
275
+ dockEffortMapped: '该线路没有「{requested}」档位,实际以「{actual}」运行。',
276
+ peakTitle: '高峰与空闲时段',
277
+ peakHint: '「优先级」策略可以在工作时段内外使用不同的提供方排序。下面时段之外的时间都算空闲。',
278
+ peakNowPeak: '高峰',
279
+ peakNowIdle: '空闲',
280
+ peakNowDisabled: '时段排序已关闭,每次调用都沿用原有的提供方优先级。',
281
+ peakNoReading: '宿主尚未上报当前时段。',
282
+ peakAt: '{clock} · {zone}',
283
+ peakNextLabel: '下次切换',
284
+ peakNextValue: '{time} → {period}',
285
+ peakHolidayWarning: '今天是法定节假日,全天按空闲处理。',
286
+ peakMakeupWarning: '今天是调休上班日,但当前策略仍按空闲处理。',
287
+ peakEnabled: '按高峰与空闲时段排序',
288
+ peakEnabledHint: '关闭后每次调用都沿用原有的提供方优先级。',
289
+ peakTimeZone: '时区',
290
+ peakTimeZoneHint: '时段所用的 IANA 时区,例如 Asia/Shanghai。无法识别时回退到北京时间。',
291
+ peakWindows: '高峰时段',
292
+ peakWindowsNone: '未配置,模型池不会进入高峰',
293
+ peakWindowsHint: '每行一个 HH:MM-HH:MM 区间,使用上面的时区。留空表示模型池不会进入高峰。',
294
+ peakWeekdays: '高峰星期',
295
+ peakWeekdaysNone: '未选择,模型池不会进入高峰',
296
+ peakWeekdaysHint: '上述时段生效的星期。一天都不选表示模型池不会进入高峰。',
297
+ peakMakeupWorkdays: '调休上班日',
298
+ peakMakeupWorkdaysHint: '每行一个 YYYY-MM-DD,列出的日期即使遇到周末也按工作日处理。',
299
+ holidayCalendarTitle: '节假日日历',
300
+ holidayCalendarHint: '法定节假日与调休上班日由插件自动获取并缓存自公开维护的数据,无需手动录入日期。',
301
+ holidayStatusOk: '已同步',
302
+ holidayStatusStale: '待更新',
303
+ holidayStatusNever: '尚未同步',
304
+ holidayStatusFailed: '同步失败',
305
+ holidayNoReading: '宿主尚未上报日历信息。',
306
+ holidayYearLabel: '{year} 年',
307
+ holidayCounts: '{holidays} 天节假日 · {makeup} 天调休上班',
308
+ holidayCurrentPending: '尚未发布',
309
+ holidayTotalsLabel: '已缓存合计',
310
+ holidaySyncError: '上次同步失败:{message}',
311
+ holidayUpdated: '最近更新',
312
+ holidayUpdatedNever: '从未',
313
+ holidayCadence: '后台每 {days} 天检查一次,需要立即更新可点「立即同步」。',
314
+ holidaySourceLabel: '数据来源',
315
+ holidayPapersLabel: '官方通知',
316
+ holidaySyncNow: '立即同步',
317
+ holidaySyncing: '正在同步…',
318
+ noticeCalendarSynced: '节假日日历已同步。',
319
+ holidaySync: '自动保持日历更新',
320
+ holidaySyncHint: '关闭后仍会使用已缓存的日历,但不再自动刷新。',
321
+ holidayAdvanced: '高级:手动修正',
322
+ holidayOverride: '额外空闲日期',
323
+ holidayOverrideHint: '每行一个 YYYY-MM-DD,叠加在自动获取的日历之上。仅用于一次性修正,刷新时不会被覆盖。',
324
+ peakPeakOrder: '高峰排序',
325
+ peakPeakOrderHint: '模型池处于高峰时越靠上优先级越高。留空则沿用提供方优先级。',
326
+ peakOffPeakOrder: '空闲排序',
327
+ peakOffPeakOrderHint: '模型池处于空闲时越靠上优先级越高。留空则沿用提供方优先级。',
328
+ peakOrderEmpty: '当前没有提供方为模型池提供模型。',
329
+ peakOrderReset: '清空排序',
330
+ peakOrderSave: '保存排序',
331
+ peakOrderSaved: '时段排序已保存。',
332
+ peakSeparator: '、',
333
+ peakWeekdayRange: '{from} 至 {to}',
334
+ 'peakWeekday.mon': '周一',
335
+ 'peakWeekday.tue': '周二',
336
+ 'peakWeekday.wed': '周三',
337
+ 'peakWeekday.thu': '周四',
338
+ 'peakWeekday.fri': '周五',
339
+ 'peakWeekday.sat': '周六',
340
+ 'peakWeekday.sun': '周日',
341
+ priorityTitle: '供应商优先级',
342
+ priorityHint: '越靠上优先级越高,供「优先级」策略使用。这里只列出当前为模型池提供模型的供应商,其余按注册顺序跟在后面。',
343
+ priorityEmpty: '当前没有供应商为模型池提供模型。模型池将按注册顺序路由。',
344
+ priorityReset: '恢复注册顺序',
345
+ priorityUp: '上移',
346
+ priorityDown: '下移',
347
+ priorityRemove: '移除',
348
+ priorityUnknown: '当前未注册',
349
+ prioritySave: '保存优先级',
350
+ prioritySaved: '供应商优先级已保存。',
351
+ mergeTitle: '合并组',
352
+ mergeHint: '同一个模型在不同提供方下的编号往往不同。合并组把这些编号列在一起,在模型池里只显示为一行。',
353
+ mergeEmpty: '暂无合并组。每个提供方的编号各占一行。',
354
+ mergeAdd: '新增合并组',
355
+ mergeRemove: '删除',
356
+ mergeName: '显示名称',
357
+ mergeId: '池内编号',
358
+ mergeModels: '模型编号或匹配模式',
359
+ mergeModelsHint: '每行一个。可匹配具体编号、提供方/模型组合,或归一化后的编号。支持 * 和 ? 通配符,例如 deepseek-v4.1-flash*',
360
+ mergeMode: '合并为一行',
361
+ mergeModeHint: '开启时,被合并的路由会从各自的单编号行中移走;关闭时仅新增一个精选池,单编号行保留。',
362
+ mergeClaimed: '{count} 条路由',
363
+ mergeNoMatch: '目前没有路由匹配这些选择器。',
364
+ mergeSave: '保存合并组',
365
+ mergeSaved: '合并组已保存。',
366
+ colModel: '模型',
367
+ colProviders: '提供方',
368
+ colDepths: '深度',
369
+ colState: '状态',
370
+ providersCount: '{count} 个提供方',
371
+ depthsCount: '{count} 档思考深度',
372
+ 'state.ok': '正常',
373
+ 'state.degraded': '降级',
374
+ 'state.unavailable': '不可用',
375
+ 'state.unknown': '未知',
376
+ reason: '原因:{reason}',
377
+ latency: '首字延迟 {ms} 毫秒',
378
+ contextWindow: '上下文 {tokens}',
379
+ weightLabel: '权重 {weight}',
380
+ defaultEffortTag: '默认',
381
+ modalityText: '文本',
382
+ modalityImage: '图片',
383
+ modalityTextOnlyTag: '仅文本',
384
+ modalityRouteHint: '该路由接受的输入类型。只有接受图片的路由才能收到截图。',
385
+ modalityRouteTextOnlyHint: '该路由不接受图片输入。只要还有其他候选接受图片,模型池仍会对外提供图片能力,此时本次调用会跳过该路由。',
386
+ modalityPoolHint: '该池内模型接受图片输入:至少有一条候选路由能识别截图。',
387
+ noEfforts: '没有思考深度',
388
+ noRoutes: '该模型没有候选路由。',
389
+ failuresTitle: '提供方目录读取失败',
390
+ failuresHint: '以下提供方读取失败,未出现在模型池中。',
391
+ };
392
+
393
+ // ── page styles ──────────────────────────────────────────────────────────
394
+ // Colour comes only from --dsw-alias-* tokens, so light/dark and any
395
+ // third-party theme work without extra wiring.
396
+ const CSS = [
397
+ '.mp_page{display:flex;flex-direction:column;gap:14px;max-width:820px;width:100%;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}',
398
+ '.mp_head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap}',
399
+ '.mp_headText{min-width:0}',
400
+ '.mp_title{margin:0;font-size:16px;line-height:24px;font-weight:600;color:var(--dsw-alias-label-primary)}',
401
+ '.mp_intro{margin:4px 0 0;max-width:560px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}',
402
+ '.mp_actions{display:flex;align-items:center;gap:8px;flex:none}',
403
+ '.mp_btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;height:30px;padding:0 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:18px;cursor:pointer;white-space:nowrap}',
404
+ '.mp_btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}',
405
+ '.mp_btn:disabled{opacity:.5;cursor:default}',
406
+ '.mp_btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
407
+ '.mp_card{display:flex;flex-direction:column;gap:10px;padding:14px 16px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}',
408
+ '.mp_cardHead{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap}',
409
+ '.mp_cardTitle{margin:0;font-size:14px;line-height:22px;font-weight:600;color:var(--dsw-alias-label-primary)}',
410
+ '.mp_hint{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
411
+ '.mp_error{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary);word-break:break-word}',
412
+ '.mp_notice{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-success-primary)}',
413
+ '.mp_empty{margin:0;padding:10px 0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}',
414
+ '.mp_formGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px 20px}',
415
+ '.mp_field{display:flex;flex-direction:column;gap:4px;min-width:0}',
416
+ '.mp_label{font-size:12px;line-height:18px;font-weight:500;color:var(--dsw-alias-label-secondary)}',
417
+ '.mp_control{box-sizing:border-box;height:30px;padding:0 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:18px}',
418
+ '.mp_control:disabled{opacity:.6;cursor:default}',
419
+ '.mp_control:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
420
+ '.mp_select{max-width:320px}',
421
+ '.mp_controlNum{max-width:160px}',
422
+ '.mp_checks{display:flex;flex-wrap:wrap;gap:16px 24px}',
423
+ '.mp_mergeGroup{display:flex;flex-direction:column;gap:6px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-base)}',
424
+ '.mp_mergeHead{display:grid;grid-template-columns:18px minmax(0,1fr) minmax(0,1fr) auto;align-items:center;gap:8px}',
425
+ '.mp_mergeIndex{display:flex;align-items:center;justify-content:center;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}',
426
+ '.mp_mergeHead .mp_label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}',
427
+ '.mp_mergeHead .mp_field{gap:0}',
428
+ '.mp_mergeBody{display:flex;flex-direction:column;gap:6px}',
429
+ '.mp_textarea{height:auto;min-height:34px;padding:6px 8px;resize:vertical;line-height:18px;font-size:12px}',
430
+ '.mp_mergeActions{display:flex;align-items:flex-end;gap:8px;flex-wrap:wrap}',
431
+ '.mp_prioList{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none}',
432
+ '.mp_prioRow,.mp_periodRow{display:grid;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:8px}',
433
+ '.mp_prioIndex{font-size:11px;line-height:16px;text-align:right;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}',
434
+ '.mp_prioField{display:flex;align-items:center;gap:8px;min-width:0}',
435
+ '.mp_prioField .mp_input{flex:1 1 auto;min-width:0}',
436
+ '.mp_prioWarn{flex:none;font-size:11px;line-height:16px;color:var(--dsw-alias-state-warn-primary)}',
437
+ '.mp_prioActions{display:flex;align-items:center;gap:4px}',
438
+ '.mp_prioName{display:flex;align-items:baseline;gap:8px;min-width:0;flex-wrap:wrap}',
439
+ '.mp_prioLabel{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis}',
440
+ '.mp_prioId{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis}',
441
+ '.mp_prioAddField{flex:0 1 260px}',
442
+ '.mp_prioAddField .mp_select{max-width:none}',
443
+ // Peak/idle period card: a live reading on top, then the editable policy.
444
+ '.mp_periodHead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
445
+ '.mp_periodBadge{display:inline-flex;align-items:center;gap:6px;padding:1px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);white-space:nowrap}',
446
+ '.mp_periodBadge_peak{color:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}',
447
+ '.mp_periodDot{display:inline-block;flex:none;width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-label-secondary)}',
448
+ '.mp_periodDot_peak{background:var(--dsw-alias-brand-primary)}',
449
+ '.mp_periodClock{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}',
450
+ '.mp_periodFacts{display:flex;flex-direction:column;gap:3px;margin:0;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
451
+ '.mp_periodFact{display:flex;gap:8px;flex-wrap:wrap}',
452
+ '.mp_periodFactKey{flex:none;min-width:88px;color:var(--dsw-alias-label-secondary)}',
453
+ '.mp_periodFactValue{min-width:0;color:var(--dsw-alias-label-primary);word-break:break-word}',
454
+ '.mp_periodWarn{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-warn-primary)}',
455
+ '.mp_periodGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}',
456
+ '.mp_periodWeekdays{display:flex;flex-wrap:wrap;gap:4px 14px}',
457
+ '.mp_periodBlock{display:flex;flex-direction:column;gap:6px;padding:10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-base)}',
458
+ '.mp_periodBlockHead{display:flex;align-items:baseline;justify-content:space-between;gap:8px;flex-wrap:wrap}',
459
+ '.mp_periodBlockTitle{margin:0;font-size:12px;line-height:18px;font-weight:600;color:var(--dsw-alias-label-primary)}',
460
+ // Matches the shape and type scale of the shipped statistics pills
461
+ // (`anchor > pill > label`): 12px, tabular numerals, one soft pill.
462
+ '.mp_dock{display:inline-flex;min-width:0;font-size:calc(var(--dsh-content-font-size-secondary,13px) - 1px);line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px))}',
463
+ '.mp_dockPill{box-sizing:border-box;display:inline-flex;align-items:center;gap:6px;max-width:100%;padding:1px 8px;border:0;border-radius:var(--dsw-radius-sm);background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;font-variant-numeric:tabular-nums;line-height:inherit;white-space:nowrap}',
464
+ '.mp_dockSeg{min-width:0;overflow:hidden;text-overflow:ellipsis}',
465
+ '.mp_dockRoute{color:var(--dsw-alias-label-secondary)}',
466
+ '.mp_dockSep{flex:none;color:var(--dsw-alias-label-secondary);opacity:.55}',
467
+ '.mp_btnIcon{width:26px;height:26px;padding:0;font-size:13px;line-height:1}',
468
+ '.mp_btnPrimary{border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-bg-base)}',
469
+ '.mp_btnPrimary:hover:not(:disabled){filter:brightness(1.06)}',
470
+ '.mp_btnDanger{color:var(--dsw-alias-state-error-primary)}',
471
+ '.mp_btnDanger:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}',
472
+ '.mp_check{display:flex;align-items:flex-start;gap:8px;min-width:0}',
473
+ '.mp_checks>.mp_check{min-width:220px;flex:1 1 220px}',
474
+ '.mp_check input{width:14px;height:14px;margin:3px 0 0;accent-color:var(--dsw-alias-brand-primary);flex:none}',
475
+ '.mp_checkText{min-width:0}',
476
+ '.mp_checkLabel{display:block;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);cursor:pointer}',
477
+ '.mp_thead{padding:2px 4px 4px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:11px;line-height:16px;letter-spacing:.04em;text-transform:uppercase;color:var(--dsw-alias-label-secondary)}',
478
+ '.mp_grid{display:grid;grid-template-columns:minmax(0,1fr) 76px 68px 116px 18px;align-items:center;gap:10px}',
479
+ '.mp_models{display:flex;flex-direction:column;margin:0;padding:0;list-style:none}',
480
+ '.mp_model{border-bottom:1px solid var(--dsw-alias-border-l1)}',
481
+ '.mp_model:last-child{border-bottom:none}',
482
+ '.mp_row{box-sizing:border-box;display:grid;width:100%;margin:0;padding:9px 4px;border:0;border-radius:8px;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer}',
483
+ '.mp_row:hover{background:var(--dsw-alias-bg-layer-2)}',
484
+ '.mp_row:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}',
485
+ '.mp_modelName{display:flex;flex-direction:column;gap:1px;min-width:0}',
486
+ '.mp_name{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis}',
487
+ '.mp_nameRow{display:flex;align-items:center;gap:6px;min-width:0}',
488
+ '.mp_nameRow>.mp_name{min-width:0;flex:1 1 auto}',
489
+ '.mp_nameFlag{flex:none;font-size:10px;line-height:14px;padding:0 5px}',
490
+ '.mp_id{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis}',
491
+ '.mp_num{font-size:12px;line-height:18px;text-align:right;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}',
492
+ '.mp_badge{display:inline-flex;align-items:center;gap:6px;padding:1px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);white-space:nowrap}',
493
+ '.mp_badge_ok{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}',
494
+ '.mp_badge_degraded{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}',
495
+ '.mp_badge_unavailable{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}',
496
+ '.mp_dot{display:inline-block;flex:none;width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-state-idle-primary)}',
497
+ '.mp_dot_ok{background:var(--dsw-alias-state-success-primary)}',
498
+ '.mp_dot_degraded{background:var(--dsw-alias-state-warn-primary)}',
499
+ '.mp_dot_unavailable{background:var(--dsw-alias-state-error-primary)}',
500
+ '.mp_dot_unknown{background:var(--dsw-alias-state-idle-primary)}',
501
+ '.mp_chevron{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);text-align:center}',
502
+ '.mp_routes{display:flex;flex-direction:column;gap:6px;padding:2px 4px 12px 4px}',
503
+ '.mp_route{display:grid;grid-template-columns:10px minmax(0,1fr) auto;gap:10px;align-items:start;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2)}',
504
+ '.mp_route .mp_dot{margin-top:6px}',
505
+ '.mp_routeBody{display:flex;flex-direction:column;gap:3px;min-width:0}',
506
+ '.mp_routeTitle{font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);word-break:break-word}',
507
+ '.mp_routeProvider{font-weight:500}',
508
+ '.mp_routeIds{margin-top:1px;font-size:11px;line-height:15px;color:var(--dsw-alias-label-secondary);word-break:break-all}',
509
+ '.mp_routeMeta{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);word-break:break-word}',
510
+ '.mp_routeReason{font-size:11px;line-height:16px;color:var(--dsw-alias-state-error-primary);word-break:break-word}',
511
+ '.mp_routeState{white-space:nowrap}',
512
+ '.mp_chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px}',
513
+ '.mp_chip{display:inline-flex;align-items:center;gap:4px;padding:0 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l1);font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}',
514
+ '.mp_chipDefault{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}',
515
+ '.mp_chipTag{font-size:10px;line-height:14px;opacity:.85}',
516
+ '.mp_chipWarn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary)}',
517
+ '.mp_chipImage{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary)}',
518
+ '.mp_mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}',
519
+ '.mp_meta{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}',
520
+ '.mp_failures{display:flex;flex-direction:column;gap:4px;margin:0;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2)}',
521
+ '.mp_failure{font-size:11px;line-height:16px;color:var(--dsw-alias-state-error-primary);word-break:break-word}',
522
+ '@media (max-width:560px){.mp_grid{grid-template-columns:minmax(0,1fr) 58px 52px 96px 14px}.mp_actions{width:100%}.mp_btn{flex:1}}',
523
+ ].join('\n');
524
+
525
+ /** Marks the one injected stylesheet so a remount never stacks another copy. */
526
+ const STYLE_TAG_ID = 'dsh-model-pool';
527
+
528
+ /**
529
+ * Install the stylesheet once per document.
530
+ *
531
+ * The Settings page and the composer dock are different slots, so each can
532
+ * mount without the other. Injecting at the document level (rather than from
533
+ * inside one component's tree) is what lets the dock render styled even when
534
+ * Settings was never opened.
535
+ * @returns {void}
536
+ */
537
+ function installStyles() {
538
+ if (typeof document === 'undefined' || document.head === undefined || document.head === null) return;
539
+ if (document.querySelector(`style[data-plugin="${STYLE_TAG_ID}"]`) !== null) return;
540
+ const tag = document.createElement('style');
541
+ tag.dataset.plugin = STYLE_TAG_ID;
542
+ tag.textContent = CSS;
543
+ document.head.appendChild(tag);
544
+ }
545
+
546
+ // ── same-origin HTTP helpers ─────────────────────────────────────────────
547
+ // Resolved against the PAGE base, not an origin-absolute path: under a
548
+ // reverse-proxy prefix a leading slash would drop the prefix. Falls back to
549
+ // the root path when `document` (or a usable base) is absent.
550
+ const API = (() => {
551
+ try {
552
+ return new URL('api/model-pool', document.baseURI).pathname;
553
+ } catch {
554
+ return '/api/model-pool';
555
+ }
556
+ })();
557
+
558
+ /**
559
+ * One JSON request against this plugin's own API.
560
+ * @param {string} path - path below `/api/model-pool`.
561
+ * @param {object} [options] - `timeout` plus fetch options.
562
+ * @returns {Promise<object>} the parsed JSON payload.
563
+ */
564
+ async function api(path, options) {
565
+ const settings = options || {};
566
+ const timeout = typeof settings.timeout === 'number' ? settings.timeout : STATE_TIMEOUT_MS;
567
+ const rest = { ...settings };
568
+ delete rest.timeout;
569
+ const controller = new AbortController();
570
+ const timer = setTimeout(() => controller.abort(), timeout);
571
+ try {
572
+ const response = await fetch(`${API}${path}`, {
573
+ ...rest,
574
+ redirect: 'error',
575
+ credentials: 'same-origin',
576
+ headers: { accept: 'application/json', ...(rest.headers || {}) },
577
+ signal: controller.signal,
578
+ });
579
+ const text = await response.text();
580
+ let payload;
581
+ try {
582
+ payload = text === '' ? {} : JSON.parse(text);
583
+ } catch {
584
+ payload = { error: { message: text.slice(0, 300) } };
585
+ }
586
+ if (payload === null || typeof payload !== 'object') payload = { value: payload };
587
+ if (!response.ok) {
588
+ const failure = payload.error;
589
+ const reason =
590
+ typeof failure === 'string'
591
+ ? failure
592
+ : failure !== null && typeof failure === 'object' && typeof failure.message === 'string'
593
+ ? failure.message
594
+ : '';
595
+ const error = new Error(reason || `HTTP ${response.status}`);
596
+ // Keep the status so callers can distinguish "no such route" from a
597
+ // real failure without parsing a localized server message.
598
+ error.status = response.status;
599
+ throw error;
600
+ }
601
+ return payload;
602
+ } finally {
603
+ clearTimeout(timer);
604
+ }
605
+ }
606
+
607
+ /**
608
+ * POST one of the action routes (no request body).
609
+ * @param {string} path - path below `/api/model-pool`.
610
+ * @param {number} timeout - abort budget in milliseconds.
611
+ * @returns {Promise<object>} the parsed JSON payload.
612
+ */
613
+ function post(path, timeout) {
614
+ return api(path, { method: 'POST', timeout });
615
+ }
616
+
617
+ /**
618
+ * Whether a rejection means "this deployment does not serve that route".
619
+ * @param {unknown} cause - thrown value.
620
+ * @returns {boolean} true for an HTTP 404/405 answer.
621
+ */
622
+ function isMissingRoute(cause) {
623
+ if (cause === null || typeof cause !== 'object') return false;
624
+ if (cause.status === 404 || cause.status === 405) return true;
625
+ const message = typeof cause.message === 'string' ? cause.message : '';
626
+ return /\bHTTP (?:404|405)\b/.test(message);
627
+ }
628
+
629
+ /**
630
+ * Human-readable message for an unknown rejection.
631
+ * @param {unknown} cause - thrown value.
632
+ * @returns {string} the message.
633
+ */
634
+ function messageOf(cause) {
635
+ if (cause && typeof cause === 'object' && typeof cause.message === 'string' && cause.message !== '') return cause.message;
636
+ return String(cause);
637
+ }
638
+
639
+ /**
640
+ * Plain JSON copy of a value, so nothing non-serializable reaches the Host.
641
+ * @param {unknown} value - candidate value.
642
+ * @returns {unknown} a JSON round-tripped value.
643
+ */
644
+ function jsonValue(value) {
645
+ try {
646
+ const encoded = JSON.stringify(value);
647
+ return encoded === undefined ? null : JSON.parse(encoded);
648
+ } catch {
649
+ return value;
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Read a finite number, falling back when the field is absent or malformed.
655
+ * @param {unknown} value - candidate value.
656
+ * @param {number} fallback - value to use otherwise.
657
+ * @returns {number} the resolved number.
658
+ */
659
+ function numberOr(value, fallback) {
660
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
661
+ }
662
+
663
+ // ── pool data helpers ────────────────────────────────────────────────────
664
+
665
+ /**
666
+ * Key for one concrete route.
667
+ * @param {string} provider - provider id.
668
+ * @param {string} model - concrete model id.
669
+ * @returns {string} the composite key.
670
+ */
671
+ function routeKey(provider, model) {
672
+ return `${provider}\u0000${model}`;
673
+ }
674
+
675
+ /**
676
+ * Index the health payload by `provider + model`.
677
+ * Accepts `{ entries: [...] }`, a bare array, or a `provider/model` map.
678
+ * @param {unknown} raw - the `health` member of the state payload.
679
+ * @returns {Map<string, object>} health records by route key.
680
+ */
681
+ function healthIndex(raw) {
682
+ const index = new Map();
683
+ const add = (entry) => {
684
+ if (entry === null || typeof entry !== 'object') return;
685
+ if (typeof entry.provider !== 'string' || typeof entry.model !== 'string') return;
686
+ index.set(routeKey(entry.provider, entry.model), entry);
687
+ };
688
+ if (Array.isArray(raw)) {
689
+ for (const entry of raw) add(entry);
690
+ } else if (raw !== null && typeof raw === 'object') {
691
+ if (Array.isArray(raw.entries)) {
692
+ for (const entry of raw.entries) add(entry);
693
+ } else {
694
+ for (const entry of Object.values(raw)) add(entry);
695
+ }
696
+ }
697
+ return index;
698
+ }
699
+
700
+ /**
701
+ * Current state of one route.
702
+ * Derives from the counters when a record carries no usable `state`, so a
703
+ * stale registry projection still renders sensibly.
704
+ * @param {object|undefined} entry - health record.
705
+ * @param {number} now - current clock reading.
706
+ * @returns {'ok'|'degraded'|'unavailable'|'unknown'} the route state.
707
+ */
708
+ function routeState(entry, now) {
709
+ if (entry === undefined || entry === null) return 'unknown';
710
+ const declared = STATE_IDS.indexOf(entry.state) >= 0 ? entry.state : '';
711
+ const failures = numberOr(entry.failures, 0);
712
+ if (failures > 0) {
713
+ if (Number.isFinite(entry.cooldownUntil)) return entry.cooldownUntil > now ? 'unavailable' : 'degraded';
714
+ return declared === 'unavailable' || declared === 'degraded' ? declared : 'degraded';
715
+ }
716
+ if (declared === 'ok' || numberOr(entry.successes, 0) > 0) return 'ok';
717
+ return declared === 'unavailable' || declared === 'degraded' ? declared : 'unknown';
718
+ }
719
+
720
+ /**
721
+ * Aggregate state of a pool model across its candidate routes.
722
+ * @param {object[]} routes - candidate routes.
723
+ * @param {Map<string, object>} index - health index.
724
+ * @param {number} now - current clock reading.
725
+ * @returns {'ok'|'degraded'|'unavailable'|'unknown'} the aggregate state.
726
+ */
727
+ function aggregateState(routes, index, now) {
728
+ if (!Array.isArray(routes) || routes.length === 0) return 'unknown';
729
+ let ok = 0;
730
+ let degraded = 0;
731
+ let unavailable = 0;
732
+ for (const route of routes) {
733
+ if (route === null || typeof route !== 'object') continue;
734
+ const entry = index.get(routeKey(route.provider, route.model));
735
+ const state = routeState(entry, now);
736
+ if (state === 'ok') ok += 1;
737
+ else if (state === 'degraded') degraded += 1;
738
+ else if (state === 'unavailable') unavailable += 1;
739
+ }
740
+ if (unavailable === routes.length) return 'unavailable';
741
+ if (unavailable > 0 || degraded > 0) return 'degraded';
742
+ if (ok > 0) return 'ok';
743
+ return 'unknown';
744
+ }
745
+
746
+ /**
747
+ * Distinct providers behind a model's routes.
748
+ * @param {object[]} routes - candidate routes.
749
+ * @returns {number} the provider count.
750
+ */
751
+ function providerCount(routes) {
752
+ const seen = new Set();
753
+ for (const route of Array.isArray(routes) ? routes : []) {
754
+ if (route !== null && typeof route === 'object' && typeof route.provider === 'string') seen.add(route.provider);
755
+ }
756
+ return seen.size;
757
+ }
758
+
759
+ /**
760
+ * Number of thinking depths the pool model advertises.
761
+ * @param {object} model - pool model.
762
+ * @returns {number} the effort count.
763
+ */
764
+ function depthCount(model) {
765
+ const efforts = model && model.reasoning && Array.isArray(model.reasoning.efforts) ? model.reasoning.efforts : [];
766
+ return efforts.length;
767
+ }
768
+
769
+ /**
770
+ * The input modalities one route declares, normalized for display.
771
+ * Mirrors `lib/capabilities.js`: text is the floor every route accepts, so
772
+ * an undeclared route (or one that only names `image`) still reads as text.
773
+ * @param {object} route - a pool route, or a pool model for its union.
774
+ * @returns {string[]} normalized modality ids, always containing `text`.
775
+ */
776
+ function declaredModalities(route) {
777
+ const raw = route !== null && typeof route === 'object' ? route.inputModalities : undefined;
778
+ const out = [];
779
+ if (Array.isArray(raw)) {
780
+ for (const entry of raw) {
781
+ if (typeof entry !== 'string') continue;
782
+ const modality = entry.trim().toLowerCase();
783
+ if (modality === '' || out.includes(modality)) continue;
784
+ out.push(modality);
785
+ }
786
+ }
787
+ return out.includes(TEXT_MODALITY) ? out : [TEXT_MODALITY, ...out];
788
+ }
789
+
790
+ /**
791
+ * Whether a route (or a pool model) accepts image input.
792
+ * The same `includes('image')` rule the router filters on, so the chip and
793
+ * the routing decision can never disagree.
794
+ * @param {object} route - a pool route, or a pool model for its union.
795
+ * @returns {boolean} true when image input is advertised.
796
+ */
797
+ function acceptsImages(route) {
798
+ return declaredModalities(route).includes(IMAGE_MODALITY);
799
+ }
800
+
801
+ /**
802
+ * Localized label of one input modality.
803
+ * @param {Function} t - bound translator.
804
+ * @param {string} modality - normalized modality id.
805
+ * @returns {string} the label, or the raw id when this page has no copy.
806
+ */
807
+ function modalityLabel(t, modality) {
808
+ const key = MODALITY_KEYS[modality];
809
+ return key === undefined ? modality : t(key);
810
+ }
811
+
812
+ /**
813
+ * Compact context-window label.
814
+ * @param {number} tokens - window size in tokens.
815
+ * @returns {string} e.g. `128K`, `1M`, or the raw count.
816
+ */
817
+ function formatWindow(tokens) {
818
+ const n = Number(tokens);
819
+ if (!Number.isFinite(n) || n <= 0) return '';
820
+ if (n >= 1000000) return `${Math.round(n / 100000) / 10}M`;
821
+ if (n >= 1000) return `${Math.round(n / 100) / 10}K`;
822
+ return String(n);
823
+ }
824
+
825
+ /**
826
+ * DOM-safe id fragment for `aria-controls`.
827
+ * @param {unknown} value - model id.
828
+ * @returns {string} the fragment.
829
+ */
830
+ function idFragment(value) {
831
+ return String(value === undefined || value === null ? '' : value).replace(/[^A-Za-z0-9_-]/g, '-');
832
+ }
833
+
834
+ // ── peak / idle period helpers ───────────────────────────────────────────
835
+
836
+ /** ISO weekdays the peak policy can name, Monday first. */
837
+ const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
838
+ /** Locale key fragment per ISO weekday, in `WEEKDAYS` order. */
839
+ const WEEKDAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
840
+
841
+ /**
842
+ * Localized name of one ISO weekday.
843
+ * @param {Function} t - bound translator.
844
+ * @param {number} weekday - ISO weekday, 1 = Monday.
845
+ * @returns {string} the weekday name, or the bare number when unknown.
846
+ */
847
+ function weekdayName(t, weekday) {
848
+ const key = WEEKDAY_KEYS[weekday - 1];
849
+ return key === undefined ? String(weekday) : t(`peakWeekday.${key}`);
850
+ }
851
+
852
+ /**
853
+ * Human text for a weekday set, collapsing consecutive runs.
854
+ *
855
+ * `[1,2,3,4,5]` reads as "Mon–Fri" rather than five comma-separated names,
856
+ * which is how the policy is actually stated; a gap starts a new run.
857
+ * @param {Function} t - bound translator.
858
+ * @param {number[]} weekdays - ISO weekdays, any order.
859
+ * @returns {string} the joined text, or '' for an empty set.
860
+ */
861
+ function describeWeekdays(t, weekdays) {
862
+ const days = (Array.isArray(weekdays) ? weekdays : []).filter((day) => WEEKDAYS.indexOf(day) >= 0).sort((left, right) => left - right);
863
+ if (days.length === 0) return '';
864
+ const parts = [];
865
+ let start = days[0];
866
+ let previous = days[0];
867
+ for (const day of days.slice(1)) {
868
+ if (day === previous + 1) {
869
+ previous = day;
870
+ continue;
871
+ }
872
+ parts.push(runLabel(t, start, previous));
873
+ start = day;
874
+ previous = day;
875
+ }
876
+ parts.push(runLabel(t, start, previous));
877
+ return parts.join(t('peakSeparator'));
878
+ }
879
+
880
+ /**
881
+ * Label for one run of consecutive weekdays.
882
+ * @param {Function} t - bound translator.
883
+ * @param {number} from - first ISO weekday of the run.
884
+ * @param {number} to - last ISO weekday of the run.
885
+ * @returns {string} one name for a single day, a range otherwise.
886
+ */
887
+ function runLabel(t, from, to) {
888
+ if (from === to) return weekdayName(t, from);
889
+ return t('peakWeekdayRange', { from: weekdayName(t, from), to: weekdayName(t, to) });
890
+ }
891
+
892
+ /**
893
+ * Wall-clock time of day from a period payload.
894
+ * `minuteOfDay` is already the reading in the policy's own zone, so this
895
+ * never re-derives the zone from the browser.
896
+ * @param {object} period - `describePeriod` payload.
897
+ * @returns {string} `HH:MM`.
898
+ */
899
+ function clockOf(period) {
900
+ const minuteOfDay = Number.isFinite(period.minuteOfDay) ? period.minuteOfDay : 0;
901
+ const hour = Math.floor(minuteOfDay / 60);
902
+ const minute = minuteOfDay % 60;
903
+ return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
904
+ }
905
+
906
+ /**
907
+ * Format one instant in the policy's time zone.
908
+ * Showing the browser's own zone beside a Beijing schedule would invite a
909
+ * wrong reading, so the configured zone is used when `Intl` can honour it.
910
+ * @param {number} at - epoch milliseconds.
911
+ * @param {string} timeZone - IANA zone name.
912
+ * @returns {string} a short localized date and time.
913
+ */
914
+ function zonedClock(at, timeZone) {
915
+ const date = new Date(at);
916
+ const local = () => date.toLocaleString();
917
+ if (typeof timeZone !== 'string' || timeZone === '') return local();
918
+ if (typeof Intl === 'undefined' || Intl === null || typeof Intl.DateTimeFormat !== 'function') return local();
919
+ try {
920
+ return new Intl.DateTimeFormat(undefined, { timeZone, dateStyle: 'short', timeStyle: 'short' }).format(date);
921
+ } catch {
922
+ return local();
923
+ }
924
+ }
925
+
926
+ // ── small controls ───────────────────────────────────────────────────────
927
+
928
+ /**
929
+ * State pill: coloured dot plus localized label.
930
+ * @param {object} props - `state` and `t`.
931
+ * @returns {object} the React element.
932
+ */
933
+ function StateBadge(props) {
934
+ const state = STATE_IDS.indexOf(props.state) >= 0 ? props.state : 'unknown';
935
+ const t = props.t;
936
+ return h(
937
+ 'span',
938
+ { className: `mp_badge mp_badge_${state}` },
939
+ h('span', { className: `mp_dot mp_dot_${state}`, 'aria-hidden': 'true' }),
940
+ h('span', null, t(`state.${state}`)),
941
+ );
942
+ }
943
+
944
+ /**
945
+ * Labelled text input that commits on blur or Enter.
946
+ * The draft keeps typing local; the committed value only travels upward on
947
+ * blur/Enter, so the Host is never written per keystroke.
948
+ * @param {object} props - id, label, hint, value, disabled, placeholder,
949
+ * `onCommit`.
950
+ * @returns {object} the React element.
951
+ */
952
+ function TextField(props) {
953
+ const { id, label, hint, value, disabled, placeholder, onCommit } = props;
954
+ const [draft, setDraft] = React.useState(String(value === undefined || value === null ? '' : value));
955
+ const committed = String(value === undefined || value === null ? '' : value);
956
+ React.useEffect(() => {
957
+ setDraft(committed);
958
+ }, [committed]);
959
+ const commit = () => {
960
+ if (draft !== committed) onCommit(draft);
961
+ };
962
+ return h(
963
+ 'label',
964
+ { className: 'mp_field', htmlFor: id },
965
+ h('span', { className: 'mp_label' }, label),
966
+ h('input', {
967
+ id,
968
+ type: 'text',
969
+ className: 'mp_control mp_input',
970
+ value: draft,
971
+ disabled: disabled === true,
972
+ placeholder,
973
+ onChange: (event) => setDraft(event.target.value),
974
+ onBlur: commit,
975
+ onKeyDown: (event) => {
976
+ if (event.key === 'Enter') {
977
+ event.preventDefault();
978
+ event.currentTarget.blur();
979
+ }
980
+ },
981
+ }),
982
+ hint ? h('span', { className: 'mp_hint' }, hint) : null,
983
+ );
984
+ }
985
+
986
+ /**
987
+ * Labelled checkbox with an explanatory hint.
988
+ * @param {object} props - id, checked, disabled, label, hint, onChange.
989
+ * @returns {object} the React element.
990
+ */
991
+ function CheckField(props) {
992
+ const id = props.id;
993
+ return h(
994
+ 'div',
995
+ { className: 'mp_check' },
996
+ h('input', {
997
+ id,
998
+ type: 'checkbox',
999
+ checked: props.checked === true,
1000
+ disabled: props.disabled === true,
1001
+ onChange: (event) => props.onChange(event.target.checked === true),
1002
+ }),
1003
+ h(
1004
+ 'div',
1005
+ { className: 'mp_checkText' },
1006
+ h('label', { className: 'mp_checkLabel', htmlFor: id }, props.label),
1007
+ props.hint ? h('p', { className: 'mp_hint' }, props.hint) : null,
1008
+ ),
1009
+ );
1010
+ }
1011
+
1012
+ /**
1013
+ * Whole-number field that commits on blur or Enter.
1014
+ * The draft keeps typing local until it parses, so a half-typed number never
1015
+ * becomes a Host write.
1016
+ * @param {object} props - id, label, hint, value, min, max, step, disabled,
1017
+ * `onCommit`, `t`.
1018
+ * @returns {object} the React element.
1019
+ */
1020
+ function NumberField(props) {
1021
+ const { id, label, hint, value, min, max, step, disabled, onCommit, t } = props;
1022
+ const [draft, setDraft] = React.useState(String(value));
1023
+ const [invalid, setInvalid] = React.useState(false);
1024
+ React.useEffect(() => {
1025
+ setDraft(String(value));
1026
+ setInvalid(false);
1027
+ }, [value]);
1028
+ /**
1029
+ * Commit the field value. The live input value is read from the event when
1030
+ * one is available, so a blur in the same tick as the edit still sees it.
1031
+ * @param {object} [event] - the blur/key event, if any.
1032
+ */
1033
+ const commit = (event) => {
1034
+ const target = event && event.currentTarget;
1035
+ const raw = target && typeof target.value === 'string' ? target.value : draft;
1036
+ const parsed = Number.parseInt(raw, 10);
1037
+ if (!Number.isFinite(parsed)) {
1038
+ setInvalid(true);
1039
+ return;
1040
+ }
1041
+ const clamped = Math.min(max, Math.max(min, parsed));
1042
+ setInvalid(false);
1043
+ setDraft(String(clamped));
1044
+ if (clamped !== value) onCommit(clamped);
1045
+ };
1046
+ return h(
1047
+ 'div',
1048
+ { className: 'mp_field' },
1049
+ h('label', { className: 'mp_label', htmlFor: id }, label),
1050
+ h('input', {
1051
+ id,
1052
+ className: 'mp_control mp_controlNum',
1053
+ type: 'number',
1054
+ inputMode: 'numeric',
1055
+ min,
1056
+ max,
1057
+ step,
1058
+ value: draft,
1059
+ disabled: disabled === true,
1060
+ 'aria-invalid': invalid ? 'true' : undefined,
1061
+ onChange: (event) => setDraft(event.target.value),
1062
+ onBlur: commit,
1063
+ onKeyDown: (event) => {
1064
+ if (event.key === 'Enter') event.currentTarget.blur();
1065
+ },
1066
+ }),
1067
+ invalid ? h('p', { className: 'mp_error', role: 'alert' }, t('invalidNumber', { min, max })) : h('p', { className: 'mp_hint' }, hint),
1068
+ );
1069
+ }
1070
+
1071
+ /**
1072
+ * One candidate route inside an expanded pool model.
1073
+ * @param {object} props - route, health entry, t, now.
1074
+ * @returns {object} the React element.
1075
+ */
1076
+ function RouteRow(props) {
1077
+ const { entry, t, now } = props;
1078
+ const route = props.route !== null && typeof props.route === 'object' ? props.route : {};
1079
+ const state = routeState(entry, now);
1080
+ const reasoning = route.reasoning !== null && typeof route.reasoning === 'object' ? route.reasoning : {};
1081
+ const efforts = Array.isArray(reasoning.efforts) ? reasoning.efforts.filter((effort) => effort !== null && typeof effort === 'object' && typeof effort.id === 'string') : [];
1082
+ const defaultEffort = typeof reasoning.defaultEffort === 'string' ? reasoning.defaultEffort : '';
1083
+ // What this concrete route accepts. The candidates behind one pool model do
1084
+ // not agree here — that divergence is exactly why a route that cannot see an
1085
+ // image is called out: the pool promises images as soon as one candidate
1086
+ // takes them, so a text-only route is the one a screenshot never reaches.
1087
+ const modalities = declaredModalities(route);
1088
+ const imageCapable = acceptsImages(route);
1089
+ const meta = [];
1090
+ if (Number.isFinite(route.contextWindow)) meta.push(t('contextWindow', { tokens: formatWindow(route.contextWindow) }));
1091
+ if (Number.isFinite(route.weight) && route.weight > 1) meta.push(t('weightLabel', { weight: route.weight }));
1092
+ if (entry && Number.isFinite(entry.latencyMs)) meta.push(t('latency', { ms: Math.round(entry.latencyMs) }));
1093
+ const reason = state !== 'ok' && entry && typeof entry.reason === 'string' && entry.reason !== '' ? entry.reason : '';
1094
+ // Names carry the row; the raw route ids sit underneath in small mono text.
1095
+ const providerLabel = typeof route.providerName === 'string' && route.providerName !== '' ? route.providerName : String(route.provider ?? '');
1096
+ const modelLabel = typeof route.name === 'string' && route.name !== '' ? route.name : String(route.model ?? '');
1097
+ return h(
1098
+ 'div',
1099
+ { className: 'mp_route' },
1100
+ h('span', { className: `mp_dot mp_dot_${state}`, 'aria-hidden': 'true' }),
1101
+ h(
1102
+ 'div',
1103
+ { className: 'mp_routeBody' },
1104
+ h(
1105
+ 'div',
1106
+ { className: 'mp_routeTitle' },
1107
+ h('span', { className: 'mp_routeProvider' }, providerLabel),
1108
+ ' · ',
1109
+ h('span', { className: 'mp_routeModel' }, modelLabel),
1110
+ ),
1111
+ h('div', { className: 'mp_routeIds mp_mono' }, `${String(route.provider)} · ${String(route.model)}`),
1112
+ meta.length > 0 ? h('div', { className: 'mp_routeMeta' }, meta.join(' · ')) : null,
1113
+ reason !== '' ? h('div', { className: 'mp_routeReason' }, t('reason', { reason })) : null,
1114
+ h(
1115
+ 'div',
1116
+ { className: 'mp_chips', 'data-modalities': modalities.join(' '), title: t(imageCapable ? 'modalityRouteHint' : 'modalityRouteTextOnlyHint') },
1117
+ modalities.map((modality) =>
1118
+ h(
1119
+ 'span',
1120
+ {
1121
+ key: modality,
1122
+ className: modality === IMAGE_MODALITY ? 'mp_chip mp_chipImage' : 'mp_chip',
1123
+ },
1124
+ modalityLabel(t, modality),
1125
+ ),
1126
+ ),
1127
+ imageCapable ? null : h('span', { className: 'mp_chip mp_chipWarn' }, t('modalityTextOnlyTag')),
1128
+ ),
1129
+ h(
1130
+ 'div',
1131
+ { className: 'mp_chips' },
1132
+ efforts.length === 0
1133
+ ? h('span', { className: 'mp_routeMeta' }, t('noEfforts'))
1134
+ : efforts.map((effort) => {
1135
+ const isDefault = defaultEffort !== '' && effort.id === defaultEffort;
1136
+ return h(
1137
+ 'span',
1138
+ {
1139
+ key: effort.id,
1140
+ className: isDefault ? 'mp_chip mp_chipDefault' : 'mp_chip',
1141
+ title: typeof effort.description === 'string' && effort.description !== '' ? effort.description : undefined,
1142
+ },
1143
+ typeof effort.name === 'string' && effort.name !== '' ? effort.name : effort.id,
1144
+ isDefault ? h('span', { className: 'mp_chipTag' }, t('defaultEffortTag')) : null,
1145
+ );
1146
+ }),
1147
+ ),
1148
+ ),
1149
+ h('div', { className: 'mp_routeState' }, h('span', { className: 'mp_routeMeta' }, t(`state.${state}`))),
1150
+ );
1151
+ }
1152
+
1153
+ /**
1154
+ * One pool-model row plus its expandable route list.
1155
+ * @param {object} props - model, health index, t, now, expanded, onToggle.
1156
+ * @returns {object} the React element.
1157
+ */
1158
+ function ModelRow(props) {
1159
+ const { index, t, now, expanded, onToggle, rowKey } = props;
1160
+ const model = props.model !== null && typeof props.model === 'object' ? props.model : {};
1161
+ const routes = Array.isArray(model.routes) ? model.routes : [];
1162
+ const state = aggregateState(routes, index, now);
1163
+ const panelId = `mp-routes-${idFragment(rowKey)}`;
1164
+ const name = typeof model.name === 'string' && model.name !== '' ? model.name : String(model.id === undefined ? '' : model.id);
1165
+ const showId = typeof model.id === 'string' && model.id !== '' && model.id !== name;
1166
+ // The pool advertises the union of its candidates, so this marker answers
1167
+ // the picker-side question — is this the pooled model to choose for a
1168
+ // screenshot? — without expanding the row to inspect each route.
1169
+ const imageCapable = acceptsImages(model);
1170
+ return h(
1171
+ 'li',
1172
+ { className: 'mp_model' },
1173
+ h(
1174
+ 'button',
1175
+ {
1176
+ type: 'button',
1177
+ className: 'mp_row mp_grid',
1178
+ 'aria-expanded': expanded ? 'true' : 'false',
1179
+ 'aria-controls': expanded ? panelId : undefined,
1180
+ onClick: onToggle,
1181
+ },
1182
+ h(
1183
+ 'span',
1184
+ { className: 'mp_modelName' },
1185
+ h(
1186
+ 'span',
1187
+ { className: 'mp_nameRow' },
1188
+ h('span', { className: 'mp_name' }, name),
1189
+ imageCapable ? h('span', { className: 'mp_chip mp_chipImage mp_nameFlag', title: t('modalityPoolHint') }, t('modalityImage')) : null,
1190
+ ),
1191
+ showId ? h('span', { className: 'mp_id mp_mono' }, model.id) : null,
1192
+ ),
1193
+ h('span', { className: 'mp_num', title: t('providersCount', { count: providerCount(routes) }) }, String(providerCount(routes))),
1194
+ h('span', { className: 'mp_num', title: t('depthsCount', { count: depthCount(model) }) }, String(depthCount(model))),
1195
+ h('span', null, h(StateBadge, { state, t })),
1196
+ h('span', { className: 'mp_chevron', 'aria-hidden': 'true' }, expanded ? '▾' : '▸'),
1197
+ ),
1198
+ expanded
1199
+ ? h(
1200
+ 'div',
1201
+ { className: 'mp_routes', id: panelId },
1202
+ routes.length === 0
1203
+ ? h('p', { className: 'mp_hint' }, t('noRoutes'))
1204
+ : routes.map((route, position) =>
1205
+ h(RouteRow, {
1206
+ key: `${String(route.provider)}/${String(route.model)}/${position}`,
1207
+ route,
1208
+ entry: index.get(routeKey(route.provider, route.model)),
1209
+ t,
1210
+ now,
1211
+ }),
1212
+ ),
1213
+ )
1214
+ : null,
1215
+ );
1216
+ }
1217
+
1218
+ /**
1219
+ * One row of the provider-priority list: an editable id plus move/remove.
1220
+ * A provider that is not currently registered is still shown (flagged) so a
1221
+ * typo or a temporarily missing provider is visible instead of silently lost.
1222
+ * @param {object} props - `index`, `value`, `total`, `known`, `t`, `disabled`,
1223
+ * `onEdit`, `onMove`, `onRemove`, `rowClass`.
1224
+ * @returns {object} the React element.
1225
+ */
1226
+ function ProviderPriorityRow(props) {
1227
+ const { index, value, total, name, known, t, disabled, onMove, onRemove, rowClass } = props;
1228
+ const id = typeof value === 'string' ? value : '';
1229
+ // An id the registry no longer reports is kept on the list but flagged, so a
1230
+ // provider that is temporarily unmounted is not silently dropped.
1231
+ const missing = known !== undefined && known.has(id) === false;
1232
+ const label = typeof name === 'string' && name !== '' ? name : id;
1233
+ // The row class is a prop so a second list on the same page can carry its
1234
+ // own marker: the provider-priority card identifies its rows by this class,
1235
+ // and sharing it would make the two lists indistinguishable.
1236
+ return h(
1237
+ 'li',
1238
+ { className: rowClass === undefined ? 'mp_prioRow' : rowClass },
1239
+ h('span', { className: 'mp_prioIndex', 'aria-hidden': 'true' }, String(index + 1)),
1240
+ h(
1241
+ 'span',
1242
+ { className: 'mp_prioName', title: id === label ? undefined : `${label} (${id})` },
1243
+ h('span', { className: 'mp_prioLabel' }, label),
1244
+ id !== label ? h('span', { className: 'mp_prioId mp_mono' }, id) : null,
1245
+ missing ? h('span', { className: 'mp_prioWarn' }, t('priorityUnknown')) : null,
1246
+ ),
1247
+ h(
1248
+ 'span',
1249
+ { className: 'mp_prioActions' },
1250
+ h(
1251
+ 'button',
1252
+ {
1253
+ type: 'button',
1254
+ className: 'mp_btn mp_btnIcon',
1255
+ disabled,
1256
+ onClick: () => onMove(index, -1),
1257
+ 'aria-label': `${t('priorityUp')}: ${label}`,
1258
+ title: t('priorityUp'),
1259
+ },
1260
+ '↑',
1261
+ ),
1262
+ h(
1263
+ 'button',
1264
+ {
1265
+ type: 'button',
1266
+ className: 'mp_btn mp_btnIcon',
1267
+ disabled,
1268
+ onClick: () => onMove(index, 1),
1269
+ 'aria-label': `${t('priorityDown')}: ${label}`,
1270
+ title: t('priorityDown'),
1271
+ },
1272
+ '↓',
1273
+ ),
1274
+ h(
1275
+ 'button',
1276
+ {
1277
+ type: 'button',
1278
+ className: 'mp_btn mp_btnIcon mp_btnDanger',
1279
+ disabled,
1280
+ onClick: () => onRemove(index),
1281
+ 'aria-label': `${t('priorityRemove')}: ${label}`,
1282
+ title: t('priorityRemove'),
1283
+ },
1284
+ '×',
1285
+ ),
1286
+ ),
1287
+ );
1288
+ }
1289
+
1290
+ /**
1291
+ * One editable merged group.
1292
+ * Local textarea state is kept per group so typing stays responsive; the
1293
+ * value is split into the `models` array only on change, and the parent
1294
+ * decides when to persist the whole list.
1295
+ * @param {object} props - `index`, `group`, `t`, `disabled`, `onChange`, `onRemove`.
1296
+ * @returns {object} the React element.
1297
+ */
1298
+ function MergedGroupRow(props) {
1299
+ const { index, t, disabled, onChange, onRemove } = props;
1300
+ const group = props.group !== null && typeof props.group === 'object' ? props.group : {};
1301
+ const models = Array.isArray(group.models) ? group.models.filter((entry) => typeof entry === 'string') : [];
1302
+ const [text, setText] = React.useState(() => models.join('\n'));
1303
+ const lastModels = React.useRef(models.join('\n'));
1304
+ // Re-sync when the stored value changes from outside (a save round-trip or
1305
+ // a reset), but never while the user is mid-edit in this textarea.
1306
+ const incoming = models.join('\n');
1307
+ React.useEffect(() => {
1308
+ if (incoming !== lastModels.current) {
1309
+ lastModels.current = incoming;
1310
+ setText(incoming);
1311
+ }
1312
+ }, [incoming]);
1313
+ const label = typeof group.name === 'string' && group.name !== '' ? group.name : String(group.id === undefined ? '' : group.id);
1314
+ return h(
1315
+ 'div',
1316
+ { className: 'mp_mergeGroup' },
1317
+ h(
1318
+ 'div',
1319
+ { className: 'mp_mergeHead' },
1320
+ h('span', { className: 'mp_mergeIndex', 'aria-hidden': 'true' }, String(index + 1)),
1321
+ h(TextField, {
1322
+ id: `mp-merge-name-${index}`,
1323
+ label: t('mergeName'),
1324
+ value: typeof group.name === 'string' ? group.name : '',
1325
+ disabled,
1326
+ onCommit: (next) => onChange({ name: next }),
1327
+ t,
1328
+ }),
1329
+ h(TextField, {
1330
+ id: `mp-merge-id-${index}`,
1331
+ label: t('mergeId'),
1332
+ value: typeof group.id === 'string' ? group.id : '',
1333
+ disabled,
1334
+ onCommit: (next) => onChange({ id: next }),
1335
+ t,
1336
+ }),
1337
+ h(
1338
+ 'button',
1339
+ {
1340
+ type: 'button',
1341
+ className: 'mp_btn mp_btnIcon mp_btnDanger',
1342
+ disabled,
1343
+ onClick: onRemove,
1344
+ 'aria-label': `${t('mergeRemove')}: ${label}`,
1345
+ title: t('mergeRemove'),
1346
+ },
1347
+ '×',
1348
+ ),
1349
+ ),
1350
+ h(
1351
+ 'div',
1352
+ { className: 'mp_mergeBody' },
1353
+ h('textarea', {
1354
+ id: `mp-merge-models-${index}`,
1355
+ className: 'mp_control mp_textarea mp_mono',
1356
+ rows: Math.min(5, Math.max(2, models.length)),
1357
+ value: text,
1358
+ disabled,
1359
+ placeholder: t('mergeModelsHint'),
1360
+ 'aria-label': t('mergeModels'),
1361
+ title: t('mergeModelsHint'),
1362
+ onChange: (event) => {
1363
+ const next = event.target.value;
1364
+ setText(next);
1365
+ const parsed = next
1366
+ .split('\n')
1367
+ .map((line) => line.trim())
1368
+ .filter((line) => line !== '');
1369
+ lastModels.current = parsed.join('\n');
1370
+ onChange({ models: parsed });
1371
+ },
1372
+ }),
1373
+ h(CheckField, {
1374
+ id: `mp-merge-mode-${index}`,
1375
+ checked: group.merge !== false,
1376
+ disabled,
1377
+ label: t('mergeMode'),
1378
+ hint: t('mergeModeHint'),
1379
+ onChange: (next) => onChange({ merge: next }),
1380
+ }),
1381
+ ),
1382
+ );
1383
+ }
1384
+
1385
+ /**
1386
+ * The live peak/idle reading.
1387
+ *
1388
+ * Everything here comes from `/state.period`, which the Host derives from the
1389
+ * same `describePeriod` call the router uses. The card therefore explains
1390
+ * routing rather than approximating it in the browser.
1391
+ * @param {object} props - `period` payload and `t`.
1392
+ * @returns {object|null} the React element, or null without a payload.
1393
+ */
1394
+ function PeriodSummary(props) {
1395
+ const { period, t } = props;
1396
+ if (period === null || typeof period !== 'object') return h('p', { className: 'mp_hint' }, t('peakNoReading'));
1397
+ const peak = period.period === 'peak';
1398
+ const timeZone = typeof period.timeZone === 'string' && period.timeZone !== '' ? period.timeZone : '';
1399
+ const date = typeof period.date === 'string' && period.date !== '' ? period.date : '';
1400
+ const clock = [date, clockOf(period)].filter((part) => part !== '').join(' ');
1401
+ const windows = (Array.isArray(period.windows) ? period.windows : [])
1402
+ .filter((window) => window !== null && typeof window === 'object' && typeof window.label === 'string')
1403
+ .map((window) => window.label);
1404
+ const weekdays = describeWeekdays(t, period.weekdays);
1405
+ const facts = [
1406
+ { key: 'peakWindows', value: windows.length === 0 ? t('peakWindowsNone') : windows.join(t('peakSeparator')) },
1407
+ { key: 'peakWeekdays', value: weekdays === '' ? t('peakWeekdaysNone') : weekdays },
1408
+ ];
1409
+ // The next flip is only known while the feature is enabled and a boundary
1410
+ // exists inside the module's lookahead; absent means "nothing to say".
1411
+ if (Number.isFinite(period.nextChangeAt)) {
1412
+ facts.push({
1413
+ key: 'peakNextLabel',
1414
+ value: t('peakNextValue', {
1415
+ time: zonedClock(period.nextChangeAt, timeZone),
1416
+ period: period.nextPeriod === 'peak' ? t('peakNowPeak') : t('peakNowIdle'),
1417
+ }),
1418
+ });
1419
+ }
1420
+ const warning = period.holiday === true
1421
+ ? t('peakHolidayWarning')
1422
+ : period.makeupWorkday === true && period.period !== 'peak'
1423
+ ? t('peakMakeupWarning')
1424
+ : '';
1425
+ return h(
1426
+ React.Fragment,
1427
+ null,
1428
+ h(
1429
+ 'div',
1430
+ { className: 'mp_periodHead' },
1431
+ h(
1432
+ 'span',
1433
+ { className: peak ? 'mp_periodBadge mp_periodBadge_peak' : 'mp_periodBadge', 'data-period': peak ? 'peak' : 'offPeak' },
1434
+ h('span', { className: peak ? 'mp_periodDot mp_periodDot_peak' : 'mp_periodDot', 'aria-hidden': 'true' }),
1435
+ h('span', null, peak ? t('peakNowPeak') : t('peakNowIdle')),
1436
+ ),
1437
+ h('span', { className: 'mp_periodClock' }, t('peakAt', { clock, zone: timeZone })),
1438
+ ),
1439
+ period.enabled === false ? h('p', { className: 'mp_hint' }, t('peakNowDisabled')) : null,
1440
+ h(
1441
+ 'div',
1442
+ { className: 'mp_periodFacts' },
1443
+ facts.map((fact) =>
1444
+ h(
1445
+ 'div',
1446
+ { className: 'mp_periodFact', key: fact.key },
1447
+ h('span', { className: 'mp_periodFactKey' }, t(fact.key)),
1448
+ h('span', { className: 'mp_periodFactValue' }, fact.value),
1449
+ ),
1450
+ ),
1451
+ ),
1452
+ warning !== '' ? h('p', { className: 'mp_periodWarn', role: 'status' }, warning) : null,
1453
+ );
1454
+ }
1455
+
1456
+ /**
1457
+ * Labelled textarea holding one string per line.
1458
+ *
1459
+ * The draft keeps typing local and commits on blur, so the Host is written
1460
+ * once per edit rather than once per keystroke. It re-syncs from the stored
1461
+ * value only when that value changed from outside — a save round-trip or a
1462
+ * reset — never while the user is mid-edit.
1463
+ * @param {object} props - id, label, hint, value, rows, disabled, placeholder,
1464
+ * `onCommit`.
1465
+ * @returns {object} the React element.
1466
+ */
1467
+ function ListField(props) {
1468
+ const { id, label, hint, value, rows, disabled, placeholder, onCommit } = props;
1469
+ const items = Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : [];
1470
+ const [text, setText] = React.useState(() => items.join('\n'));
1471
+ const lastItems = React.useRef(items.join('\n'));
1472
+ const incoming = items.join('\n');
1473
+ React.useEffect(() => {
1474
+ if (incoming !== lastItems.current) {
1475
+ lastItems.current = incoming;
1476
+ setText(incoming);
1477
+ }
1478
+ }, [incoming]);
1479
+ const commit = () => {
1480
+ const parsed = text
1481
+ .split('\n')
1482
+ .map((line) => line.trim())
1483
+ .filter((line) => line !== '');
1484
+ if (parsed.join('\n') !== incoming) onCommit(parsed);
1485
+ };
1486
+ return h(
1487
+ 'div',
1488
+ { className: 'mp_field' },
1489
+ h('label', { className: 'mp_label', htmlFor: id }, label),
1490
+ h('textarea', {
1491
+ id,
1492
+ className: 'mp_control mp_textarea mp_mono',
1493
+ rows: rows,
1494
+ value: text,
1495
+ disabled: disabled === true,
1496
+ placeholder,
1497
+ onChange: (event) => setText(event.target.value),
1498
+ onBlur: commit,
1499
+ }),
1500
+ hint ? h('p', { className: 'mp_hint' }, hint) : null,
1501
+ );
1502
+ }
1503
+
1504
+ /**
1505
+ * Weekday multi-select as one checkbox per ISO weekday.
1506
+ *
1507
+ * A checkbox row rather than a multi-`<select>`: the set is small, every
1508
+ * choice stays visible, and "nothing selected" is legible as "never peak".
1509
+ * @param {object} props - id prefix, label, hint, value, disabled, `t`,
1510
+ * `onChange`.
1511
+ * @returns {object} the React element.
1512
+ */
1513
+ function WeekdayField(props) {
1514
+ const { idPrefix, label, hint, value, disabled, t, onChange } = props;
1515
+ const days = (Array.isArray(value) ? value : []).filter((day) => WEEKDAYS.indexOf(day) >= 0);
1516
+ return h(
1517
+ 'div',
1518
+ { className: 'mp_field' },
1519
+ h('span', { className: 'mp_label' }, label),
1520
+ h(
1521
+ 'div',
1522
+ { className: 'mp_periodWeekdays' },
1523
+ WEEKDAYS.map((day) =>
1524
+ h(
1525
+ 'label',
1526
+ { className: 'mp_check', key: day, htmlFor: `${idPrefix}-${day}` },
1527
+ h('input', {
1528
+ id: `${idPrefix}-${day}`,
1529
+ type: 'checkbox',
1530
+ checked: days.indexOf(day) >= 0,
1531
+ disabled: disabled === true,
1532
+ onChange: (event) => {
1533
+ const next = days.filter((entry) => entry !== day);
1534
+ if (event.target.checked === true) next.push(day);
1535
+ onChange(next.sort((left, right) => left - right));
1536
+ },
1537
+ }),
1538
+ h('span', { className: 'mp_checkLabel' }, weekdayName(t, day)),
1539
+ ),
1540
+ ),
1541
+ ),
1542
+ hint ? h('p', { className: 'mp_hint' }, hint) : null,
1543
+ );
1544
+ }
1545
+
1546
+ /**
1547
+ * The read-only holiday calendar block.
1548
+ *
1549
+ * These dates are fetched and cached by the Host; the page only reports what
1550
+ * it got. Nothing here is editable, because a hand-typed statutory calendar
1551
+ * is exactly what this feature replaces.
1552
+ * @param {object} props - `calendar` payload, `updateDays`, `busy`, `t`,
1553
+ * `disabled`, `onSync`.
1554
+ * @returns {object} the React element.
1555
+ */
1556
+ function HolidayCalendar(props) {
1557
+ const { calendar, updateDays, busy, t, disabled, onSync } = props;
1558
+ if (calendar === null || typeof calendar !== 'object') return h('p', { className: 'mp_hint' }, t('holidayNoReading'));
1559
+ const years = Array.isArray(calendar.years) ? calendar.years.filter((year) => Number.isFinite(year)) : [];
1560
+ const pendingYears = Array.isArray(calendar.pendingYears) ? calendar.pendingYears.filter((year) => Number.isFinite(year)) : [];
1561
+ const papers = Array.isArray(calendar.papers) ? calendar.papers.filter((entry) => typeof entry === 'string' && entry !== '') : [];
1562
+ const fetchedAt = Number.isFinite(calendar.fetchedAt) ? calendar.fetchedAt : null;
1563
+ const lastError = typeof calendar.lastError === 'string' && calendar.lastError !== '' ? calendar.lastError : '';
1564
+ const source = typeof calendar.source === 'string' && calendar.source !== '' ? calendar.source : '';
1565
+ // One status word, in the order the facts decide it: a reported error wins,
1566
+ // then "no cache at all", then a cache the Host itself calls stale.
1567
+ const status = lastError !== ''
1568
+ ? 'failed'
1569
+ : fetchedAt === null
1570
+ ? 'never'
1571
+ : calendar.stale === true
1572
+ ? 'stale'
1573
+ : 'ok';
1574
+ const label = status === 'failed' ? t('holidayStatusFailed') : status === 'never' ? t('holidayStatusNever') : status === 'stale' ? t('holidayStatusStale') : t('holidayStatusOk');
1575
+ // The year the counts describe. A Host that predates `currentYear` still gets a
1576
+ // sensible label instead of 'undefined': the browser's own year is the reading a
1577
+ // reader expects, and the Host's own `now()` uses the same wall clock.
1578
+ const currentYear = Number.isFinite(calendar.currentYear) ? calendar.currentYear : new Date().getFullYear();
1579
+ // A Host that predates the per-year counts reports neither, and `numberOr` would
1580
+ // then render "0 holidays" for a year that is fully loaded — a lie in the opposite
1581
+ // direction from the pending case, and the worse of the two because nothing on the
1582
+ // card reveals it. Only trust the per-year figures when the Host actually sent them.
1583
+ const hasCurrentCounts = Number.isFinite(calendar.currentHolidayCount)
1584
+ && Number.isFinite(calendar.currentMakeupCount);
1585
+ const currentHolidays = hasCurrentCounts ? calendar.currentHolidayCount : 0;
1586
+ const currentMakeup = hasCurrentCounts ? calendar.currentMakeupCount : 0;
1587
+ // A pending year is normal, not an error: the State Council publishes next year's
1588
+ // notice in November, so every January has a year the source cannot answer for yet.
1589
+ // Rendering '0 holidays' there would read as a broken calendar, so the card says so
1590
+ // instead. An absent `years` list (an older Host) cannot prove the year is missing,
1591
+ // so only a listed-but-empty or explicitly pending year counts as unpublished.
1592
+ const currentPending = hasCurrentCounts && currentHolidays === 0 && currentMakeup === 0
1593
+ && (pendingYears.indexOf(currentYear) >= 0 || (years.length > 0 && years.indexOf(currentYear) < 0));
1594
+ // Neither figure is trustworthy: fall back to the whole-set totals, which every Host
1595
+ // reports, and label them as the cached set rather than as one year. Showing a true
1596
+ // aggregate beats showing a false per-year zero.
1597
+ const countsKnown = hasCurrentCounts;
1598
+ return h(
1599
+ 'div',
1600
+ { className: 'mp_periodBlock', 'data-calendar': status },
1601
+ h(
1602
+ 'div',
1603
+ { className: 'mp_periodBlockHead' },
1604
+ h('h4', { className: 'mp_periodBlockTitle' }, t('holidayCalendarTitle')),
1605
+ h(
1606
+ 'span',
1607
+ { className: status === 'ok' ? 'mp_periodBadge mp_periodBadge_peak' : 'mp_periodBadge', 'data-calendar-status': status },
1608
+ h('span', { className: status === 'ok' ? 'mp_periodDot mp_periodDot_peak' : 'mp_periodDot', 'aria-hidden': 'true' }),
1609
+ h('span', null, label),
1610
+ ),
1611
+ ),
1612
+ h(
1613
+ 'div',
1614
+ { className: 'mp_periodFacts' },
1615
+ // One year only: the reader wants to know whether today's calendar is right,
1616
+ // not how much history happens to be cached. The year labels the counts so
1617
+ // the numbers cannot be misread as a whole-set total.
1618
+ // A Host that reports per-year figures gets the one row that answers "is this
1619
+ // year right". An older Host gets the cached totals instead, labelled as such —
1620
+ // showing a per-year zero it cannot vouch for would be the worse lie.
1621
+ countsKnown
1622
+ ? h(
1623
+ 'div',
1624
+ { className: 'mp_periodFact', 'data-calendar-year': String(currentYear) },
1625
+ h('span', { className: 'mp_periodFactKey' }, t('holidayYearLabel', { year: currentYear })),
1626
+ h('span', { className: 'mp_periodFactValue' }, currentPending
1627
+ ? t('holidayCurrentPending')
1628
+ : t('holidayCounts', { holidays: currentHolidays, makeup: currentMakeup })),
1629
+ )
1630
+ : h(
1631
+ 'div',
1632
+ { className: 'mp_periodFact', 'data-calendar-totals': 'true' },
1633
+ h('span', { className: 'mp_periodFactKey' }, t('holidayTotalsLabel')),
1634
+ h('span', { className: 'mp_periodFactValue' }, t('holidayCounts', {
1635
+ holidays: numberOr(calendar.holidayCount, 0),
1636
+ makeup: numberOr(calendar.makeupCount, 0),
1637
+ })),
1638
+ ),
1639
+ h(
1640
+ 'div',
1641
+ { className: 'mp_periodFact' },
1642
+ h('span', { className: 'mp_periodFactKey' }, t('holidayUpdated')),
1643
+ h('span', { className: 'mp_periodFactValue' }, fetchedAt === null ? t('holidayUpdatedNever') : zonedClock(fetchedAt, '')),
1644
+ ),
1645
+ source !== ''
1646
+ ? h(
1647
+ 'div',
1648
+ { className: 'mp_periodFact' },
1649
+ h('span', { className: 'mp_periodFactKey' }, t('holidaySourceLabel')),
1650
+ h('span', { className: 'mp_periodFactValue mp_mono' }, source),
1651
+ )
1652
+ : null,
1653
+ // The notice URLs are shown as text, never as links: the page has no
1654
+ // business sending a reader off-site, and the ids are what matter.
1655
+ papers.length > 0
1656
+ ? h(
1657
+ 'div',
1658
+ { className: 'mp_periodFact' },
1659
+ h('span', { className: 'mp_periodFactKey' }, t('holidayPapersLabel')),
1660
+ h('span', { className: 'mp_periodFactValue mp_mono' }, papers.join(t('peakSeparator'))),
1661
+ )
1662
+ : null,
1663
+ ),
1664
+ h('p', { className: 'mp_hint' }, t('holidayCadence', { days: numberOr(updateDays, 7) })),
1665
+ lastError !== '' ? h('p', { className: 'mp_error', role: 'alert' }, t('holidaySyncError', { message: lastError })) : null,
1666
+ h(
1667
+ 'div',
1668
+ { className: 'mp_mergeActions' },
1669
+ h(
1670
+ 'button',
1671
+ {
1672
+ type: 'button',
1673
+ className: 'mp_btn',
1674
+ disabled: disabled === true || busy === true,
1675
+ onClick: () => {
1676
+ onSync();
1677
+ },
1678
+ },
1679
+ busy === true ? t('holidaySyncing') : t('holidaySyncNow'),
1680
+ ),
1681
+ ),
1682
+ );
1683
+ }
1684
+
1685
+ /**
1686
+ * One period's provider ranking.
1687
+ *
1688
+ * Rows are the configured ranking followed by every other pool provider in
1689
+ * registration order, exactly like the provider-priority card, so any
1690
+ * provider can be moved into the ranking. The draft is local until saved:
1691
+ * the list only reaches the Host through `onSave`.
1692
+ * @param {object} props - `field`, `title`, `hint`, `ranking`, `providers`,
1693
+ * `t`, `disabled`, `onSave`.
1694
+ * @returns {object} the React element.
1695
+ */
1696
+ function PeriodOrderList(props) {
1697
+ const { field, title, hint, ranking, providers, t, disabled, onSave } = props;
1698
+ const [draft, setDraft] = React.useState(null);
1699
+ const stored = Array.isArray(ranking) ? ranking.filter((id) => typeof id === 'string') : [];
1700
+ const base = Array.isArray(draft) ? draft : stored;
1701
+ const known = new Set(providers.map((provider) => provider.id));
1702
+ const listed = base.filter((id) => known.has(id));
1703
+ const seen = new Set(listed);
1704
+ const shown = [...listed, ...providers.filter((provider) => !seen.has(provider.id)).map((provider) => provider.id)]
1705
+ .map((id) => {
1706
+ const match = providers.find((provider) => provider.id === id);
1707
+ return { id, name: match !== undefined ? match.name : id };
1708
+ });
1709
+ const shownIds = shown.map((row) => row.id);
1710
+ // Dirty is the user's intent against the stored ranking, not the visible
1711
+ // rows against them: rows are appended from the provider directory, so
1712
+ // comparing those would call an explicit "clear ranking" clean whenever the
1713
+ // stored ranking happened to equal registration order.
1714
+ const dirty = draft !== null && JSON.stringify(draft) !== JSON.stringify(stored);
1715
+ const move = (position, delta) => {
1716
+ setDraft(() => {
1717
+ const next = [...shownIds];
1718
+ const target = position + delta;
1719
+ if (target < 0 || target >= next.length) return next;
1720
+ const [moved] = next.splice(position, 1);
1721
+ next.splice(target, 0, moved);
1722
+ return next;
1723
+ });
1724
+ };
1725
+ const remove = (position) => {
1726
+ setDraft(() => {
1727
+ const next = [...shownIds];
1728
+ next.splice(position, 1);
1729
+ return next;
1730
+ });
1731
+ };
1732
+ return h(
1733
+ 'div',
1734
+ { className: 'mp_periodBlock', 'data-order': field },
1735
+ h(
1736
+ 'div',
1737
+ { className: 'mp_periodBlockHead' },
1738
+ h('h4', { className: 'mp_periodBlockTitle' }, title),
1739
+ h('p', { className: 'mp_hint' }, hint),
1740
+ ),
1741
+ shown.length === 0
1742
+ ? h('p', { className: 'mp_hint' }, t('peakOrderEmpty'))
1743
+ : h('ul', { className: 'mp_prioList' }, shown.map((row, position) =>
1744
+ h(ProviderPriorityRow, {
1745
+ key: `${field}-${row.id}`,
1746
+ index: position,
1747
+ value: row.id,
1748
+ name: row.name,
1749
+ total: shown.length,
1750
+ known,
1751
+ t,
1752
+ disabled: disabled === true,
1753
+ onMove: move,
1754
+ onRemove: remove,
1755
+ rowClass: 'mp_periodRow',
1756
+ }),
1757
+ )),
1758
+ h(
1759
+ 'div',
1760
+ { className: 'mp_mergeActions' },
1761
+ h(
1762
+ 'button',
1763
+ {
1764
+ type: 'button',
1765
+ className: 'mp_btn',
1766
+ disabled: disabled === true || draft === null,
1767
+ onClick: () => setDraft([]),
1768
+ },
1769
+ t('peakOrderReset'),
1770
+ ),
1771
+ h(
1772
+ 'button',
1773
+ {
1774
+ type: 'button',
1775
+ className: 'mp_btn mp_btnPrimary',
1776
+ disabled: disabled === true || dirty !== true,
1777
+ onClick: () => {
1778
+ void onSave(shownIds, () => setDraft(null));
1779
+ },
1780
+ },
1781
+ t('peakOrderSave'),
1782
+ ),
1783
+ ),
1784
+ );
1785
+ }
1786
+
1787
+ // ── the page ─────────────────────────────────────────────────────────────
1788
+
1789
+ /** Stable empty snapshot for the no-controller case (must not be recreated per read). */
1790
+ const EMPTY_FORM = Object.freeze({ status: 'unavailable', value: undefined, base: undefined, user: undefined, revision: undefined, writable: false });
1791
+ /** Stand-in controller when the configForms entry is missing. */
1792
+ const EMPTY_CONTROLLER = { subscribe: () => () => {}, getSnapshot: () => EMPTY_FORM };
1793
+ /** Stable subscribe/getSnapshot pair for the fallback reader. */
1794
+ const EMPTY_SUBSCRIBE = () => () => {};
1795
+ const EMPTY_READ = () => EMPTY_FORM;
1796
+ /** `useSyncExternalStore` from React 18; absent on an older shell. */
1797
+ const useSyncExternalStore = typeof React.useSyncExternalStore === 'function' ? React.useSyncExternalStore : null;
1798
+
1799
+ /**
1800
+ * Read an observable snapshot and re-render when it changes.
1801
+ * @param {{ subscribe: Function, getSnapshot: Function }} source - the pair.
1802
+ * @returns {object} the current snapshot.
1803
+ */
1804
+ function useSnapshot(source) {
1805
+ if (useSyncExternalStore !== null) return useSyncExternalStore(source.subscribe, source.getSnapshot, EMPTY_READ);
1806
+ // React 17 fallback: a local copy kept in sync by the same subscription.
1807
+ const [snapshot, setSnapshot] = React.useState(() => source.getSnapshot());
1808
+ React.useEffect(() => source.subscribe(() => setSnapshot(source.getSnapshot())), [source]);
1809
+ return snapshot;
1810
+ }
1811
+
1812
+ /**
1813
+ * Render the Settings → Model Pool page.
1814
+ * @param {object} props - slot props: `t` and the `controller` from the
1815
+ * registration's inject face; the shell's standard props are ignored.
1816
+ * @returns {object} the React element.
1817
+ */
1818
+ function SettingsPage(props) {
1819
+ const t = typeof props.t === 'function' ? props.t : (key) => key;
1820
+ const controller = props.controller && typeof props.controller.getSnapshot === 'function' ? props.controller : EMPTY_CONTROLLER;
1821
+
1822
+ // Subscribe to the settings form controller. The pair is memoized on the
1823
+ // controller so a re-render never churns the subscription.
1824
+ const source = React.useMemo(
1825
+ () => ({
1826
+ subscribe: controller === EMPTY_CONTROLLER ? EMPTY_SUBSCRIBE : (listener) => controller.subscribe(listener),
1827
+ getSnapshot: controller === EMPTY_CONTROLLER ? EMPTY_READ : () => controller.getSnapshot(),
1828
+ }),
1829
+ [controller],
1830
+ );
1831
+ const form = useSnapshot(source);
1832
+
1833
+ // Unique id prefix: two mounted instances (a transition or a second shell)
1834
+ // must not collide on the labelled controls. `useId` exists from React 18;
1835
+ // the fallback keeps an older shell rendering with a fixed prefix.
1836
+ const uid = typeof React.useId === 'function' ? React.useId() : 'mp';
1837
+ const [state, setState] = React.useState({ status: 'loading', data: null, error: null });
1838
+ const [busy, setBusy] = React.useState(null);
1839
+ // Kept apart from `busy`: a calendar sync only disables its own button, so
1840
+ // the rest of the page stays usable while a slow fetch runs.
1841
+ const [calendarBusy, setCalendarBusy] = React.useState(false);
1842
+ const [notice, setNotice] = React.useState('');
1843
+ const [writeBusy, setWriteBusy] = React.useState(false);
1844
+ const [writeError, setWriteError] = React.useState('');
1845
+ // Merged groups are edited as a local draft. `null` means "not touched
1846
+ // yet", so the draft follows the stored config until the first edit.
1847
+ const [mergeDraft, setMergeDraft] = React.useState(null);
1848
+ // Provider priority is edited as a local draft for the same reason.
1849
+ const [orderDraft, setOrderDraft] = React.useState(null);
1850
+ const [expanded, setExpanded] = React.useState(() => new Set());
1851
+ const tRef = React.useRef(t);
1852
+ tRef.current = t;
1853
+
1854
+ /**
1855
+ * Load one state payload. `action` picks the route and the button label.
1856
+ * @param {'load'|'refresh'|'reset'|null} action - what triggered the read.
1857
+ * @returns {Promise<void>} settles after the state is stored.
1858
+ */
1859
+ const run = React.useCallback(async (action) => {
1860
+ setBusy(action);
1861
+ try {
1862
+ let payload;
1863
+ if (action === 'reset') {
1864
+ payload = await post('/reset-health', ACTION_TIMEOUT_MS);
1865
+ } else if (action === 'refresh') {
1866
+ // Force a catalog re-read. A Host without the action route still
1867
+ // answers the read route, so the button never becomes a dead end;
1868
+ // any other failure is a real error and is reported as one.
1869
+ try {
1870
+ payload = await post('/refresh', ACTION_TIMEOUT_MS);
1871
+ } catch (cause) {
1872
+ if (!isMissingRoute(cause)) throw cause;
1873
+ payload = await api('/state', { timeout: STATE_TIMEOUT_MS });
1874
+ }
1875
+ } else {
1876
+ payload = await api('/state', { timeout: STATE_TIMEOUT_MS });
1877
+ }
1878
+ setState({ status: 'ready', data: payload && typeof payload === 'object' ? payload : {}, error: null });
1879
+ setNotice(action === 'refresh' ? 'noticeRefreshed' : action === 'reset' ? 'noticeHealthReset' : '');
1880
+ } catch (cause) {
1881
+ const aborted = cause !== null && typeof cause === 'object' && cause.name === 'AbortError';
1882
+ const detail = aborted ? tRef.current('timeout') : messageOf(cause);
1883
+ setState((current) => ({
1884
+ status: current.data ? 'ready' : 'error',
1885
+ data: current.data,
1886
+ error: { message: detail },
1887
+ }));
1888
+ setNotice('');
1889
+ } finally {
1890
+ setBusy(null);
1891
+ }
1892
+ }, []);
1893
+
1894
+ React.useEffect(() => {
1895
+ void run('load');
1896
+ }, [run]);
1897
+
1898
+ /**
1899
+ * Persist one config field through the settings form controller.
1900
+ * @param {string} field - Config field name.
1901
+ * @param {unknown} value - new value.
1902
+ * @returns {Promise<void>} settles when the write is answered.
1903
+ */
1904
+ const writeField = React.useCallback(
1905
+ async (field, value) => {
1906
+ if (controller === EMPTY_CONTROLLER) return;
1907
+ setWriteBusy(true);
1908
+ setWriteError('');
1909
+ try {
1910
+ const accepted = await controller.set(field, jsonValue(value));
1911
+ if (accepted === false) setWriteError(tRef.current('saveFailed'));
1912
+ } catch (cause) {
1913
+ setWriteError(messageOf(cause));
1914
+ } finally {
1915
+ setWriteBusy(false);
1916
+ }
1917
+ },
1918
+ [controller],
1919
+ );
1920
+
1921
+ const data = state.data;
1922
+ const snapshot = data && data.snapshot !== null && typeof data.snapshot === 'object' ? data.snapshot : null;
1923
+ const models = snapshot && Array.isArray(snapshot.models) ? snapshot.models : [];
1924
+ const failures = snapshot && Array.isArray(snapshot.failures) ? snapshot.failures : [];
1925
+ const httpConfig = data && data.config !== null && typeof data.config === 'object' ? data.config : {};
1926
+ const remoteError = data && data.error !== null && typeof data.error === 'object' ? data.error : null;
1927
+ const now = Date.now();
1928
+ const index = healthIndex(data ? data.health : undefined);
1929
+
1930
+ const formReady = form && form.status === 'ready';
1931
+ const formValue = formReady && form.value !== null && typeof form.value === 'object' ? form.value : {};
1932
+ // `writable` is the settings document's own flag (`settings.describe()`).
1933
+ // A state payload that explicitly reports `writable: false` on the pool
1934
+ // snapshot also disables the controls, so either surface can veto writes.
1935
+ const writable = formReady && form.writable !== false && !(snapshot !== null && snapshot.writable === false);
1936
+ const strategy = STRATEGIES.indexOf(formValue.strategy) >= 0 ? formValue.strategy : 'priority';
1937
+ const fallback = formValue.fallback !== false;
1938
+ const skipUnavailable = formValue.skipUnavailable !== false;
1939
+ const sticky = formValue.sticky !== false;
1940
+ const cooldownMs = numberOr(formValue.cooldownMs, DEFAULT_COOLDOWN_MS);
1941
+ const maxAttempts = numberOr(formValue.maxAttempts, DEFAULT_MAX_ATTEMPTS);
1942
+ const controlsDisabled = !writable || writeBusy;
1943
+
1944
+ // Merged groups: the stored list is the source of truth until the user
1945
+ // edits, after which the draft wins locally until it is saved/reset.
1946
+ // Provider priority: same draft rule as the merged groups.
1947
+ const storedOrder = Array.isArray(formValue.providerOrder) ? formValue.providerOrder.filter((id) => typeof id === 'string') : [];
1948
+ const providerOrder = Array.isArray(orderDraft) ? orderDraft : storedOrder;
1949
+ const orderDirty = orderDraft !== null ? JSON.stringify(orderDraft) !== JSON.stringify(storedOrder) : false;
1950
+
1951
+ /**
1952
+ * Move one provider-priority row up or down.
1953
+ * @param {number} position - row index.
1954
+ * @param {number} delta - -1 for up, 1 for down.
1955
+ * @returns {void}
1956
+ */
1957
+ const moveProviderOrder = (position, delta) => {
1958
+ setOrderDraft((current) => {
1959
+ const base = Array.isArray(current) ? [...current] : shownOrderIds();
1960
+ const target = position + delta;
1961
+ if (target < 0 || target >= base.length) return base;
1962
+ const [moved] = base.splice(position, 1);
1963
+ base.splice(target, 0, moved);
1964
+ return base;
1965
+ });
1966
+ };
1967
+
1968
+ /**
1969
+ * Remove one provider-priority row.
1970
+ * @param {number} position - row index.
1971
+ * @returns {void}
1972
+ */
1973
+ const removeProviderOrder = (position) => {
1974
+ setOrderDraft((current) => {
1975
+ const base = Array.isArray(current) ? [...current] : shownOrderIds();
1976
+ base.splice(position, 1);
1977
+ return base;
1978
+ });
1979
+ };
1980
+
1981
+ /**
1982
+ * Persist the provider-priority list.
1983
+ * @returns {Promise<void>} settles when the write is answered.
1984
+ */
1985
+ const saveProviderOrder = async () => {
1986
+ const rows = Array.isArray(orderDraft) ? orderDraft : shownOrderIds();
1987
+ const cleaned = [];
1988
+ for (const entry of rows) {
1989
+ const id = typeof entry === 'string' ? entry.trim() : '';
1990
+ // Drop blanks and duplicates: the Host treats the list as a ranking, so
1991
+ // a repeated id would only be a confusing no-op.
1992
+ if (id === '' || cleaned.includes(id)) continue;
1993
+ cleaned.push(id);
1994
+ }
1995
+ // Storing the untouched registration order would be noise: the router
1996
+ // already falls back to registration order, so an "everything, unchanged"
1997
+ // list is saved as an empty ranking instead.
1998
+ const isRegistrationOrder = cleaned.length > 0
1999
+ && cleaned.join('\u0000') === registrationOrderIds.join('\u0000');
2000
+ await writeField('providerOrder', isRegistrationOrder ? [] : cleaned);
2001
+ setOrderDraft(null);
2002
+ setNotice('prioritySaved');
2003
+ void run('refresh');
2004
+ };
2005
+
2006
+ const storedGroups = Array.isArray(formValue.modelGroups) ? formValue.modelGroups : [];
2007
+ const mergedGroups = Array.isArray(mergeDraft) ? mergeDraft : storedGroups;
2008
+ const mergedGroupsDirty = mergeDraft !== null ? JSON.stringify(mergeDraft) !== JSON.stringify(storedGroups) : false;
2009
+
2010
+ /**
2011
+ * Apply one edit to the merged-group draft.
2012
+ * @param {number} position - group index; `length` appends.
2013
+ * @param {object|null} patch - field patch, or null to remove the group.
2014
+ * @returns {void}
2015
+ */
2016
+ const replaceMergedGroup = (position, patch) => {
2017
+ setMergeDraft((current) => {
2018
+ const base = Array.isArray(current) ? current : storedGroups;
2019
+ const next = base.map((group) => (group !== null && typeof group === 'object' ? { ...group } : { id: '', name: '', models: [], merge: true }));
2020
+ if (patch === null) next.splice(position, 1);
2021
+ else if (position >= next.length) next.push({ id: '', name: '', models: [], merge: true, ...patch });
2022
+ else next[position] = { ...next[position], ...patch };
2023
+ return next;
2024
+ });
2025
+ };
2026
+
2027
+ /**
2028
+ * Persist the whole merged-group list.
2029
+ * @returns {Promise<void>} settles when the write is answered.
2030
+ */
2031
+ const saveMergedGroups = async () => {
2032
+ const groups = Array.isArray(mergeDraft) ? mergeDraft : storedGroups;
2033
+ // Drop rows the user left blank: an unnamed, selector-less group would
2034
+ // only be rejected by the Host anyway.
2035
+ const cleaned = groups
2036
+ .filter((group) => group !== null && typeof group === 'object')
2037
+ .map((group) => ({
2038
+ id: typeof group.id === 'string' ? group.id.trim() : '',
2039
+ name: typeof group.name === 'string' ? group.name.trim() : '',
2040
+ models: (Array.isArray(group.models) ? group.models : []).filter((entry) => typeof entry === 'string' && entry.trim() !== ''),
2041
+ merge: group.merge !== false,
2042
+ }))
2043
+ .filter((group) => group.name !== '' && group.models.length > 0);
2044
+ await writeField('modelGroups', cleaned);
2045
+ setMergeDraft(null);
2046
+ setNotice('mergeSaved');
2047
+ void run('refresh');
2048
+ };
2049
+
2050
+ const allProviders = new Set();
2051
+ for (const model of models) {
2052
+ for (const route of Array.isArray(model && model.routes) ? model.routes : []) {
2053
+ if (route !== null && typeof route === 'object' && typeof route.provider === 'string') allProviders.add(route.provider);
2054
+ }
2055
+ }
2056
+
2057
+ // The registry's provider directory: `{ id, name, registrationIndex }` in
2058
+ // registration order. Used to name each row and to populate the picker, so
2059
+ // the user never types an id.
2060
+ const providerDirectory = snapshot !== null && Array.isArray(snapshot.providers) ? snapshot.providers.filter(
2061
+ (entry) => entry !== null && typeof entry === 'object' && typeof entry.id === 'string',
2062
+ ) : [];
2063
+ const providerNames = new Map(providerDirectory.map((entry) => [entry.id, typeof entry.name === 'string' && entry.name !== '' ? entry.name : entry.id]));
2064
+
2065
+ // Only providers that actually contribute a route to the pool are worth
2066
+ // ranking: a provider with no models cannot be routed to, so listing it
2067
+ // would offer a choice that changes nothing. `poolProviders` is the
2068
+ // registration-ordered subset, which is also the router's fallback order.
2069
+ const poolProviders = providerDirectory
2070
+ .filter((entry) => allProviders.has(entry.id))
2071
+ .sort((left, right) => (left.registrationIndex ?? 0) - (right.registrationIndex ?? 0));
2072
+ const poolProviderIds = new Set(poolProviders.map((entry) => entry.id));
2073
+ /** The same providers with a display name resolved, for the period rankings. */
2074
+ const orderableProviders = poolProviders.map((entry) => ({ id: entry.id, name: providerNames.get(entry.id) ?? entry.id }));
2075
+
2076
+ // Rows: the configured ranking first, then the remaining pool providers in
2077
+ // registration order. With nothing configured this is exactly the
2078
+ // registration order, which is what the router falls back to.
2079
+ const shownOrder = (() => {
2080
+ const listed = providerOrder.filter((id) => typeof id === 'string' && poolProviderIds.has(id));
2081
+ const seen = new Set(listed);
2082
+ const rest = poolProviders.filter((entry) => !seen.has(entry.id)).map((entry) => entry.id);
2083
+ return [...listed, ...rest].map((id) => ({ id, name: providerNames.get(id) ?? id }));
2084
+ })();
2085
+ /** Pool-provider ids in plain registration order, used to normalize a no-op save. */
2086
+ const registrationOrderIds = poolProviders.map((entry) => entry.id);
2087
+ /**
2088
+ * The ids as currently displayed, in display order.
2089
+ *
2090
+ * The mutators above start from this rather than from `storedOrder`:
2091
+ * `shownOrder` appends pool providers that the ranking does not mention,
2092
+ * so with an empty ranking the displayed list has rows while
2093
+ * `storedOrder` has none. Reading `storedOrder` there made every move a
2094
+ * no-op on an unconfigured pool.
2095
+ *
2096
+ * Declared as a function so it can be referenced by handlers defined
2097
+ * above it; it is only called on interaction, after this line runs.
2098
+ * @returns {string[]} provider ids in display order.
2099
+ */
2100
+ function shownOrderIds() {
2101
+ return shownOrder.map((row) => row.id);
2102
+ }
2103
+
2104
+ // Peak / idle policy. The values follow the same rule as every other
2105
+ // field: the stored config is the source of truth, and an absent list is
2106
+ // left undefined so the Host keeps its own default rather than seeing an
2107
+ // accidental "empty".
2108
+ const period = data && data.period !== null && typeof data.period === 'object' ? data.period : null;
2109
+ const calendar = data && data.calendar !== null && typeof data.calendar === 'object' ? data.calendar : null;
2110
+ const peakEnabled = formValue.peakEnabled !== false;
2111
+ const peakTimeZone = typeof formValue.peakTimeZone === 'string' ? formValue.peakTimeZone : '';
2112
+ const peakWindows = Array.isArray(formValue.peakWindows) ? formValue.peakWindows.filter((entry) => typeof entry === 'string') : [];
2113
+ const peakWeekdays = Array.isArray(formValue.peakWeekdays) ? formValue.peakWeekdays.filter((entry) => WEEKDAYS.indexOf(entry) >= 0) : [];
2114
+ const peakMakeupWorkdays = Array.isArray(formValue.peakMakeupWorkdays)
2115
+ ? formValue.peakMakeupWorkdays.filter((entry) => typeof entry === 'string')
2116
+ : [];
2117
+ // Calendar sync settings. `holidaySource` and `holidayUrlTemplate` stay out
2118
+ // of the page: the shipped source is the maintained one, and a URL
2119
+ // template is an operator escape hatch rather than a Settings decision.
2120
+ const holidaySync = formValue.holidaySync !== false;
2121
+ const holidayUpdateDays = numberOr(formValue.holidayUpdateDays, DEFAULT_UPDATE_DAYS);
2122
+ const holidayOverride = Array.isArray(formValue.holidayOverride)
2123
+ ? formValue.holidayOverride.filter((entry) => typeof entry === 'string')
2124
+ : [];
2125
+ const peakProviderOrder = Array.isArray(formValue.peakProviderOrder) ? formValue.peakProviderOrder.filter((entry) => typeof entry === 'string') : [];
2126
+ const offPeakProviderOrder = Array.isArray(formValue.offPeakProviderOrder) ? formValue.offPeakProviderOrder.filter((entry) => typeof entry === 'string') : [];
2127
+
2128
+ /**
2129
+ * Persist one period ranking.
2130
+ *
2131
+ * Same normalisation as `saveProviderOrder`: blanks and duplicates are
2132
+ * dropped because the Host reads the list as a ranking, and a list that
2133
+ * reproduces registration order is stored as `[]` — for a period ranking
2134
+ * that is "inherit `providerOrder`", which is also what "Clear ranking"
2135
+ * means, so the untouched view never pins an accidental order.
2136
+ * @param {string} field - `peakProviderOrder` or `offPeakProviderOrder`.
2137
+ * @param {string[]} rows - the ids in display order.
2138
+ * @param {Function} done - clears the component's local draft.
2139
+ * @returns {Promise<void>} settles when the write is answered.
2140
+ */
2141
+ const savePeriodOrder = async (field, rows, done) => {
2142
+ const cleaned = [];
2143
+ for (const entry of rows) {
2144
+ const id = typeof entry === 'string' ? entry.trim() : '';
2145
+ if (id === '' || cleaned.includes(id)) continue;
2146
+ cleaned.push(id);
2147
+ }
2148
+ const isRegistrationOrder = cleaned.length > 0
2149
+ && cleaned.join('\u0000') === registrationOrderIds.join('\u0000');
2150
+ await writeField(field, isRegistrationOrder ? [] : cleaned);
2151
+ done();
2152
+ setNotice('peakOrderSaved');
2153
+ void run('refresh');
2154
+ };
2155
+
2156
+ /**
2157
+ * Force a calendar refetch.
2158
+ *
2159
+ * The route answers with the whole state payload, so the result replaces the
2160
+ * page's state directly instead of triggering a second read. A failure keeps
2161
+ * whatever calendar is already on screen and reports the reason.
2162
+ * @returns {Promise<void>} settles when the sync is answered.
2163
+ */
2164
+ const syncCalendar = async () => {
2165
+ setCalendarBusy(true);
2166
+ try {
2167
+ const payload = await post('/refresh-calendar', ACTION_TIMEOUT_MS);
2168
+ setState({ status: 'ready', data: payload && typeof payload === 'object' ? payload : {}, error: null });
2169
+ setNotice('noticeCalendarSynced');
2170
+ } catch (cause) {
2171
+ const aborted = cause !== null && typeof cause === 'object' && cause.name === 'AbortError';
2172
+ setState((current) => ({
2173
+ status: current.data ? 'ready' : 'error',
2174
+ data: current.data,
2175
+ error: { message: aborted ? tRef.current('timeout') : messageOf(cause) },
2176
+ }));
2177
+ setNotice('');
2178
+ } finally {
2179
+ setCalendarBusy(false);
2180
+ }
2181
+ };
2182
+
2183
+ let emptyText = t('emptyPool');
2184
+ if (state.status === 'loading') emptyText = t('loading');
2185
+ else if (state.status === 'error') emptyText = t('loadFailed');
2186
+ else if (snapshot === null) emptyText = remoteError ? t('catalogError', { message: String(remoteError.message === undefined ? '' : remoteError.message) }) : t('noSnapshot');
2187
+
2188
+ const formNote =
2189
+ form.status === 'loading'
2190
+ ? t('formLoading')
2191
+ : form.status === 'unavailable'
2192
+ ? t('formUnavailable')
2193
+ : formReady && form.writable === false
2194
+ ? t('readOnly')
2195
+ : '';
2196
+
2197
+ const toggle = (id) => {
2198
+ setExpanded((current) => {
2199
+ const next = new Set(current);
2200
+ if (next.has(id)) next.delete(id);
2201
+ else next.add(id);
2202
+ return next;
2203
+ });
2204
+ };
2205
+
2206
+ return h(
2207
+ React.Fragment,
2208
+ null,
2209
+ h(
2210
+ 'div',
2211
+ { className: 'mp_page', 'data-plugin': 'dsh-model-pool' },
2212
+ h(
2213
+ 'div',
2214
+ { className: 'mp_head' },
2215
+ h('div', { className: 'mp_headText' }, h('h2', { className: 'mp_title' }, t('title')), h('p', { className: 'mp_intro' }, t('intro'))),
2216
+ h(
2217
+ 'div',
2218
+ { className: 'mp_actions' },
2219
+ h(
2220
+ 'button',
2221
+ {
2222
+ type: 'button',
2223
+ className: 'mp_btn',
2224
+ disabled: busy !== null,
2225
+ onClick: () => {
2226
+ void run('refresh');
2227
+ },
2228
+ },
2229
+ busy === 'refresh' ? t('refreshing') : t('refresh'),
2230
+ ),
2231
+ h(
2232
+ 'button',
2233
+ {
2234
+ type: 'button',
2235
+ className: 'mp_btn',
2236
+ disabled: busy !== null,
2237
+ onClick: () => {
2238
+ void run('reset');
2239
+ },
2240
+ },
2241
+ busy === 'reset' ? t('resetting') : t('resetHealth'),
2242
+ ),
2243
+ ),
2244
+ ),
2245
+
2246
+ formNote !== '' ? h('p', { className: 'mp_hint' }, formNote) : null,
2247
+ writeError !== '' ? h('p', { className: 'mp_error', role: 'alert' }, writeError) : null,
2248
+ state.error !== null ? h('p', { className: 'mp_error', role: 'alert' }, `${t('loadFailed')} ${state.error.message}`) : null,
2249
+ // A partial failure: the Host answered with a snapshot but also
2250
+ // reported a catalog problem (e.g. one provider unreadable).
2251
+ remoteError !== null && snapshot !== null
2252
+ ? h('p', { className: 'mp_error', role: 'status' }, t('catalogError', { message: String(remoteError.message === undefined ? '' : remoteError.message) }))
2253
+ : null,
2254
+ notice !== '' ? h('p', { className: 'mp_notice', role: 'status' }, t(notice)) : null,
2255
+
2256
+ h(
2257
+ 'section',
2258
+ { className: 'mp_card' },
2259
+ h('div', { className: 'mp_cardHead' }, h('h3', { className: 'mp_cardTitle' }, t('strategyTitle')), h('p', { className: 'mp_hint' }, t('strategyHint'))),
2260
+ h(
2261
+ 'select',
2262
+ {
2263
+ className: 'mp_control mp_select',
2264
+ value: strategy,
2265
+ disabled: controlsDisabled,
2266
+ 'aria-label': t('strategyTitle'),
2267
+ onChange: (event) => {
2268
+ void writeField('strategy', event.target.value);
2269
+ },
2270
+ },
2271
+ STRATEGIES.map((id) => h('option', { key: id, value: id }, t(`strategy.${id}`))),
2272
+ ),
2273
+ h('p', { className: 'mp_hint' }, t(`strategy.${strategy}.desc`)),
2274
+ ),
2275
+
2276
+ h(
2277
+ 'section',
2278
+ { className: 'mp_card' },
2279
+ h('div', { className: 'mp_cardHead' }, h('h3', { className: 'mp_cardTitle' }, t('behaviourTitle')), h('p', { className: 'mp_hint' }, t('behaviourHint'))),
2280
+ h(
2281
+ 'div',
2282
+ { className: 'mp_checks' },
2283
+ h(CheckField, {
2284
+ id: `${uid}-fallback`,
2285
+ checked: fallback,
2286
+ disabled: controlsDisabled,
2287
+ label: t('fallback'),
2288
+ hint: t('fallbackHint'),
2289
+ onChange: (next) => {
2290
+ void writeField('fallback', next);
2291
+ },
2292
+ }),
2293
+ h(CheckField, {
2294
+ id: `${uid}-skip-unavailable`,
2295
+ checked: skipUnavailable,
2296
+ disabled: controlsDisabled,
2297
+ label: t('skipUnavailable'),
2298
+ hint: t('skipUnavailableHint'),
2299
+ onChange: (next) => {
2300
+ void writeField('skipUnavailable', next);
2301
+ },
2302
+ }),
2303
+ h(CheckField, {
2304
+ id: `${uid}-sticky`,
2305
+ checked: sticky,
2306
+ disabled: controlsDisabled,
2307
+ label: t('sticky'),
2308
+ hint: t('stickyHint'),
2309
+ onChange: (next) => {
2310
+ void writeField('sticky', next);
2311
+ },
2312
+ }),
2313
+ ),
2314
+ h(
2315
+ 'div',
2316
+ { className: 'mp_formGrid' },
2317
+ h(NumberField, {
2318
+ id: `${uid}-cooldown`,
2319
+ label: t('cooldownMs'),
2320
+ hint: t('cooldownMsHint'),
2321
+ value: cooldownMs,
2322
+ min: 0,
2323
+ max: 3600000,
2324
+ step: 1000,
2325
+ disabled: controlsDisabled,
2326
+ onCommit: (next) => {
2327
+ void writeField('cooldownMs', next);
2328
+ },
2329
+ t,
2330
+ }),
2331
+ h(NumberField, {
2332
+ id: `${uid}-max-attempts`,
2333
+ label: t('maxAttempts'),
2334
+ hint: t('maxAttemptsHint'),
2335
+ value: maxAttempts,
2336
+ min: 1,
2337
+ max: 20,
2338
+ step: 1,
2339
+ disabled: controlsDisabled,
2340
+ onCommit: (next) => {
2341
+ void writeField('maxAttempts', next);
2342
+ },
2343
+ t,
2344
+ }),
2345
+ ),
2346
+ ),
2347
+
2348
+ h(
2349
+ 'section',
2350
+ { className: 'mp_card', 'data-card': 'peak' },
2351
+ h(
2352
+ 'div',
2353
+ { className: 'mp_cardHead' },
2354
+ h('h3', { className: 'mp_cardTitle' }, t('peakTitle')),
2355
+ h('p', { className: 'mp_hint' }, t('peakHint')),
2356
+ ),
2357
+
2358
+ // (a) the live reading, straight from the payload the router used
2359
+ h(PeriodSummary, { period, t }),
2360
+
2361
+ // (b) the policy: switch, zone, windows, weekdays, holiday lists
2362
+ h(
2363
+ 'div',
2364
+ { className: 'mp_checks' },
2365
+ h(CheckField, {
2366
+ id: `${uid}-peak-enabled`,
2367
+ checked: peakEnabled,
2368
+ disabled: controlsDisabled,
2369
+ label: t('peakEnabled'),
2370
+ hint: t('peakEnabledHint'),
2371
+ onChange: (next) => {
2372
+ void writeField('peakEnabled', next);
2373
+ },
2374
+ }),
2375
+ ),
2376
+ h(
2377
+ 'div',
2378
+ { className: 'mp_periodGrid' },
2379
+ h(TextField, {
2380
+ id: `${uid}-peak-tz`,
2381
+ label: t('peakTimeZone'),
2382
+ hint: t('peakTimeZoneHint'),
2383
+ value: peakTimeZone,
2384
+ disabled: controlsDisabled,
2385
+ placeholder: 'Asia/Shanghai',
2386
+ onCommit: (next) => {
2387
+ void writeField('peakTimeZone', next);
2388
+ },
2389
+ }),
2390
+ h(WeekdayField, {
2391
+ idPrefix: `${uid}-peak-day`,
2392
+ label: t('peakWeekdays'),
2393
+ hint: t('peakWeekdaysHint'),
2394
+ value: peakWeekdays,
2395
+ disabled: controlsDisabled,
2396
+ onChange: (next) => {
2397
+ void writeField('peakWeekdays', next);
2398
+ },
2399
+ t,
2400
+ }),
2401
+ ),
2402
+ h(ListField, {
2403
+ id: `${uid}-peak-windows`,
2404
+ label: t('peakWindows'),
2405
+ hint: t('peakWindowsHint'),
2406
+ value: peakWindows,
2407
+ rows: Math.min(5, Math.max(2, peakWindows.length)),
2408
+ disabled: controlsDisabled,
2409
+ placeholder: '09:00-12:00',
2410
+ onCommit: (next) => {
2411
+ void writeField('peakWindows', next);
2412
+ },
2413
+ }),
2414
+ // The statutory calendar is fetched, never typed: the block reports it
2415
+ // read-only, and the only control is a manual refetch.
2416
+ h(HolidayCalendar, {
2417
+ calendar,
2418
+ updateDays: holidayUpdateDays,
2419
+ busy: calendarBusy,
2420
+ t,
2421
+ disabled: controlsDisabled,
2422
+ onSync: () => {
2423
+ void syncCalendar();
2424
+ },
2425
+ }),
2426
+ h(
2427
+ 'div',
2428
+ { className: 'mp_checks' },
2429
+ h(CheckField, {
2430
+ id: `${uid}-holiday-sync`,
2431
+ checked: holidaySync,
2432
+ disabled: controlsDisabled,
2433
+ label: t('holidaySync'),
2434
+ hint: t('holidaySyncHint'),
2435
+ onChange: (next) => {
2436
+ void writeField('holidaySync', next);
2437
+ },
2438
+ }),
2439
+ ),
2440
+ h(
2441
+ 'div',
2442
+ { className: 'mp_periodGrid' },
2443
+ h(ListField, {
2444
+ id: `${uid}-peak-makeup`,
2445
+ label: t('peakMakeupWorkdays'),
2446
+ hint: t('peakMakeupWorkdaysHint'),
2447
+ value: peakMakeupWorkdays,
2448
+ rows: Math.min(8, Math.max(3, peakMakeupWorkdays.length)),
2449
+ disabled: controlsDisabled,
2450
+ placeholder: '2026-10-10',
2451
+ onCommit: (next) => {
2452
+ void writeField('peakMakeupWorkdays', next);
2453
+ },
2454
+ }),
2455
+ ),
2456
+ // A one-off correction layered on top of the fetch. Deliberately the
2457
+ // last thing in the card and explicitly labelled advanced: the normal
2458
+ // path must stay "not configured by hand".
2459
+ h(
2460
+ 'div',
2461
+ { className: 'mp_periodBlock' },
2462
+ h('h4', { className: 'mp_periodBlockTitle' }, t('holidayAdvanced')),
2463
+ h(ListField, {
2464
+ id: `${uid}-holiday-override`,
2465
+ label: t('holidayOverride'),
2466
+ hint: t('holidayOverrideHint'),
2467
+ value: holidayOverride,
2468
+ rows: Math.min(5, Math.max(2, holidayOverride.length)),
2469
+ disabled: controlsDisabled,
2470
+ placeholder: '2026-10-01',
2471
+ onCommit: (next) => {
2472
+ void writeField('holidayOverride', next);
2473
+ },
2474
+ }),
2475
+ ),
2476
+
2477
+ // (c) the two rankings, each saved on its own
2478
+ h(
2479
+ 'div',
2480
+ { className: 'mp_periodGrid' },
2481
+ h(PeriodOrderList, {
2482
+ field: 'peakProviderOrder',
2483
+ title: t('peakPeakOrder'),
2484
+ hint: t('peakPeakOrderHint'),
2485
+ ranking: peakProviderOrder,
2486
+ providers: orderableProviders,
2487
+ t,
2488
+ disabled: controlsDisabled,
2489
+ onSave: (rows, done) => savePeriodOrder('peakProviderOrder', rows, done),
2490
+ }),
2491
+ h(PeriodOrderList, {
2492
+ field: 'offPeakProviderOrder',
2493
+ title: t('peakOffPeakOrder'),
2494
+ hint: t('peakOffPeakOrderHint'),
2495
+ ranking: offPeakProviderOrder,
2496
+ providers: orderableProviders,
2497
+ t,
2498
+ disabled: controlsDisabled,
2499
+ onSave: (rows, done) => savePeriodOrder('offPeakProviderOrder', rows, done),
2500
+ }),
2501
+ ),
2502
+ ),
2503
+
2504
+ h(
2505
+ 'section',
2506
+ { className: 'mp_card' },
2507
+ h(
2508
+ 'div',
2509
+ { className: 'mp_cardHead' },
2510
+ h('h3', { className: 'mp_cardTitle' }, t('priorityTitle')),
2511
+ h('p', { className: 'mp_hint' }, t('priorityHint')),
2512
+ ),
2513
+ shownOrder.length === 0
2514
+ ? h('p', { className: 'mp_hint' }, t('priorityEmpty'))
2515
+ : h('ul', { className: 'mp_prioList' }, shownOrder.map((provider, position) =>
2516
+ h(ProviderPriorityRow, {
2517
+ key: `prio-${String(provider.id)}`,
2518
+ index: position,
2519
+ value: provider.id,
2520
+ name: provider.name,
2521
+ total: shownOrder.length,
2522
+ known: allProviders,
2523
+ t,
2524
+ disabled: controlsDisabled,
2525
+ onMove: moveProviderOrder,
2526
+ onRemove: removeProviderOrder,
2527
+ }),
2528
+ )),
2529
+ h(
2530
+ 'div',
2531
+ { className: 'mp_mergeActions' },
2532
+ h(
2533
+ 'button',
2534
+ {
2535
+ type: 'button',
2536
+ className: 'mp_btn',
2537
+ disabled: controlsDisabled || orderDirty !== true,
2538
+ onClick: () => {
2539
+ // An empty ranking *is* registration order: `shownOrder` appends
2540
+ // every pool provider the ranking omits. Saving this stores `[]`,
2541
+ // which is exactly the unconfigured state.
2542
+ setOrderDraft([]);
2543
+ },
2544
+ },
2545
+ t('priorityReset'),
2546
+ ),
2547
+ h(
2548
+ 'button',
2549
+ {
2550
+ type: 'button',
2551
+ className: 'mp_btn mp_btnPrimary',
2552
+ disabled: controlsDisabled || orderDirty !== true,
2553
+ onClick: () => {
2554
+ void saveProviderOrder();
2555
+ },
2556
+ },
2557
+ t('prioritySave'),
2558
+ ),
2559
+ ),
2560
+ ),
2561
+
2562
+ h(
2563
+ 'section',
2564
+ { className: 'mp_card' },
2565
+ h(
2566
+ 'div',
2567
+ { className: 'mp_cardHead' },
2568
+ h('h3', { className: 'mp_cardTitle' }, t('mergeTitle')),
2569
+ h('p', { className: 'mp_hint' }, t('mergeHint')),
2570
+ ),
2571
+ mergedGroups.length === 0
2572
+ ? h('p', { className: 'mp_hint' }, t('mergeEmpty'))
2573
+ : mergedGroups.map((group, position) =>
2574
+ h(MergedGroupRow, {
2575
+ key: `merge-${position}`,
2576
+ index: position,
2577
+ group,
2578
+ t,
2579
+ disabled: controlsDisabled,
2580
+ onChange: (patch) => replaceMergedGroup(position, patch),
2581
+ onRemove: () => replaceMergedGroup(position, null),
2582
+ }),
2583
+ ),
2584
+ h(
2585
+ 'div',
2586
+ { className: 'mp_mergeActions' },
2587
+ h(
2588
+ 'button',
2589
+ {
2590
+ type: 'button',
2591
+ className: 'mp_btn',
2592
+ disabled: controlsDisabled,
2593
+ onClick: () => replaceMergedGroup(mergedGroups.length, { id: '', name: '', models: [], merge: true }),
2594
+ },
2595
+ t('mergeAdd'),
2596
+ ),
2597
+ h(
2598
+ 'button',
2599
+ {
2600
+ type: 'button',
2601
+ className: 'mp_btn mp_btnPrimary',
2602
+ disabled: controlsDisabled || mergedGroupsDirty !== true,
2603
+ onClick: () => {
2604
+ void saveMergedGroups();
2605
+ },
2606
+ },
2607
+ t('mergeSave'),
2608
+ ),
2609
+ ),
2610
+ ),
2611
+
2612
+ h(
2613
+ 'section',
2614
+ { className: 'mp_card' },
2615
+ h(
2616
+ 'div',
2617
+ { className: 'mp_cardHead' },
2618
+ h('h3', { className: 'mp_cardTitle' }, t('poolTitle')),
2619
+ h('p', { className: 'mp_hint' }, t('poolSummary', { models: models.length, providers: allProviders.size })),
2620
+ ),
2621
+ models.length === 0
2622
+ ? null
2623
+ : h(
2624
+ 'div',
2625
+ { className: 'mp_thead mp_grid' },
2626
+ h('span', null, t('colModel')),
2627
+ h('span', { className: 'mp_num' }, t('colProviders')),
2628
+ h('span', { className: 'mp_num' }, t('colDepths')),
2629
+ h('span', null, t('colState')),
2630
+ h('span', { 'aria-hidden': 'true' }),
2631
+ ),
2632
+ models.length === 0
2633
+ ? h('p', { className: 'mp_empty' }, emptyText)
2634
+ : h(
2635
+ 'ul',
2636
+ { className: 'mp_models' },
2637
+ models.map((model, position) => {
2638
+ // Row identity is positional: two models may share an id in
2639
+ // a malformed snapshot, and the disclosure must not follow.
2640
+ const rowKey = `${String(model && model.id)}-${position}`;
2641
+ return h(ModelRow, {
2642
+ key: rowKey,
2643
+ rowKey,
2644
+ model,
2645
+ index,
2646
+ t,
2647
+ now,
2648
+ expanded: expanded.has(rowKey),
2649
+ onToggle: () => toggle(rowKey),
2650
+ });
2651
+ }),
2652
+ ),
2653
+ ),
2654
+
2655
+ failures.length > 0
2656
+ ? h(
2657
+ 'section',
2658
+ { className: 'mp_card' },
2659
+ h(
2660
+ 'div',
2661
+ { className: 'mp_cardHead' },
2662
+ h('h3', { className: 'mp_cardTitle' }, t('failuresTitle')),
2663
+ h('p', { className: 'mp_hint' }, t('failuresHint')),
2664
+ ),
2665
+ h(
2666
+ 'div',
2667
+ { className: 'mp_failures' },
2668
+ failures.map((failure, position) =>
2669
+ h(
2670
+ 'p',
2671
+ { className: 'mp_failure', key: `${String(failure && failure.provider)}-${position}` },
2672
+ `${String(failure && failure.name ? failure.name : failure && failure.provider ? failure.provider : '')}: ${String(failure && failure.message ? failure.message : '')}`,
2673
+ ),
2674
+ ),
2675
+ ),
2676
+ )
2677
+ : null,
2678
+
2679
+ h(
2680
+ 'div',
2681
+ { className: 'mp_meta' },
2682
+ typeof httpConfig.providerId === 'string' && httpConfig.providerId !== ''
2683
+ ? h('span', null, t('poolRoute', { provider: typeof httpConfig.providerName === 'string' && httpConfig.providerName !== '' ? httpConfig.providerName : httpConfig.providerId, id: httpConfig.providerId }))
2684
+ : null,
2685
+ snapshot && Number.isFinite(snapshot.takenAt) && snapshot.takenAt > 0
2686
+ ? h('span', null, t('takenAt', { time: new Date(snapshot.takenAt).toLocaleTimeString() }))
2687
+ : null,
2688
+ data && typeof data.version === 'string' && data.version !== '' ? h('span', null, t('version', { version: data.version })) : null,
2689
+ ),
2690
+ ),
2691
+ );
2692
+ }
2693
+
2694
+ // ── composer dock: the live route of this conversation ───────────────────
2695
+
2696
+ /** How often the dock refreshes while a turn is running. */
2697
+ const DOCK_POLL_MS = 4000;
2698
+
2699
+ /**
2700
+ * Render the concrete provider behind the pool for one session.
2701
+ *
2702
+ * The pool route the user picks is virtual, so without this the composer
2703
+ * only ever shows "Model Pool / DeepSeek V4.1 Flash" and the real upstream
2704
+ * stays invisible. The Host records the route that actually produced
2705
+ * content for each session; this component reads it back.
2706
+ *
2707
+ * It renders nothing at all when the session never ran through the pool,
2708
+ * so a normal conversation is unaffected.
2709
+ *
2710
+ * @param {object} props - slot props; `sessionId` comes from the shell.
2711
+ * @returns {object|null} the React element, or null when inactive.
2712
+ */
2713
+ function PoolRouteDock(props) {
2714
+ const t = React.useMemo(() => props.t, [props.t]);
2715
+ const sessionId = typeof props.sessionId === 'string' ? props.sessionId : '';
2716
+ const [route, setRoute] = React.useState(null);
2717
+ const [failed, setFailed] = React.useState(false);
2718
+
2719
+ React.useEffect(() => {
2720
+ if (sessionId === '') return undefined;
2721
+ // `cancelled` guards against a state write after the session changed or
2722
+ // the dock unmounted, which React would warn about.
2723
+ let cancelled = false;
2724
+ let timer;
2725
+ const read = async () => {
2726
+ try {
2727
+ const payload = await api(`/session?id=${encodeURIComponent(sessionId)}`, { timeout: 6000 });
2728
+ if (cancelled) return;
2729
+ const next = payload && typeof payload.route === 'object' && payload.route !== null ? payload.route : null;
2730
+ setRoute(next);
2731
+ setFailed(false);
2732
+ } catch (cause) {
2733
+ if (!cancelled) setFailed(true);
2734
+ }
2735
+ if (!cancelled) timer = setTimeout(read, DOCK_POLL_MS);
2736
+ };
2737
+ void read();
2738
+ return () => {
2739
+ cancelled = true;
2740
+ clearTimeout(timer);
2741
+ };
2742
+ }, [sessionId, failed === false]);
2743
+
2744
+ if (route === null) return null;
2745
+
2746
+ /** First non-empty string among the candidates. */
2747
+ const pick = (...values) => {
2748
+ for (const value of values) {
2749
+ if (typeof value === 'string' && value !== '') return value;
2750
+ }
2751
+ return '';
2752
+ };
2753
+
2754
+ const provider = pick(route.provider);
2755
+ // Display names throughout: the ids live only in the hover title.
2756
+ const providerLabel = pick(route.providerName, route.provider);
2757
+ const modelLabel = pick(route.providerModelName, route.providerModel);
2758
+ const effortLabel = pick(route.effortName, route.effort);
2759
+ const requestedRaw = pick(route.requestedEffort);
2760
+ const requestedLabel = pick(route.requestedEffortName, route.requestedEffort);
2761
+ if (provider === '') return null;
2762
+
2763
+ // The pill shows names; the hover title adds the ids for anyone who needs
2764
+ // to know the exact route. One part per segment, joined with a separator.
2765
+ const titlePart = (name, id) => {
2766
+ if (name === '' && id === '') return '';
2767
+ if (name === '' || name === id) return id === '' ? name : id;
2768
+ if (id === '') return name;
2769
+ return `${name} (${id})`;
2770
+ };
2771
+ const titleParts = [
2772
+ titlePart(pick(route.providerName), pick(route.provider)),
2773
+ titlePart(pick(route.providerModelName), pick(route.providerModel)),
2774
+ titlePart(pick(route.effortName), pick(route.effort)),
2775
+ ].filter((part) => part !== '');
2776
+ // The adjustment fact still travels in the hover title, where it informs
2777
+ // without popping a box over the composer.
2778
+ if (requestedRaw !== '') titleParts.push(t('dockEffortMapped', { requested: requestedLabel, actual: effortLabel }));
2779
+ const title = titleParts.join(' · ');
2780
+ return h(
2781
+ 'span',
2782
+ { className: 'mp_dock', 'data-composer-stat': 'model-pool-route' },
2783
+ h(
2784
+ 'span',
2785
+ { className: 'mp_dockPill', title },
2786
+ h('span', { className: 'mp_dockSeg mp_dockRoute' }, providerLabel),
2787
+ modelLabel !== '' ? h('span', { className: 'mp_dockSep', 'aria-hidden': 'true' }, '·') : null,
2788
+ modelLabel !== '' ? h('span', { className: 'mp_dockSeg' }, modelLabel) : null,
2789
+ effortLabel !== '' ? h('span', { className: 'mp_dockSep', 'aria-hidden': 'true' }, '·') : null,
2790
+ // Only the tier that actually ran. An adjustment note is carried in the
2791
+ // pill's own title instead of a separate tooltip on this segment: the
2792
+ // segment is small, easy to hover by accident, and the record describes
2793
+ // the previous call — a floating box there reads as a complaint about
2794
+ // the current selection.
2795
+ effortLabel !== '' ? h('span', { className: 'mp_dockSeg' }, effortLabel) : null,
2796
+ ),
2797
+ );
2798
+ }
2799
+
2800
+ // ── registration ─────────────────────────────────────────────────────────
2801
+
2802
+ /**
2803
+ * Mount the Settings section.
2804
+ * @param {object} ctx - browser plugin context.
2805
+ * @returns {void}
2806
+ */
2807
+ function apply(ctx) {
2808
+ installStyles();
2809
+ const t = ctx.locale.bind(NS);
2810
+ ctx.effect(() => ctx.locale.register(NS, { en: EN, zh: ZH }), 'model-pool: dictionaries');
2811
+
2812
+ const form = ctx.configForms.get(ENTRY_ID);
2813
+
2814
+ ctx.slots.inject('settings.section', () =>
2815
+ ctx.slots.register(
2816
+ {
2817
+ name: 'settings.section',
2818
+ id: ENTRY_ID,
2819
+ order: 45,
2820
+ label: () => t('nav'),
2821
+ locale: NS,
2822
+ inject: () => ({ controller: form }),
2823
+ },
2824
+ SettingsPage,
2825
+ ),
2826
+ );
2827
+
2828
+ // A fresh dock entry beside the shipped `activity`/`usage` statistics. A
2829
+ // distinct id adds a control; reusing an id would replace that one.
2830
+ ctx.slots.inject('conversation.composer.dock', () =>
2831
+ ctx.slots.register(
2832
+ {
2833
+ name: 'conversation.composer.dock',
2834
+ id: 'model-pool-route',
2835
+ order: 2,
2836
+ label: () => t('dockRoute'),
2837
+ locale: NS,
2838
+ inject: () => ({ t }),
2839
+ },
2840
+ PoolRouteDock,
2841
+ ),
2842
+ );
2843
+ }
2844
+
2845
+ return { inject: ['slots', 'locale', 'configForms'], apply };
2846
+ },
2847
+ });