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 +1 -1
- package/README.md +1 -1
- package/client/index.js +967 -26
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
*
|
|
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
|
-
*
|
|
993
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
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`
|
|
1239
|
-
//
|
|
1240
|
-
|
|
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
|
-
|
|
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 !==
|
|
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(
|
|
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.
|
|
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": {
|