dsh-kenari-plugin 0.2.1 → 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/README.en.md CHANGED
@@ -79,7 +79,7 @@ Under **Settings → Models → Kenari → Edit → Model catalog**, "Add model"
79
79
  - Filters are `in plan`, `free`, and the capability tags. "In plan" and "free" are mutually exclusive, and `embedding` is exclusive on its own (it has no chat endpoint, so mixing it with anything returns an empty list). The rest combine
80
80
  - "Add selected" **appends** to the current entries, and rows already in the route cannot be selected
81
81
 
82
- The write is immediate with no save step, and the list inside that card refreshes only after you collapse and reopen Edit. The Host computes the data and serves it at `GET /api/kenari.models`, the same facts as `kenari_list_models`, and no key is needed.
82
+ The write is immediate: the picker closes itself once it is done and those rows are already in the card behind it. Every other part of that card behaves the same way — display name, base URL, protocol, and each row's id, name, context window and max output are written as soon as you stop typing, and deleting a row or restoring the default catalog takes effect the moment you click. Which is why Cancel/Save now sit under the API key field and commit nothing but the key (they stay hidden while that field is empty). The Host computes the data and serves it at `GET /api/kenari.models`, the same facts as `kenari_list_models`, and no key is needed.
83
83
 
84
84
  ### Three protocol lines and the compat preset
85
85
 
package/README.md CHANGED
@@ -79,7 +79,7 @@ KENARI_API_KEY=kn-...
79
79
  - 过滤片有 `套餐内` `免费` 与各能力标签。「套餐内」与「免费」互斥,`embedding` 独占(它没有 chat 端点,混选只会得到空列表),其余可多选
80
80
  - 「添加所选」**追加**到现有条目之后,已在路由里的行不能再勾
81
81
 
82
- 写入是即时的,没有保存步骤;同一张卡里的列表要收起再展开才会刷新。数据由 Host 侧算好(`GET /api/kenari.models`),与 `kenari_list_models` 同源,没有 key 也能看。
82
+ 写入是即时的:挑选器写完即关,卡片里当场多出那几行。卡片其余部分同理——显示名、API 地址、协议,以及每行的 id/显示名/上下文窗口/最大输出,都在改完(停止输入)后立即写入;删除一行与「恢复默认模型」也是点完即生效。正因如此,「取消/保存」被挪到 API 密钥字段下方,只负责提交密钥(密钥框为空时这两个按钮不显示)。数据由 Host 侧算好(`GET /api/kenari.models`),与 `kenari_list_models` 同源,没有 key 也能看。
83
83
 
84
84
  ### 三条协议线与兼容预设
85
85
 
