dsh-kenari-plugin 0.2.0 → 0.2.2

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/index.js CHANGED
@@ -188,13 +188,15 @@ window.__ModuleLoader__.load({
188
188
  'catalog.readonly': '设置只读,不能写入。',
189
189
  'catalog.settingsReadOnly': '设置只读,改动只能在配置文件里做。',
190
190
  'catalog.noNamespace': '设置里没有 {ns} 这一节,先让插件把预设写进去。',
191
+ 'catalog.writeRefused': '写入被拒绝,{count} 个模型没进设置。多半是设置刚被别处改过,重新打开面板再试一次。',
191
192
  'catalog.plansError': '套餐表读取失败,「套餐内」标签与筛选这次不可用:{message}',
192
193
  'catalog.empty': '当前筛选下没有模型。',
193
194
  'catalog.summary': '显示 {visible} / {total} 个,待加入 {addable} 个。「套餐内」表示这个付费模型由订阅套餐覆盖,否则走余额。',
194
195
  'catalog.footerNote': '加入后立即生效',
195
196
  'catalog.submit': '添加所选({count})',
196
197
  'catalog.saving': '正在加入…',
197
- 'catalog.added': '已加入 {count} 个模型。模型页的列表要重新展开「编辑」才会刷新。',
198
+ 'catalog.added': '已加入 {count} 个模型,编辑卡片已刷新。',
199
+ 'catalog.addedStale': '已加入 {count} 个模型。模型页的列表要重新展开「编辑」才会刷新。',
198
200
  'catalog.chatOnly': '(不支持会话)',
199
201
  'catalog.inRoute': '已在路由',
200
202
 
@@ -311,13 +313,15 @@ window.__ModuleLoader__.load({
311
313
  'catalog.readonly': 'Settings are read-only; nothing can be written.',
312
314
  'catalog.settingsReadOnly': 'Settings are read-only; changes have to go in the configuration file.',
313
315
  'catalog.noNamespace': 'Your settings have no {ns} section yet. Let the plugin write its preset first.',
316
+ 'catalog.writeRefused': 'The write was refused and {count} models did not reach your settings. Usually the document changed elsewhere; reopen the panel and try again.',
314
317
  'catalog.plansError': 'Could not read the plan table, so the "In plan" tag and filter are unavailable this time: {message}',
315
318
  'catalog.empty': 'No model matches these filters.',
316
319
  'catalog.summary': 'Showing {visible} of {total}, {addable} ready to add. "In plan" means a subscription covers this paid model; otherwise it is billed to your balance.',
317
320
  'catalog.footerNote': 'Applies immediately',
318
321
  'catalog.submit': 'Add selected ({count})',
319
322
  'catalog.saving': 'Adding…',
320
- 'catalog.added': 'Added {count} models. The list on the Models page refreshes once you reopen Edit.',
323
+ 'catalog.added': 'Added {count} models; the editor card was refreshed.',
324
+ 'catalog.addedStale': 'Added {count} models. The list on the Models page refreshes once you reopen Edit.',
321
325
  'catalog.chatOnly': '(not a chat model)',
322
326
  'catalog.inRoute': 'Already in the route',
323
327
 
@@ -360,6 +364,153 @@ window.__ModuleLoader__.load({
360
364
  */
361
365
  const TAKEOVER_LABELS = ['添加模型', 'Add model', '获取可用模型', 'Fetch available models']
362
366
 
367
+ /**
368
+ * The row's own editing toggle, in the locales this build ships.
369
+ *
370
+ * Clicking it is `setEditing(open ? undefined : target)`, and the card itself
371
+ * renders as `open ? <ProviderEditor/> : null` — so collapse unmounts the card
372
+ * and expand mounts a fresh one. Same identity problem as {@link TAKEOVER_LABELS}:
373
+ * the class is a hash and the button carries the same 编辑/Edit text in both
374
+ * locales, so text is what there is to match.
375
+ */
376
+ const EDIT_LABELS = ['编辑', 'Edit']
377
+
378
+ /**
379
+ * The card's footer pair. `apply` is dsh's own word for it — the English face
380
+ * says Apply, not Save — and it is these two that {@link parkFooterUnderKey}
381
+ * relocates and that this bundle takes over the submit half of.
382
+ */
383
+ const SUBMIT_LABELS = ['保存', 'Apply']
384
+ const CANCEL_LABELS = ['取消', 'Cancel']
385
+
386
+ /** The link that sends the route back to the shipped catalog. */
387
+ const RESET_LABELS = ['恢复默认模型', 'Restore defaults']
388
+
389
+ /**
390
+ * The prefix of each row's model-id field, in the locales this build ships
391
+ * (`ModelListEditor` labels them `${modelId} ${index + 1}`). Reading those
392
+ * fields back is how the remount below proves the card it re-seeded really
393
+ * carries the models that were just written.
394
+ */
395
+ const MODEL_ID_LABELS = ['模型 ID', 'Model ID']
396
+
397
+ /**
398
+ * dsh's per-row removal button, in the locales this build ships.
399
+ *
400
+ * Unlike every other label this bundle matches, this one has NO text: the
401
+ * button is an icon, so its aria-label (`${removeModel} ${index + 1}`) is the
402
+ * only identity it has.
403
+ */
404
+ const REMOVE_LABELS = ['删除模型', 'Delete model']
405
+
406
+ /** The route this plugin owns, as the Models page and the marker seat name it. */
407
+ const KENARI_PROVIDER = 'kenari'
408
+
409
+ /**
410
+ * The route's own fields, matched by their exact aria-labels (the model rows
411
+ * use the same words with a row number appended, so only an exact match is the
412
+ * card-level field). `path` is the key inside the route's settings profile.
413
+ */
414
+ const CARD_FIELDS = [
415
+ { labels: ['显示名称', 'Display name'], path: 'displayName' },
416
+ { labels: ['API 地址', 'Base URL'], path: 'baseURL' },
417
+ { labels: ['API 协议', 'API protocol'], path: 'api' },
418
+ ]
419
+
420
+ /**
421
+ * The per-row fields, matched by `${label} ${rowNumber}`. `model` is the key
422
+ * inside one entry of the route's `models` array, and `capacity` marks the two
423
+ * magnitudes, which are edited as K/M text rather than as a number.
424
+ */
425
+ const ROW_FIELDS = [
426
+ { labels: ['模型 ID', 'Model ID'], model: 'id' },
427
+ { labels: ['显示名称', 'Display name'], model: 'name' },
428
+ { labels: ['上下文窗口', 'Context window'], model: 'contextWindow', capacity: true },
429
+ { labels: ['最大输出 token', 'Max output tokens'], model: 'maxTokens', capacity: true },
430
+ ]
431
+
432
+ /** The value a field is set to when the user empties it: the key leaves the profile. */
433
+ const UNSET = Symbol('unset')
434
+
435
+ /** The credential reference a key typed into a route with none stored lands under. */
436
+ const DEFAULT_KEY_REF = 'KENARI_API_KEY'
437
+
438
+ /**
439
+ * What an aria-label names in the Kenari card, or null when it is not a field
440
+ * this bundle writes.
441
+ *
442
+ * Card fields are matched exactly and rows by `label + row number`: dsh labels
443
+ * a row's field with the row index it renders in, which is the index this
444
+ * bundle's writes use too.
445
+ * @param label - the input's aria-label.
446
+ * @returns the target, or null.
447
+ */
448
+ function fieldTargetOf(label) {
449
+ if (typeof label !== 'string' || label.length === 0) return null
450
+ for (let at = 0; at < CARD_FIELDS.length; at += 1) {
451
+ const field = CARD_FIELDS[at]
452
+ if (field.labels.indexOf(label) !== -1) return { where: 'card', path: field.path }
453
+ }
454
+ // Longest label first: 显示名称 is a prefix of nothing, but 模型 ID must not
455
+ // claim a row whose label merely starts with it (there is none today, and
456
+ // the trailing-number test makes that structural rather than incidental).
457
+ for (let index = 0; index < ROW_FIELDS.length; index += 1) {
458
+ const field = ROW_FIELDS[index]
459
+ for (let at = 0; at < field.labels.length; at += 1) {
460
+ const prefix = `${field.labels[at]} `
461
+ if (label.indexOf(prefix) !== 0) continue
462
+ const number = label.slice(prefix.length)
463
+ if (!/^[0-9]+$/.test(number)) continue
464
+ const row = Number(number)
465
+ if (row < 1) continue
466
+ return { where: 'row', model: field.model, index: row - 1, capacity: field.capacity === true }
467
+ }
468
+ }
469
+ return null
470
+ }
471
+
472
+ /**
473
+ * A capacity field's text as the number to store, or undefined when it is not
474
+ * a count this card can mean.
475
+ *
476
+ * dsh spells these as K/M text and counts K as 1000 (its own hint says 256K is
477
+ * 256000), so the same vocabulary is parsed here rather than a second one
478
+ * invented. An unreadable value is left alone: the card keeps showing what the
479
+ * user typed, and — since dsh's own 保存 no longer runs on this card — nothing
480
+ * silently stores a NaN.
481
+ * @param text - the field's current text.
482
+ * @returns the count, or undefined.
483
+ */
484
+ function capacityOf(text) {
485
+ if (typeof text !== 'string') return undefined
486
+ const trimmed = text.trim()
487
+ if (trimmed.length === 0) return UNSET
488
+ const match = /^(\d+(?:\.\d+)?)\s*([KkMm]?)$/.exec(trimmed)
489
+ if (match === null) return undefined
490
+ const scale = match[2] === '' ? 1 : match[2] === 'K' || match[2] === 'k' ? 1000 : 1000000
491
+ const value = Number(match[1]) * scale
492
+ return Number.isFinite(value) && value > 0 ? value : undefined
493
+ }
494
+
495
+ /**
496
+ * How many collapse/expand attempts the remount gets, and how long each
497
+ * waits for the page to paint, in milliseconds. The card is re-seeded from
498
+ * the Models page's own settings snapshot, which React re-renders
499
+ * asynchronously after the write — so the first attempt can legitimately land
500
+ * before it, and the wait is what a later one needs. The budget bounds a
501
+ * hopeless case at roughly a second, after which the caller says so.
502
+ */
503
+ const REFRESH_ATTEMPTS = 8
504
+ const REFRESH_RETRY_MS = 60
505
+
506
+ /**
507
+ * How long a burst of card edits is collected before it is written, in
508
+ * milliseconds. Long enough to swallow a run of clicks on adjacent rows and the
509
+ * keystrokes of one field, short enough that a single edit still looks
510
+ * immediate.
511
+ */
512
+ const CARD_WRITE_DEBOUNCE_MS = 120
513
+
363
514
  /**
364
515
  * Whether a button's label (already trimmed) belongs to this takeover. An
365
516
  * exact match, never a prefix: the Models page carries other 添加… buttons
@@ -921,8 +1072,45 @@ window.__ModuleLoader__.load({
921
1072
  * A `disabled` button never dispatches a click at all, so the native
922
1073
  * gates (adding while the editor is busy, fetching without a base URL) hold
923
1074
  * here too without this function re-reading any of them.
1075
+ *
1076
+ * The same listener carries the two other halves of the card's live editing:
1077
+ * `input`/`change` on its fields (each edit is written as it is made) and a
1078
+ * click on 保存 (which, with every other field writing itself, is left holding
1079
+ * only the API key).
924
1080
  */
925
- function installEntryTakeover() {
1081
+ function installEntryTakeover(hooks) {
1082
+ const onRemove = hooks !== null && hooks !== undefined && typeof hooks.onRemove === 'function'
1083
+ ? hooks.onRemove
1084
+ : null
1085
+ const onField = hooks !== null && hooks !== undefined && typeof hooks.onField === 'function'
1086
+ ? hooks.onField
1087
+ : null
1088
+ const onKey = hooks !== null && hooks !== undefined && typeof hooks.onKey === 'function'
1089
+ ? hooks.onKey
1090
+ : null
1091
+ const onReset = hooks !== null && hooks !== undefined && typeof hooks.onReset === 'function'
1092
+ ? hooks.onReset
1093
+ : null
1094
+
1095
+ /** One field edit, read off whichever input the event came from. */
1096
+ const onFieldEvent = (event) => {
1097
+ if (takeoverBypassed || !pickerChannel.mounted) return
1098
+ const field = event.target
1099
+ if (field === null || field === undefined || typeof field.getAttribute !== 'function') return
1100
+ const card = cardWithMarker(field)
1101
+ if (card === null) return
1102
+ // The key is not written here — it is what the card's 保存 commits — but
1103
+ // its content is what decides whether that button is offered at all.
1104
+ if (field.type === 'password') {
1105
+ syncFooterVisibility(card)
1106
+ return
1107
+ }
1108
+ if (onField === null) return
1109
+ const target = fieldTargetOf(ariaLabelOf(field))
1110
+ if (target === null) return
1111
+ onField(card, target, typeof field.value === 'string' ? field.value : '')
1112
+ }
1113
+
926
1114
  const onClickCapture = (event) => {
927
1115
  if (takeoverBypassed || !pickerChannel.mounted) return
928
1116
  const target = event.target
@@ -930,16 +1118,567 @@ window.__ModuleLoader__.load({
930
1118
  const button = target.closest('button')
931
1119
  if (button === null) return
932
1120
  const label = typeof button.textContent === 'string' ? button.textContent.trim() : ''
1121
+ // dsh's own 编辑 toggle, riding along on this listener because the fold it
1122
+ // mounts is where this plugin's entry points live. Nothing is prevented or
1123
+ // stopped here: the click has to reach dsh for the card to open at all.
1124
+ if (EDIT_LABELS.indexOf(label) !== -1) {
1125
+ if (cardWithMarker(button) !== null) void revealEditorFoldSoon()
1126
+ return
1127
+ }
1128
+ const card = cardWithMarker(button)
1129
+ // 恢复默认模型, and 保存: both are dsh actions this plugin takes over rather
1130
+ // than lets run. The reset drops the route's stored array (the card then
1131
+ // re-mounts showing the shipped presets, exactly as dsh's draft edit
1132
+ // looked), and 保存 — which the card now shows under the key — commits the
1133
+ // typed key, because everything else on the card has already been written.
1134
+ if (card !== null && RESET_LABELS.indexOf(label) !== -1 && onReset !== null) {
1135
+ event.preventDefault()
1136
+ event.stopPropagation()
1137
+ onReset(card)
1138
+ return
1139
+ }
1140
+ if (card !== null && SUBMIT_LABELS.indexOf(label) !== -1 && onKey !== null) {
1141
+ const input = keyInputOf(card)
1142
+ const value = input === null || typeof input.value !== 'string' ? '' : input.value
1143
+ event.preventDefault()
1144
+ event.stopPropagation()
1145
+ onKey(card, value)
1146
+ return
1147
+ }
1148
+ // dsh's per-row removal, taken over the same way 添加模型 is — but here the
1149
+ // click is deliberately NOT prevented, because the card has to drop the row
1150
+ // itself for the click to look immediate. What this adds is the write: the
1151
+ // stored route loses the model now instead of at 保存, so a deletion lands
1152
+ // exactly like an addition does, and the draft that dsh just edited is
1153
+ // re-seeded from the document a moment later.
1154
+ if (card !== null && onRemove !== null) {
1155
+ const removalRow = removalRowOf(button, card)
1156
+ if (removalRow !== -1) {
1157
+ const shown = cardModelIds(card)
1158
+ const removed = shown === null ? undefined : shown[removalRow]
1159
+ if (typeof removed === 'string' && removed.length > 0) onRemove(card, removed)
1160
+ return
1161
+ }
1162
+ }
933
1163
  if (!isTakeoverLabel(label)) return
934
- if (cardWithMarker(button) === null) return
1164
+ if (card === null) return
935
1165
  event.preventDefault()
936
1166
  event.stopPropagation()
937
1167
  pickerChannel.open(button)
938
1168
  }
939
1169
  document.addEventListener('click', onClickCapture, true)
1170
+ document.addEventListener('input', onFieldEvent, true)
1171
+ document.addEventListener('change', onFieldEvent, true)
940
1172
  return () => {
941
1173
  document.removeEventListener('click', onClickCapture, true)
1174
+ document.removeEventListener('input', onFieldEvent, true)
1175
+ document.removeEventListener('change', onFieldEvent, true)
1176
+ }
1177
+ }
1178
+
1179
+ /** A node's aria-label, or '' when it has none. */
1180
+ function ariaLabelOf(node) {
1181
+ if (node === null || node === undefined || typeof node.getAttribute !== 'function') return ''
1182
+ const label = node.getAttribute('aria-label')
1183
+ return typeof label === 'string' ? label : ''
1184
+ }
1185
+
1186
+ /** Whether an already-read aria-label is one of dsh's per-row removal buttons. */
1187
+ function isRemoveLabel(label) {
1188
+ for (let at = 0; at < REMOVE_LABELS.length; at += 1) {
1189
+ if (label.indexOf(`${REMOVE_LABELS[at]} `) === 0) return true
942
1190
  }
1191
+ return false
1192
+ }
1193
+
1194
+ /**
1195
+ * Which model row a removal button owns, or -1 when this click is not one of
1196
+ * dsh's removal buttons.
1197
+ *
1198
+ * Counted from the card's own removal buttons, which `ModelListEditor` renders
1199
+ * one per row in row order — the order {@link cardModelIds} reads, so the two
1200
+ * indexes name the same row. Counting beats parsing the trailing number out of
1201
+ * the aria-label: the label's own numbering would be another dsh detail to
1202
+ * track, while the DOM here is already the source of the rows.
1203
+ * @param button - the clicked button.
1204
+ * @param card - the provider's card element.
1205
+ * @returns the row index, or -1.
1206
+ */
1207
+ function removalRowOf(button, card) {
1208
+ if (!isRemoveLabel(ariaLabelOf(button))) return -1
1209
+ if (card === null || card === undefined || typeof card.querySelectorAll !== 'function') return -1
1210
+ const buttons = card.querySelectorAll('button')
1211
+ if (buttons === null || buttons === undefined || typeof buttons.length !== 'number') return -1
1212
+ let index = 0
1213
+ for (let at = 0; at < buttons.length; at += 1) {
1214
+ const node = buttons[at]
1215
+ if (node === button) return index
1216
+ if (isRemoveLabel(ariaLabelOf(node))) index += 1
1217
+ }
1218
+ return -1
1219
+ }
1220
+
1221
+ /**
1222
+ * The list left after dropping one model id, in order.
1223
+ *
1224
+ * Filters by id rather than by index: the row the user clicked is named by
1225
+ * what that row shows, and a stored array whose order drifted must not make
1226
+ * the click delete its neighbour.
1227
+ * @param models - the stored model entries.
1228
+ * @param id - the id to drop.
1229
+ * @returns a new list without it.
1230
+ */
1231
+ function withoutModel(models, id) {
1232
+ const kept = []
1233
+ for (let at = 0; at < models.length; at += 1) {
1234
+ const model = models[at]
1235
+ if (model !== null && model !== undefined && model.id === id) continue
1236
+ kept.push(model)
1237
+ }
1238
+ return kept
1239
+ }
1240
+
1241
+ /**
1242
+ * One route model array with the edited rows' fields applied.
1243
+ *
1244
+ * Each row is found by the id its card row shows, so an edit follows the row
1245
+ * the user typed into rather than a position that may have moved. The id is
1246
+ * also the one field whose edit erases that link — the draft then names an id
1247
+ * the document does not have — and only that case (and only while both lists
1248
+ * still have the same length) falls back to the row's position.
1249
+ *
1250
+ * Entries are copied, never rebuilt: this card edits four fields of a model
1251
+ * and knows nothing about the rest of them, so anything it does not edit has
1252
+ * to survive the write.
1253
+ * @param models - the stored model entries.
1254
+ * @param draftIds - the ids the card's rows show, or null when they cannot be read.
1255
+ * @param rows - edited row index to the fields changed in it.
1256
+ * @returns a new array, or the same one when nothing changed.
1257
+ */
1258
+ function patchModels(models, draftIds, rows) {
1259
+ if (rows === null || rows === undefined || typeof rows.size !== 'number' || rows.size === 0) return models
1260
+ const next = models.map((model) => (model !== null && typeof model === 'object' ? { ...model } : model))
1261
+ let touched = false
1262
+ for (const [index, patch] of rows) {
1263
+ let target = -1
1264
+ const draftId = draftIds !== null && index < draftIds.length ? draftIds[index] : undefined
1265
+ if (typeof draftId === 'string' && draftId.length > 0) {
1266
+ for (let at = 0; at < next.length; at += 1) {
1267
+ const entry = next[at]
1268
+ if (entry !== null && entry !== undefined && typeof entry === 'object' && entry.id === draftId) {
1269
+ target = at
1270
+ break
1271
+ }
1272
+ }
1273
+ }
1274
+ if (target === -1 && draftIds !== null && draftIds.length === next.length) target = index
1275
+ const entry = target === -1 ? undefined : next[target]
1276
+ if (entry === null || entry === undefined || typeof entry !== 'object') continue
1277
+ for (const key of Object.keys(patch)) {
1278
+ const value = patch[key]
1279
+ if (value === UNSET) {
1280
+ if (key in entry) {
1281
+ delete entry[key]
1282
+ touched = true
1283
+ }
1284
+ continue
1285
+ }
1286
+ if (entry[key] !== value) {
1287
+ entry[key] = value
1288
+ touched = true
1289
+ }
1290
+ }
1291
+ }
1292
+ return touched ? next : models
1293
+ }
1294
+
1295
+ /**
1296
+ * The row's editing toggle, found from the row a taken-over button sits in.
1297
+ *
1298
+ * Scoped to `closest('li')` — the same per-provider list item the marker
1299
+ * lookup uses — so a sibling provider's identical 编辑 button is never claimed.
1300
+ * @param row - the provider's list item, or null.
1301
+ * @returns the toggle button, or null when this DOM is not the shape we know.
1302
+ */
1303
+ function editToggleOf(row) {
1304
+ if (row === null || row === undefined || typeof row.querySelectorAll !== 'function') return null
1305
+ const buttons = row.querySelectorAll('button')
1306
+ for (let index = 0; index < buttons.length; index += 1) {
1307
+ const node = buttons[index]
1308
+ const label = typeof node.textContent === 'string' ? node.textContent.trim() : ''
1309
+ if (EDIT_LABELS.indexOf(label) !== -1) return node
1310
+ }
1311
+ return null
1312
+ }
1313
+
1314
+ /**
1315
+ * The model ids the card's own rows currently show, or null when `row` is
1316
+ * not the shape this bundle knows.
1317
+ *
1318
+ * Read straight from the row inputs rather than from any snapshot: it is the
1319
+ * DOM the user is looking at that has to carry the answer.
1320
+ * @param row - the provider's list item, or null.
1321
+ * @returns the ids in row order, or null when the row cannot be read.
1322
+ */
1323
+ function cardModelIds(row) {
1324
+ if (row === null || row === undefined || typeof row.querySelectorAll !== 'function') return null
1325
+ const inputs = row.querySelectorAll('input')
1326
+ if (inputs === null || inputs === undefined || typeof inputs.length !== 'number') return null
1327
+ const ids = []
1328
+ for (let index = 0; index < inputs.length; index += 1) {
1329
+ const node = inputs[index]
1330
+ if (node === null || node === undefined) continue
1331
+ const label = typeof node.getAttribute === 'function' ? node.getAttribute('aria-label') : null
1332
+ if (typeof label !== 'string') continue
1333
+ for (let at = 0; at < MODEL_ID_LABELS.length; at += 1) {
1334
+ if (label.indexOf(MODEL_ID_LABELS[at]) === 0) {
1335
+ ids.push(typeof node.value === 'string' ? node.value : '')
1336
+ break
1337
+ }
1338
+ }
1339
+ }
1340
+ return ids
1341
+ }
1342
+
1343
+ /**
1344
+ * Let the page paint the state this plugin just changed. The remount below
1345
+ * has to wait for React, and a fixed delay is the only thing that can: the
1346
+ * work being waited on is another component's render, which no DOM event
1347
+ * announces.
1348
+ * @returns a promise settling after one retry interval.
1349
+ */
1350
+ function afterPaint() {
1351
+ return new Promise((resolve) => {
1352
+ setTimeout(resolve, REFRESH_RETRY_MS)
1353
+ })
1354
+ }
1355
+
1356
+ /**
1357
+ * Re-open the card's 自定义设置 fold, which the remount re-creates closed.
1358
+ *
1359
+ * The models this plugin adds live inside that fold, so without this the
1360
+ * list refreshes out of sight and the user still has to expand it to see
1361
+ * what they added. `open` is the browser's own attribute here — React renders
1362
+ * this `<details>` without controlling it, so setting it is not a state
1363
+ * write it would undo.
1364
+ * @param row - the provider's list item.
1365
+ */
1366
+ function openEditorFold(row) {
1367
+ if (row === null || row === undefined || typeof row.querySelector !== 'function') return
1368
+ const fold = row.querySelector('details')
1369
+ if (fold !== null && fold !== undefined) fold.open = true
1370
+ }
1371
+
1372
+ /** The card's API-key field: the one password input it renders. */
1373
+ function keyInputOf(card) {
1374
+ if (card === null || card === undefined || typeof card.querySelectorAll !== 'function') return null
1375
+ const inputs = card.querySelectorAll('input')
1376
+ if (inputs === null || inputs === undefined || typeof inputs.length !== 'number') return null
1377
+ for (let at = 0; at < inputs.length; at += 1) {
1378
+ if (inputs[at] !== null && inputs[at] !== undefined && inputs[at].type === 'password') return inputs[at]
1379
+ }
1380
+ return null
1381
+ }
1382
+
1383
+ /**
1384
+ * The card's action row: the smallest ancestor of the commit button that also
1385
+ * holds the dismiss one. Its class is a CSS-module hash; this shape is not.
1386
+ * @param card - the provider's card element.
1387
+ * @returns the row, or null when the card is not the shape this bundle knows.
1388
+ */
1389
+ function footerOf(card) {
1390
+ if (card === null || card === undefined || typeof card.querySelectorAll !== 'function') return null
1391
+ const buttons = card.querySelectorAll('button')
1392
+ if (buttons === null || buttons === undefined || typeof buttons.length !== 'number') return null
1393
+ let submit = null
1394
+ let cancel = null
1395
+ for (let at = 0; at < buttons.length; at += 1) {
1396
+ const node = buttons[at]
1397
+ if (node === null || node === undefined) continue
1398
+ const label = typeof node.textContent === 'string' ? node.textContent.trim() : ''
1399
+ if (submit === null && SUBMIT_LABELS.indexOf(label) !== -1) submit = node
1400
+ if (cancel === null && CANCEL_LABELS.indexOf(label) !== -1) cancel = node
1401
+ }
1402
+ if (submit === null || cancel === null) return null
1403
+ let footer = submit.parentElement
1404
+ while (footer !== null && footer !== undefined && footer !== card
1405
+ && typeof footer.contains === 'function' && !footer.contains(cancel)) {
1406
+ footer = footer.parentElement
1407
+ }
1408
+ return footer === null || footer === undefined || footer === card ? null : footer
1409
+ }
1410
+
1411
+ /**
1412
+ * Show the card's 取消/保存 only while the key field holds something.
1413
+ *
1414
+ * Those two buttons now commit exactly one thing — the key — so with the field
1415
+ * empty they offer a commit of nothing, and the card is cleaner without them.
1416
+ * dsh never pre-fills the field (its placeholder is 已配置——输入新值可替换),
1417
+ * so a card opens with them hidden and they appear on the first keystroke.
1418
+ *
1419
+ * `hidden` would lose to dsh's own `display` rule for that row, hence the
1420
+ * inline style, cleared rather than set to a literal so the row keeps looking
1421
+ * the way dsh drew it.
1422
+ * @param card - the provider's card element.
1423
+ */
1424
+ function syncFooterVisibility(card) {
1425
+ const footer = footerOf(card)
1426
+ if (footer === null || footer.style === null || footer.style === undefined) return
1427
+ const input = keyInputOf(card)
1428
+ const typed = input !== null && typeof input.value === 'string' && input.value.trim().length > 0
1429
+ footer.style.display = typed ? '' : 'none'
1430
+ }
1431
+
1432
+ /**
1433
+ * Put the card's 取消/保存 directly under the API-key field.
1434
+ *
1435
+ * Everything else on this card writes itself now — the model list on add and
1436
+ * delete, and every other field on its own edit — so the pair that used to
1437
+ * mean "commit this whole card" only has the key left to commit. Moving it
1438
+ * there is what makes that read off the screen instead of needing an
1439
+ * explanation.
1440
+ *
1441
+ * A move, not a copy: dsh's own nodes keep their own handlers, and React only
1442
+ * re-creates them when it re-creates the editor — at which point this runs
1443
+ * again from {@link arrangeCard}. Nothing is removed from the DOM, so there is
1444
+ * no node React would later fail to remove.
1445
+ * @param card - the provider's card element.
1446
+ */
1447
+ function parkFooterUnderKey(card) {
1448
+ const footer = footerOf(card)
1449
+ if (footer === null) return
1450
+ const keyInput = keyInputOf(card)
1451
+ const keyField = keyInput === null ? null : keyInput.parentElement
1452
+ if (keyField === null || keyField === undefined || keyField.parentElement === null) return
1453
+ if (keyField.nextElementSibling !== footer) keyField.parentElement.insertBefore(footer, keyField.nextElementSibling)
1454
+ syncFooterVisibility(card)
1455
+ }
1456
+
1457
+ /**
1458
+ * Put one card's editor into the shape this plugin's copy describes: the model
1459
+ * list unfolded, and the footer under the key it now belongs to.
1460
+ *
1461
+ * Called from both places the editor is (re)built: the 编辑 click dsh handles
1462
+ * itself, and this plugin's own remount after a write.
1463
+ * @param row - the provider's list item.
1464
+ */
1465
+ function arrangeCard(row) {
1466
+ openEditorFold(row)
1467
+ if (row !== null && row !== undefined) parkFooterUnderKey(row)
1468
+ }
1469
+
1470
+ /**
1471
+ * The folds this bundle has already revealed, one entry per editor mount.
1472
+ *
1473
+ * React re-creates the `<details>` every time the card reopens, so a fresh
1474
+ * element is revealed again while the one a user collapsed by hand is left
1475
+ * alone — the memory has to be per element, not per card.
1476
+ */
1477
+ const revealedFolds = new WeakSet()
1478
+
1479
+ /**
1480
+ * Open one card's 自定义设置 fold, once per element.
1481
+ *
1482
+ * Why it is needed: that fold holds the model list AND the two buttons this
1483
+ * plugin takes over, and dsh renders it closed. A card opened by hand
1484
+ * therefore buries the list — and this plugin's way in — one click deep,
1485
+ * every single time. Opening it as the editor appears is what makes the
1486
+ * models visible on the spot.
1487
+ *
1488
+ * Once per element, deliberately: the user's collapse is theirs to make, and
1489
+ * re-opening it on every mutation would fight them out of the control.
1490
+ * @param card - the provider's card element, or null.
1491
+ */
1492
+ function revealFoldOf(card) {
1493
+ if (card === null || card === undefined || typeof card.querySelector !== 'function') return
1494
+ const fold = card.querySelector('details')
1495
+ if (fold === null || fold === undefined) return
1496
+ if (revealedFolds.has(fold)) return
1497
+ revealedFolds.add(fold)
1498
+ fold.open = true
1499
+ }
1500
+
1501
+ /** Reveal the mounted Kenari card's fold; a no-op while no card is on screen. */
1502
+ function revealEditorFold() {
1503
+ const card = markedCard()
1504
+ revealFoldOf(card)
1505
+ parkFooterUnderKey(card)
1506
+ }
1507
+
1508
+ /**
1509
+ * Reveal the fold of the editor a click just toggled.
1510
+ *
1511
+ * The card's own 编辑 toggle is dsh's button, so the editor it mounts does
1512
+ * not exist yet when the click is handled: the reveal is retried across one
1513
+ * paint. A toggle that closed the card instead finds no fold and does
1514
+ * nothing, which is what keeps a hand-collapsed fold from snapping back.
1515
+ * @returns settlement after both reveal attempts.
1516
+ */
1517
+ async function revealEditorFoldSoon() {
1518
+ revealEditorFold()
1519
+ await afterPaint()
1520
+ revealEditorFold()
1521
+ }
1522
+
1523
+ /** Whether the card's editor is mounted right now. */
1524
+ function editorMounted(row) {
1525
+ return row !== null
1526
+ && row !== undefined
1527
+ && typeof row.querySelector === 'function'
1528
+ && row.querySelector('details') !== null
1529
+ }
1530
+
1531
+ /**
1532
+ * Leave one card open, with its model list unfolded.
1533
+ *
1534
+ * Called after a remount, including a remount whose list never agreed: the
1535
+ * user opened this picker from an expanded card, and collapsing it would
1536
+ * hide the very list the dialog's copy is talking about. Re-open is a click
1537
+ * on the same toggle (the card is only ever closed by one), and the fold is
1538
+ * re-opened because the remount re-creates it closed.
1539
+ * @param row - the provider's list item.
1540
+ * @param wasOpen - whether the editor was mounted when the work began.
1541
+ * @returns settlement after the card has been re-opened.
1542
+ */
1543
+ async function leaveEditorOpen(row, wasOpen) {
1544
+ // Only a card that was open to begin with is restored: a user who collapsed
1545
+ // it while the write was in flight asked for it closed, and a deletion must
1546
+ // not fight that the way a dialog's own success may override a stale draft.
1547
+ if (wasOpen && !editorMounted(row)) {
1548
+ const toggle = editToggleOf(row)
1549
+ if (toggle !== null && typeof toggle.click === 'function') {
1550
+ toggle.click()
1551
+ await afterPaint()
1552
+ }
1553
+ }
1554
+ arrangeCard(row)
1555
+ }
1556
+
1557
+ /**
1558
+ * Re-mount the editing card this bundle's picker was opened from, and report
1559
+ * success only once its list actually lists what was just written.
1560
+ *
1561
+ * Why it is needed: the card seeds its model list once, at mount, and
1562
+ * deliberately does not follow a pushed settings refresh — that is what keeps
1563
+ * a half-typed API key from being overwritten. A write made from this plugin's
1564
+ * modal therefore lands *behind* the card, and its list would stay stale until
1565
+ * something remounted it. Collapse-then-expand IS that remount, and it is the
1566
+ * card's own control rather than a synthetic dialog close.
1567
+ *
1568
+ * The draft it discards was already invalid: the card's revision fence is stale
1569
+ * after this plugin's write, so its next save would be refused as stale anyway.
1570
+ *
1571
+ * Why the result is checked instead of assumed: the remount re-seeds from the
1572
+ * Models page's snapshot, and a single collapse/expand issued the moment the
1573
+ * write resolves can run before React has re-rendered that page. The card then
1574
+ * looks refreshed and still lists the OLD models — the exact bug this exists to
1575
+ * fix, wearing the success copy. So each attempt is compared against the ids it
1576
+ * must show, spaced by a paint, until it agrees or the budget runs out.
1577
+ *
1578
+ * Fail-open — no toggle found (a dsh release renamed it), or a list that never
1579
+ * agrees, returns false and the caller falls back to the copy that names the
1580
+ * manual way. Either way the card is left open with its fold unfolded, since
1581
+ * the user came here from an expanded card — a collapsed one would hide both
1582
+ * the list and the copy talking about it.
1583
+ * @param button - the native button the takeover intercepted.
1584
+ * @param addedIds - the model ids the card's list must contain to count as refreshed.
1585
+ * @returns whether the card now lists every added id.
1586
+ */
1587
+ async function refreshProviderEditor(button, addedIds) {
1588
+ if (button === null || button === undefined || typeof button.closest !== 'function') return false
1589
+ const wanted = Array.isArray(addedIds) ? addedIds.filter((id) => typeof id === 'string') : []
1590
+ // Resolved ONCE, before anything is clicked: the button that opened the
1591
+ // picker lives INSIDE the editor, so collapsing detaches it and every later
1592
+ // `button.closest('li')` would answer null. The row itself survives — dsh
1593
+ // keys it by provider and only the editor inside it comes and goes.
1594
+ const row = button.closest('li')
1595
+ if (row === null || row === undefined) return false
1596
+ return remountEditorWhile(row, (shown) => wanted.every((id) => shown.indexOf(id) !== -1))
1597
+ }
1598
+
1599
+ /**
1600
+ * Collapse and expand one card until its list agrees, then leave it as the
1601
+ * user had it.
1602
+ *
1603
+ * The loop is shared by both writes this plugin makes to a route: an addition
1604
+ * wants its ids to appear, a removal wants its id to be gone, and everything
1605
+ * else — the pair of clicks, the paint between them, the budget, the row
1606
+ * resolved once, the fold re-opened — is the same either way.
1607
+ * @param row - the provider's list item.
1608
+ * @param agrees - reads the ids the card now lists and answers whether the change is on screen.
1609
+ * @returns whether the card ever agreed.
1610
+ */
1611
+ /**
1612
+ * Every scroller above one node, with where each is scrolled to.
1613
+ *
1614
+ * Recorded because a remount collapses the card first, and a collapse shortens
1615
+ * the page: the browser clamps a scroller's `scrollTop` to the shorter content
1616
+ * the moment it shrinks, so by the time the card is expanded again the position
1617
+ * is already gone. Reading it before the first click is the only way to put it
1618
+ * back.
1619
+ * @param node - the element the card's scrolling is measured from.
1620
+ * @returns the positions, in the order they were found.
1621
+ */
1622
+ function scrollMarksOf(node) {
1623
+ const marks = []
1624
+ let parent = node === null || node === undefined ? null : node.parentElement
1625
+ while (parent !== null && parent !== undefined) {
1626
+ if (typeof parent.scrollTop === 'number'
1627
+ && (parent.scrollTop > 0 || parent.scrollHeight > parent.clientHeight)) {
1628
+ marks.push({ node: parent, top: parent.scrollTop, left: parent.scrollLeft })
1629
+ }
1630
+ parent = parent.parentElement
1631
+ }
1632
+ const root = typeof document === 'undefined' ? null : document.scrollingElement
1633
+ if (root !== null && root !== undefined && typeof root.scrollTop === 'number') {
1634
+ marks.push({ node: root, top: root.scrollTop, left: root.scrollLeft })
1635
+ }
1636
+ return marks
1637
+ }
1638
+
1639
+ /**
1640
+ * Put every recorded scroller back where it was.
1641
+ *
1642
+ * Called after the card has been expanded again, and again after the fold is
1643
+ * re-opened: the fold changes the height too, so restoring before that would
1644
+ * be clamped all over again.
1645
+ * @param marks - what {@link scrollMarksOf} returned.
1646
+ */
1647
+ function restoreScroll(marks) {
1648
+ for (let at = 0; at < marks.length; at += 1) {
1649
+ const mark = marks[at]
1650
+ if (typeof mark.node.scrollTop !== 'number') continue
1651
+ mark.node.scrollTop = mark.top
1652
+ mark.node.scrollLeft = mark.left
1653
+ }
1654
+ }
1655
+
1656
+ async function remountEditorWhile(row, agrees) {
1657
+ const wasOpen = editorMounted(row)
1658
+ const marks = scrollMarksOf(row)
1659
+ let agreed = false
1660
+ for (let attempt = 0; attempt < REFRESH_ATTEMPTS && !agreed; attempt += 1) {
1661
+ const toggle = editToggleOf(row)
1662
+ if (toggle === null || typeof toggle.click !== 'function') break
1663
+ toggle.click()
1664
+ // The second click must land after React commits the first: collapse is a
1665
+ // state update, and re-reading `open` inside the same click would expand
1666
+ // nothing. The row node survives the collapse (it is keyed by provider), so
1667
+ // the toggle is looked up again there instead of reusing a detached node.
1668
+ await afterPaint()
1669
+ const again = editToggleOf(row)
1670
+ if (again !== null && typeof again.click === 'function') again.click()
1671
+ await afterPaint()
1672
+ const shown = cardModelIds(row)
1673
+ if (shown === null) break
1674
+ agreed = agrees(shown)
1675
+ restoreScroll(marks)
1676
+ }
1677
+ await leaveEditorOpen(row, wasOpen)
1678
+ // After the fold, not before: re-opening it changes the height too, and a
1679
+ // position restored under it would be clamped a second time.
1680
+ restoreScroll(marks)
1681
+ return agreed
943
1682
  }
944
1683
 
945
1684
  /**
@@ -1097,7 +1836,7 @@ window.__ModuleLoader__.load({
1097
1836
  * because a catalog browser that cannot add anything just duplicated it.
1098
1837
  */
1099
1838
  function useCatalogPanel(props) {
1100
- const { routeNs, routeProvider, loadPanel, addModels } = props
1839
+ const { routeNs, routeProvider, loadPanel, addModels, onAdded, onComplete } = props
1101
1840
  const [state, setState] = React.useState({ status: 'loading' })
1102
1841
  const [reloads, setReloads] = React.useState(0)
1103
1842
  const [query, setQuery] = React.useState('')
@@ -1159,14 +1898,40 @@ window.__ModuleLoader__.load({
1159
1898
  if (profiles.length === 0 || routeNs === undefined || routeProvider === undefined) return
1160
1899
  setWrite({ status: 'saving' })
1161
1900
  addModels({ settingsNs: routeNs, provider: routeProvider }, profiles).then(
1162
- (result) => {
1163
- setWrite(result.ok === true
1164
- ? { status: 'added', message: t('catalog.added', { count: profiles.length }) }
1165
- : { status: 'error', message: result.message })
1166
- if (result.ok === true) {
1167
- setPicked([])
1168
- setReloads((current) => current + 1)
1901
+ async (result) => {
1902
+ if (result.ok !== true) {
1903
+ setWrite({ status: 'error', message: result.message })
1904
+ return
1905
+ }
1906
+ // The card behind this modal seeds its list once, at mount, and does not
1907
+ // follow pushed settings refreshes — so it has to be remounted for the
1908
+ // write to show up in it. `onAdded` answers whether the card now really
1909
+ // lists those ids; its own verification is what keeps this copy honest,
1910
+ // because a remount that ran too early looks identical from here.
1911
+ let remounted = false
1912
+ if (typeof onAdded === 'function') {
1913
+ try {
1914
+ remounted = (await onAdded(profiles.map((profile) => profile.id))) === true
1915
+ } catch (_remountFailure) {
1916
+ remounted = false
1917
+ }
1169
1918
  }
1919
+ // Confirmed on the card itself: the rows are already on screen behind
1920
+ // this dialog, so the dialog has nothing left to say and gets out of
1921
+ // the way. An unconfirmed refresh keeps it open, because then its copy
1922
+ // is the only thing that can tell the user to reopen the card.
1923
+ if (remounted && typeof onComplete === 'function') {
1924
+ onComplete()
1925
+ return
1926
+ }
1927
+ setWrite({
1928
+ status: 'added',
1929
+ message: remounted
1930
+ ? t('catalog.added', { count: profiles.length })
1931
+ : t('catalog.addedStale', { count: profiles.length }),
1932
+ })
1933
+ setPicked([])
1934
+ setReloads((current) => current + 1)
1170
1935
  },
1171
1936
  (err) => setWrite({ status: 'error', message: String(err && err.message ? err.message : err) }),
1172
1937
  )
@@ -1316,7 +2081,18 @@ window.__ModuleLoader__.load({
1316
2081
  */
1317
2082
  function ModelCatalogModal(props) {
1318
2083
  const { onClose, nativeButton, loadPanel, addModels, routeNs, routeProvider } = props
1319
- const panel = useCatalogPanel({ routeNs, routeProvider, loadPanel, addModels })
2084
+ const panel = useCatalogPanel({
2085
+ routeNs,
2086
+ routeProvider,
2087
+ loadPanel,
2088
+ addModels,
2089
+ // The card this modal was opened from is the row's editing card; remounting
2090
+ // it is what makes the freshly written models visible in its list. The ids
2091
+ // travel with the ask so the remount can check its own work, and a confirmed
2092
+ // refresh closes this dialog rather than reporting what the card now shows.
2093
+ onAdded: (addedIds) => refreshProviderEditor(nativeButton, addedIds),
2094
+ onComplete: onClose,
2095
+ })
1320
2096
 
1321
2097
  /**
1322
2098
  * The way back out: close this dialog, then re-click the button dsh
@@ -1423,7 +2199,7 @@ window.__ModuleLoader__.load({
1423
2199
  // picker is about Kenari, so another pi-ai route renders nothing — not
1424
2200
  // even the marker, which is what keeps the takeover from claiming that
1425
2201
  // route's otherwise identical button.
1426
- if (props.provider === undefined || props.provider.provider !== 'kenari') return null
2202
+ if (props.provider === undefined || props.provider.provider !== KENARI_PROVIDER) return null
1427
2203
  return React.createElement(
1428
2204
  React.Fragment,
1429
2205
  null,
@@ -1773,6 +2549,11 @@ window.__ModuleLoader__.load({
1773
2549
  */
1774
2550
  function apply(ctx) {
1775
2551
  const scope = ctx.settingsScope.bind({ namespace: NS })
2552
+ // The route's own scope, held beside this plugin's: writes to the pi-ai
2553
+ // section go through it rather than through a bare `settings.mutate`, and
2554
+ // that is what folds the write answer into the shared settings mirror the
2555
+ // Models page reads. See `addModels`.
2556
+ const routeScope = ctx.settingsScope.bind({ namespace: PI_AI_NS })
1776
2557
 
1777
2558
  // Bind the translator before anything can render: the components below
1778
2559
  // call it, and every one of them is reachable from here on.
@@ -1882,6 +2663,16 @@ window.__ModuleLoader__.load({
1882
2663
  * list that is currently in effect (user layer when it owns the array,
1883
2664
  * else the composition layer the patch pinned) and only append. Without
1884
2665
  * that, adding one paid model would silently drop the free presets.
2666
+ *
2667
+ * The write goes through this route's settings scope, not a bare
2668
+ * `settings.mutate`: the scope folds the write answer into the shared
2669
+ * settings mirror the Models page reads, so the editing card this plugin
2670
+ * remounts right after reads the new list. A bare wire write would leave
2671
+ * that mirror waiting for the Host's push, which the remount can outrun.
2672
+ *
2673
+ * The scope reports no failure — a refused or unanswered write reloads the
2674
+ * mirror and settles — so success is decided by the document afterwards,
2675
+ * never by what this function assumed it wrote.
1885
2676
  */
1886
2677
  const addModels = async (route, profiles) => {
1887
2678
  const read = await readPiAi()
@@ -1894,13 +2685,249 @@ window.__ModuleLoader__.load({
1894
2685
  if (model && typeof model.id === 'string') byId.set(model.id, model)
1895
2686
  }
1896
2687
  for (const profile of profiles) if (!byId.has(profile.id)) byId.set(profile.id, profile)
1897
- const written = await ctx.remote.settings.mutate(
1898
- route.settingsNs,
2688
+ await routeScope.mutate(
1899
2689
  [{ op: 'set', path: ['providers', route.provider, 'models'], value: [...byId.values()] }],
1900
2690
  read.view.revision,
1901
2691
  )
1902
- if (!written.ok) return { ok: false, message: `${written.error.code}: ${written.error.message}` }
1903
- return { ok: true }
2692
+ const after = await readPiAi()
2693
+ const present = new Set(
2694
+ routeModelsOf(after.view, route.provider)
2695
+ .map((model) => (model && typeof model.id === 'string' ? model.id : undefined))
2696
+ .filter((id) => id !== undefined),
2697
+ )
2698
+ const missing = profiles.filter((profile) => !present.has(profile.id)).length
2699
+ return missing === 0
2700
+ ? { ok: true }
2701
+ : { ok: false, message: t('catalog.writeRefused', { count: missing }) }
2702
+ }
2703
+
2704
+ /**
2705
+ * Everything the card writes, collected and flushed as one write.
2706
+ *
2707
+ * The card used to hand dsh a draft and wait for its 保存; now every edit
2708
+ * lands in the document as it is made — a removed row, a restored default
2709
+ * catalog, a renamed model, an endpoint, a display name — and 保存 is left
2710
+ * holding only the API key, which is why it sits under that field.
2711
+ *
2712
+ * Collected rather than written per edit, for two reasons. Each write is a
2713
+ * read-modify-write of the document, so overlapping ones would fence the
2714
+ * second on a revision the first had already superseded and it would be
2715
+ * refused; and a burst of keystrokes should be one write, not one per
2716
+ * character (which is also what keeps a capacity typed as `1M` from being
2717
+ * stored as `1` on its way through).
2718
+ *
2719
+ * Only one edit re-mounts the card: restoring the shipped catalog, whose list
2720
+ * the draft still holds. A field edit is already on screen where the user
2721
+ * typed it (and re-mounting under a caret would take the caret with it), and a
2722
+ * removal is already on screen too — dsh made that edit in the draft itself —
2723
+ * so neither has anything to rebuild.
2724
+ */
2725
+ const pendingEdits = {
2726
+ card: null,
2727
+ removals: new Set(),
2728
+ rows: new Map(),
2729
+ paths: new Map(),
2730
+ reset: false,
2731
+ timer: null,
2732
+ tail: Promise.resolve(),
2733
+ }
2734
+
2735
+ const cardEdit = (card, edit) => {
2736
+ pendingEdits.card = card
2737
+ if (edit.removal !== undefined) pendingEdits.removals.add(edit.removal)
2738
+ if (edit.reset === true) pendingEdits.reset = true
2739
+ if (edit.path !== undefined) pendingEdits.paths.set(edit.path, edit.value)
2740
+ if (edit.row !== undefined) {
2741
+ const at = pendingEdits.rows.get(edit.row.index) ?? {}
2742
+ pendingEdits.rows.set(edit.row.index, { ...at, [edit.row.model]: edit.row.value })
2743
+ }
2744
+ if (pendingEdits.timer !== null) return
2745
+ pendingEdits.timer = setTimeout(flushCardEdits, CARD_WRITE_DEBOUNCE_MS)
2746
+ }
2747
+
2748
+ const flushCardEdits = () => {
2749
+ pendingEdits.timer = null
2750
+ const batch = {
2751
+ card: pendingEdits.card,
2752
+ removals: [...pendingEdits.removals],
2753
+ rows: new Map(pendingEdits.rows),
2754
+ paths: new Map(pendingEdits.paths),
2755
+ reset: pendingEdits.reset,
2756
+ }
2757
+ pendingEdits.removals.clear()
2758
+ pendingEdits.rows.clear()
2759
+ pendingEdits.paths.clear()
2760
+ pendingEdits.reset = false
2761
+ const run = () => applyCardEdits(batch)
2762
+ pendingEdits.tail = pendingEdits.tail.then(run, run)
2763
+ }
2764
+
2765
+ const applyCardEdits = async (batch) => {
2766
+ try {
2767
+ const read = await readPiAi()
2768
+ if (read.failure !== undefined || read.view === undefined) return
2769
+ // A read-only deployment keeps dsh's own behaviour: the card stays a
2770
+ // draft, and it is left alone rather than re-mounted out from under it.
2771
+ if (read.writable !== true) return
2772
+ const modelsPath = ['providers', KENARI_PROVIDER, 'models']
2773
+ const ops = []
2774
+ let remount = false
2775
+ let removed = null
2776
+ if (batch.reset) {
2777
+ ops.push({ op: 'unset', path: modelsPath })
2778
+ remount = true
2779
+ } else {
2780
+ const existing = routeModelsOf(read.view, KENARI_PROVIDER)
2781
+ let next = existing
2782
+ let changed = false
2783
+ for (let at = 0; at < batch.removals.length; at += 1) {
2784
+ const shortened = withoutModel(next, batch.removals[at])
2785
+ if (shortened.length !== next.length) changed = true
2786
+ next = shortened
2787
+ }
2788
+ const patched = patchModels(next, batch.card === null ? null : cardModelIds(batch.card), batch.rows)
2789
+ if (patched !== next) changed = true
2790
+ next = patched
2791
+ if (changed) {
2792
+ ops.push({ op: 'set', path: modelsPath, value: next })
2793
+ if (batch.removals.length > 0) removed = batch.removals
2794
+ }
2795
+ }
2796
+ for (const [field, value] of batch.paths) {
2797
+ ops.push(value === UNSET
2798
+ ? { op: 'unset', path: ['providers', KENARI_PROVIDER, field] }
2799
+ : { op: 'set', path: ['providers', KENARI_PROVIDER, field], value })
2800
+ }
2801
+ if (ops.length === 0) return
2802
+ await routeScope.mutate(ops, read.view.revision)
2803
+ // Restoring the shipped catalog is the one edit that needs the card
2804
+ // rebuilt: its draft still holds the custom list, and nothing shorter of a
2805
+ // remount can take it away.
2806
+ if (remount && batch.card !== null) {
2807
+ await remountEditorWhile(batch.card, () => true)
2808
+ return
2809
+ }
2810
+ // A removal needs no remount at all. dsh made that edit in the card's own
2811
+ // draft, so the row is already gone from the screen, and the draft and the
2812
+ // document now say the same thing — collapsing the card here would only
2813
+ // flash it away to rebuild what is already right (and, if the user had
2814
+ // scrolled the list, drop them somewhere else). What is left to check is
2815
+ // the document, not the card.
2816
+ //
2817
+ // A removal that did NOT land is the one case that has to be shown: the
2818
+ // row is gone from the draft but still stored, so the card is rebuilt to
2819
+ // put it back where the user can see that it survived.
2820
+ if (removed !== null && batch.card !== null) {
2821
+ const after = await readPiAi()
2822
+ const stored = after.view === undefined
2823
+ ? []
2824
+ : routeModelsOf(after.view, KENARI_PROVIDER).map((model) => (model === null || model === undefined ? undefined : model.id))
2825
+ const survived = removed.filter((id) => stored.indexOf(id) !== -1)
2826
+ if (survived.length > 0) {
2827
+ await remountEditorWhile(batch.card, (shown) => survived.some((id) => shown.indexOf(id) !== -1))
2828
+ }
2829
+ }
2830
+ } catch (_cardWriteFailure) {
2831
+ // Fail-open: nothing re-seeds the card, so a write that did not land
2832
+ // shows as the old value in the card's own list rather than as a silent
2833
+ // success.
2834
+ }
2835
+ }
2836
+
2837
+ /** Take one edit off the card's inputs and queue it. */
2838
+ const onCardField = (card, target, raw) => {
2839
+ if (target.where === 'card') {
2840
+ cardEdit(card, { path: target.path, value: raw.trim().length === 0 ? UNSET : raw })
2841
+ return
2842
+ }
2843
+ if (target.capacity === true) {
2844
+ const value = capacityOf(raw)
2845
+ // Not a count this card can mean: the text stays where the user put it
2846
+ // rather than being stored as something it is not.
2847
+ if (value === undefined) return
2848
+ cardEdit(card, { row: { index: target.index, model: target.model, value } })
2849
+ return
2850
+ }
2851
+ const value = raw.trim()
2852
+ if (target.model === 'id') {
2853
+ // An id is the entry's identity: a blank or duplicated one names nothing
2854
+ // the adapter could address, so it stays in the card unhidden.
2855
+ if (value.length === 0) return
2856
+ const shown = cardModelIds(card)
2857
+ if (shown !== null && shown.filter((id) => id === value).length > 1) return
2858
+ }
2859
+ cardEdit(card, { row: { index: target.index, model: target.model, value: value.length === 0 ? UNSET : value } })
2860
+ }
2861
+
2862
+ /**
2863
+ * Commit the key the card's 保存 now stands for.
2864
+ *
2865
+ * The value crosses to the Host and never comes back (the credential seam
2866
+ * has no read path), so this is the one edit with nothing to verify against:
2867
+ * a store that is refused leaves the field filled and says so, rather than
2868
+ * pretending.
2869
+ */
2870
+ const onCardKey = (card, raw) => {
2871
+ const value = raw.trim()
2872
+ if (value.length === 0) {
2873
+ closeCard(card)
2874
+ return
2875
+ }
2876
+ void storeKey(card, value)
2877
+ }
2878
+
2879
+ const storeKey = async (card, value) => {
2880
+ const read = await readPiAi()
2881
+ if (read.failure !== undefined || read.view === undefined || read.writable !== true) return
2882
+ const profile = pathGet(read.view.user, ['providers', KENARI_PROVIDER])
2883
+ const named = profile !== null && typeof profile === 'object' ? profile.apiKeyEnv : undefined
2884
+ const keyRef = typeof named === 'string' && named.length > 0 ? named : DEFAULT_KEY_REF
2885
+ try {
2886
+ await ctx.remote.credentials.set(keyRef, value)
2887
+ } catch (err) {
2888
+ showCardFailure(card, String(err !== null && err !== undefined && err.message ? err.message : err))
2889
+ return
2890
+ }
2891
+ // dsh's own save records the reference the key was stored under; without it
2892
+ // the route still resolves the same name by derivation, but the card would
2893
+ // go on showing an endpoint it no longer names.
2894
+ if (typeof named !== 'string' || named.length === 0) {
2895
+ try {
2896
+ await routeScope.mutate([{ op: 'set', path: ['providers', KENARI_PROVIDER, 'apiKeyEnv'], value: keyRef }], read.view.revision)
2897
+ } catch (_referenceFailure) { /* the derived name is the same one */ }
2898
+ }
2899
+ clearCardFailure(card)
2900
+ closeCard(card)
2901
+ }
2902
+
2903
+ /** Fold the card away, the way dsh folds it after its own save. */
2904
+ function closeCard(card) {
2905
+ const toggle = editToggleOf(card)
2906
+ if (toggle !== null && typeof toggle.click === 'function') toggle.click()
2907
+ }
2908
+
2909
+ // The one place a refused key write can be seen: dsh's own error line is
2910
+ // React's, so this is a node of this bundle's own, put under the key field
2911
+ // and taken away again on the next attempt.
2912
+ const CARD_FAILURE_ATTR = 'data-kenari-card-failure'
2913
+
2914
+ function clearCardFailure(card) {
2915
+ if (card === null || card === undefined || typeof card.querySelector !== 'function') return
2916
+ const shown = card.querySelector(`[${CARD_FAILURE_ATTR}]`)
2917
+ if (shown !== null && shown !== undefined && typeof shown.remove === 'function') shown.remove()
2918
+ }
2919
+
2920
+ function showCardFailure(card, message) {
2921
+ if (card === null || card === undefined || typeof card.querySelector !== 'function') return
2922
+ clearCardFailure(card)
2923
+ if (typeof document.createElement !== 'function') return
2924
+ const line = document.createElement('p')
2925
+ line.setAttribute(CARD_FAILURE_ATTR, '')
2926
+ line.setAttribute('style', 'margin:6px 0 0;font-size:12px;color:#d4380d')
2927
+ line.textContent = message
2928
+ const input = keyInputOf(card)
2929
+ const field = input === null ? null : input.parentElement
2930
+ if (field !== null && field !== undefined && typeof field.appendChild === 'function') field.appendChild(line)
1904
2931
  }
1905
2932
 
1906
2933
  const injected = () => ({ scope, loadModels, describeKey })
@@ -1940,7 +2967,12 @@ window.__ModuleLoader__.load({
1940
2967
  // One document-level listener, torn down with this fiber. It is inert
1941
2968
  // until the Kenari card mounts, because a click only means anything when
1942
2969
  // this plugin's dialog can answer it.
1943
- ctx.effect(() => installEntryTakeover(), 'kenari: 模型目录入口接管')
2970
+ ctx.effect(() => installEntryTakeover({
2971
+ onRemove: (card, modelId) => { cardEdit(card, { removal: modelId }) },
2972
+ onReset: (card) => { cardEdit(card, { reset: true }) },
2973
+ onField: onCardField,
2974
+ onKey: onCardKey,
2975
+ }), 'kenari: 模型目录入口接管')
1944
2976
  ensureDialogWidth()
1945
2977
 
1946
2978
  // The settings nav glyph. dsh picks its own by section id, so this is the
@@ -1957,6 +2989,26 @@ window.__ModuleLoader__.load({
1957
2989
  exports.__internals = {
1958
2990
  CAPABILITY_TAGS,
1959
2991
  TAKEOVER_LABELS,
2992
+ EDIT_LABELS,
2993
+ MODEL_ID_LABELS,
2994
+ REMOVE_LABELS,
2995
+ SUBMIT_LABELS,
2996
+ CANCEL_LABELS,
2997
+ RESET_LABELS,
2998
+ UNSET,
2999
+ fieldTargetOf,
3000
+ capacityOf,
3001
+ patchModels,
3002
+ footerOf,
3003
+ syncFooterVisibility,
3004
+ scrollMarksOf,
3005
+ restoreScroll,
3006
+ isRemoveLabel,
3007
+ removalRowOf,
3008
+ withoutModel,
3009
+ refreshProviderEditor,
3010
+ cardModelIds,
3011
+ revealFoldOf,
1960
3012
  isTakeoverLabel,
1961
3013
  MARKER_ATTR,
1962
3014
  pathGet,