package/client/index.js CHANGED
@@ -375,6 +375,142 @@ window.__ModuleLoader__.load({
375
375
  */
376
376
  const EDIT_LABELS = ['编辑', 'Edit']
377
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
+
378
514
  /**
379
515
  * Whether a button's label (already trimmed) belongs to this takeover. An
380
516
  * exact match, never a prefix: the Models page carries other 添加… buttons
@@ -936,8 +1072,45 @@ window.__ModuleLoader__.load({
936
1072
  * A `disabled` button never dispatches a click at all, so the native
937
1073
  * gates (adding while the editor is busy, fetching without a base URL) hold
938
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).
939
1080
  */
940
- 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
+
941
1114
  const onClickCapture = (event) => {
942
1115
  if (takeoverBypassed || !pickerChannel.mounted) return
943
1116
  const target = event.target
@@ -945,16 +1118,178 @@ window.__ModuleLoader__.load({
945
1118
  const button = target.closest('button')
946
1119
  if (button === null) return
947
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
+ }
948
1163
  if (!isTakeoverLabel(label)) return
949
- if (cardWithMarker(button) === null) return
1164
+ if (card === null) return
950
1165
  event.preventDefault()
951
1166
  event.stopPropagation()
952
1167
  pickerChannel.open(button)
953
1168
  }
954
1169
  document.addEventListener('click', onClickCapture, true)
1170
+ document.addEventListener('input', onFieldEvent, true)
1171
+ document.addEventListener('change', onFieldEvent, true)
955
1172
  return () => {
956
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
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
+ }
957
1291
  }
1292
+ return touched ? next : models
958
1293
  }
959
1294
 
960
1295
  /**
@@ -977,7 +1312,251 @@ window.__ModuleLoader__.load({
977
1312
  }
978
1313
 
979
1314
  /**
980
- * Re-mount the editing card this bundle's picker was opened from.
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.
981
1560
  *
982
1561
  * Why it is needed: the card seeds its model list once, at mount, and
983
1562
  * deliberately does not follow a pushed settings refresh — that is what keeps
@@ -989,26 +1568,117 @@ window.__ModuleLoader__.load({
989
1568
  * The draft it discards was already invalid: the card's revision fence is stale
990
1569
  * after this plugin's write, so its next save would be refused as stale anyway.
991
1570
  *
992
- * Fail-open no toggle found (a dsh release renamed it) returns false and the
993
- * caller falls back to the copy that tells the user to reopen the card.
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.
994
1583
  * @param button - the native button the takeover intercepted.
995
- * @returns whether a remount was started.
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.
996
1586
  */
997
- function refreshProviderEditor(button) {
1587
+ async function refreshProviderEditor(button, addedIds) {
998
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.
999
1594
  const row = button.closest('li')
1000
- const toggle = editToggleOf(row)
1001
- if (toggle === null || typeof toggle.click !== 'function') return false
1002
- toggle.click()
1003
- // The second click must land after React commits the first: collapse is a
1004
- // state update, and re-reading `open` inside the same click would expand
1005
- // nothing. The row node survives the collapse (it is keyed by provider), so
1006
- // the toggle is looked up again there instead of reusing a detached node.
1007
- setTimeout(() => {
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()
1008
1669
  const again = editToggleOf(row)
1009
1670
  if (again !== null && typeof again.click === 'function') again.click()
1010
- }, 0)
1011
- return true
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
1012
1682
  }
1013
1683
 
1014
1684
  /**
@@ -1166,7 +1836,7 @@ window.__ModuleLoader__.load({
1166
1836
  * because a catalog browser that cannot add anything just duplicated it.
1167
1837
  */
1168
1838
  function useCatalogPanel(props) {
1169
- const { routeNs, routeProvider, loadPanel, addModels, onAdded } = props
1839
+ const { routeNs, routeProvider, loadPanel, addModels, onAdded, onComplete } = props
1170
1840
  const [state, setState] = React.useState({ status: 'loading' })
1171
1841
  const [reloads, setReloads] = React.useState(0)
1172
1842
  const [query, setQuery] = React.useState('')
@@ -1228,16 +1898,32 @@ window.__ModuleLoader__.load({
1228
1898
  if (profiles.length === 0 || routeNs === undefined || routeProvider === undefined) return
1229
1899
  setWrite({ status: 'saving' })
1230
1900
  addModels({ settingsNs: routeNs, provider: routeProvider }, profiles).then(
1231
- (result) => {
1901
+ async (result) => {
1232
1902
  if (result.ok !== true) {
1233
1903
  setWrite({ status: 'error', message: result.message })
1234
1904
  return
1235
1905
  }
1236
1906
  // The card behind this modal seeds its list once, at mount, and does not
1237
1907
  // follow pushed settings refreshes — so it has to be remounted for the
1238
- // write to show up in it. `onAdded` reports whether that remount was
1239
- // possible; when it is not, the copy says how to get there by hand.
1240
- const remounted = typeof onAdded === 'function' && onAdded() === true
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
+ }
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
+ }
1241
1927
  setWrite({
1242
1928
  status: 'added',
1243
1929
  message: remounted
@@ -1401,8 +2087,11 @@ window.__ModuleLoader__.load({
1401
2087
  loadPanel,
1402
2088
  addModels,
1403
2089
  // The card this modal was opened from is the row's editing card; remounting
1404
- // it is what makes the freshly written models visible in its list.
1405
- onAdded: () => refreshProviderEditor(nativeButton),
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,
1406
2095
  })
1407
2096
 
1408
2097
  /**
@@ -1510,7 +2199,7 @@ window.__ModuleLoader__.load({
1510
2199
  // picker is about Kenari, so another pi-ai route renders nothing — not
1511
2200
  // even the marker, which is what keeps the takeover from claiming that
1512
2201
  // route's otherwise identical button.
1513
- if (props.provider === undefined || props.provider.provider !== 'kenari') return null
2202
+ if (props.provider === undefined || props.provider.provider !== KENARI_PROVIDER) return null
1514
2203
  return React.createElement(
1515
2204
  React.Fragment,
1516
2205
  null,
@@ -2012,6 +2701,235 @@ window.__ModuleLoader__.load({
2012
2701
  : { ok: false, message: t('catalog.writeRefused', { count: missing }) }
2013
2702
  }
2014
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)
2931
+ }
2932
+
2015
2933
  const injected = () => ({ scope, loadModels, describeKey })
2016
2934
  ctx.slots.inject('settings.section', () =>
2017
2935
  ctx.slots.register(
@@ -2049,7 +2967,12 @@ window.__ModuleLoader__.load({
2049
2967
  // One document-level listener, torn down with this fiber. It is inert
2050
2968
  // until the Kenari card mounts, because a click only means anything when
2051
2969
  // this plugin's dialog can answer it.
2052
- 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: 模型目录入口接管')
2053
2976
  ensureDialogWidth()
2054
2977
 
2055
2978
  // The settings nav glyph. dsh picks its own by section id, so this is the
@@ -2067,7 +2990,25 @@ window.__ModuleLoader__.load({
2067
2990
  CAPABILITY_TAGS,
2068
2991
  TAKEOVER_LABELS,
2069
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,
2070
3009
  refreshProviderEditor,
3010
+ cardModelIds,
3011
+ revealFoldOf,
2071
3012
  isTakeoverLabel,
2072
3013
  MARKER_ATTR,
2073
3014
  pathGet,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-kenari-plugin",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Kenari (kenari.id) as a first-class citizen of DeepSeek Harness: session models, web search/fetch provider with fallback, and REST capability tools.",
5
5
  "license": "MIT",
6
6
  "repository": {