dsh-model-capability-sync 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,650 @@
1
+ /**
2
+ * The capability-sync settings section: pick the provider entry, analyze its
3
+ * models against models.dev, review what would change, then apply.
4
+ *
5
+ * Pure props — no context access, no subscriptions. Everything arrives through
6
+ * the inject face `apply` builds, including the bound translate function.
7
+ */
8
+ import { useEffect, useState } from 'react'
9
+ import type { ReactNode } from 'react'
10
+ import type { CapabilityFields, LiveResponse, LiveRow, PreviewResponse, PreviewRow, RpcEnvelope } from '../types.ts'
11
+ import { sortByModelId } from '../sort.ts'
12
+
13
+ /** One settings descriptor as the remote reports it (only the fields used here). */
14
+ export interface SettingsDescriptor {
15
+ ns: string
16
+ revision: number
17
+ value: Record<string, unknown>
18
+ }
19
+
20
+ /** One ordered form edit understood by `settings.mutate`. */
21
+ export interface SettingsPathOp {
22
+ op: 'set' | 'unset'
23
+ path: string[]
24
+ value?: unknown
25
+ }
26
+
27
+ /** What `apply` injects into this component. */
28
+ export interface SyncSectionInjected {
29
+ preview(request: { ids: string[]; aliases?: Record<string, string>; refresh?: boolean }): Promise<RpcEnvelope<PreviewResponse>>
30
+ live(request: { provider: string; ids: string[] }): Promise<RpcEnvelope<LiveResponse>>
31
+ describeSettings(): Promise<SettingsDescriptor[]>
32
+ mutateSettings(ns: string, ops: SettingsPathOp[], expectedRevision?: number): Promise<SettingsDescriptor>
33
+ }
34
+
35
+ export interface SyncSectionProps extends SyncSectionInjected {
36
+ t: (key: string, params?: Record<string, unknown>) => string
37
+ }
38
+
39
+ /** A model entry as the provider stores it: structurally open, so unknown fields survive. */
40
+ type ModelDraft = Record<string, unknown>
41
+
42
+ /** The official adapter this plugin fills capabilities into. */
43
+ const TARGET_DEFAULT = 'llm-pi-ai'
44
+
45
+ /**
46
+ * How a writable settings namespace reads to a person.
47
+ *
48
+ * A namespace id is an internal identity, so the page names the thing it
49
+ * configures instead. Provenance is a table, not a guess: the client cannot
50
+ * see which package owns a namespace (`settings.describe` reports only
51
+ * `ns/revision/value/base/user`), and inferring "official" from an `llm-`
52
+ * prefix would eventually mislabel a third-party plugin. A namespace this
53
+ * table does not know is shown as a plugin, with its id.
54
+ */
55
+ const TARGET_DIRECTORY: Record<string, { native: boolean; nameKey: string }> = {
56
+ 'llm-pi-ai': { native: true, nameKey: 'nameLlmPiAi' },
57
+ 'llm-deepseek': { native: true, nameKey: 'nameLlmDeepseek' },
58
+ 'llm-deepseek-account': { native: true, nameKey: 'nameLlmDeepseekAccount' },
59
+ 'llm-newapi': { native: false, nameKey: 'nameLlmNewapi' },
60
+ }
61
+
62
+ /**
63
+ * Render one namespace as provenance plus a name.
64
+ * @param ns - the settings namespace id.
65
+ * @param t - the bound translate function.
66
+ * @returns copy such as `原生配置 · pi-ai`, never a bare namespace id.
67
+ */
68
+ function targetLabelOf(ns: string, t: SyncSectionProps['t']): string {
69
+ const known = TARGET_DIRECTORY[ns]
70
+ if (known === undefined) return `${t('sourcePlugin')} · ${ns}`
71
+ return `${known.native ? t('sourceNative') : t('sourcePlugin')} · ${t(known.nameKey)}`
72
+ }
73
+
74
+ /** One capability field that applying a row would rewrite. */
75
+ interface FieldChange {
76
+ key: keyof CapabilityFields
77
+ before: unknown
78
+ after: unknown
79
+ }
80
+
81
+ /** One preview row joined with the stored entry it would rewrite. */
82
+ interface PlanRow {
83
+ row: PreviewRow
84
+ model: ModelDraft
85
+ changes: FieldChange[]
86
+ }
87
+
88
+ /** Read a string field, or the empty string. */
89
+ function textOf(model: ModelDraft, key: string): string {
90
+ const value = model[key]
91
+ return typeof value === 'string' ? value : ''
92
+ }
93
+
94
+ /** The provider dict inside one descriptor's live value. */
95
+ function providersOf(descriptor: SettingsDescriptor | undefined): Record<string, Record<string, unknown>> {
96
+ const providers = descriptor?.value?.providers
97
+ return typeof providers === 'object' && providers !== null
98
+ ? providers as Record<string, Record<string, unknown>>
99
+ : {}
100
+ }
101
+
102
+ /** The models array of one provider profile. */
103
+ function modelsOf(provider: Record<string, unknown> | undefined): ModelDraft[] {
104
+ const models = provider?.models
105
+ return Array.isArray(models) ? models.filter((entry): entry is ModelDraft => typeof entry === 'object' && entry !== null) : []
106
+ }
107
+
108
+ /** The models of one provider profile, in display order. */
109
+ function sortedModelsOf(provider: Record<string, unknown> | undefined): ModelDraft[] {
110
+ return sortByModelId(modelsOf(provider), model => textOf(model, 'id'))
111
+ }
112
+
113
+ /** Human token count: 128000 → `128K`, 1000000 → `1M`. */
114
+ function tokens(value: unknown): string {
115
+ if (typeof value !== 'number') return '—'
116
+ if (value >= 1_000_000) return `${String(Number((value / 1_000_000).toFixed(1)))}M`
117
+ if (value >= 1000) return `${String(Number((value / 1000).toFixed(0)))}K`
118
+ return String(value)
119
+ }
120
+
121
+ /** Which rule produced a match, as section copy. */
122
+ function viaLabel(row: PreviewRow, t: SyncSectionProps['t']): string {
123
+ switch (row.match?.via) {
124
+ case 'alias': return t('viaAlias')
125
+ case 'exact': return t('viaExact')
126
+ case 'folded': return t('viaFolded')
127
+ case 'basename': return t('viaBasename')
128
+ case 'suffix': return t('viaSuffix')
129
+ default: return ''
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Whether a stored value carries no decision of its own.
135
+ *
136
+ * The provider schema materializes defaults, so a field the user never set can
137
+ * arrive as `[]` or `{}` rather than as `undefined`. Treating those as "already
138
+ * set" is what made an empty `input: []` survive every fill — the field looked
139
+ * answered while it said nothing. `false` and `0` are decisions, not blanks.
140
+ * @param value - the stored value.
141
+ * @returns true when filling the field loses no user intent.
142
+ */
143
+ function isBlank(value: unknown): boolean {
144
+ if (value === undefined || value === null || value === '') return true
145
+ if (Array.isArray(value)) return value.length === 0
146
+ if (typeof value === 'object') return Object.keys(value as Record<string, unknown>).length === 0
147
+ return false
148
+ }
149
+
150
+ /**
151
+ * Merge one row's capability fields into an existing model entry.
152
+ *
153
+ * `name` is never written: a label is the user's or the gateway's choice, and
154
+ * renaming models is not this page's job. The host no longer returns one, and
155
+ * the explicit skip keeps that true even against an older host build.
156
+ * @param model - the entry as stored.
157
+ * @param fields - catalog capabilities.
158
+ * @param overwrite - replace values the user already set.
159
+ * @returns a new entry; the input is never mutated.
160
+ */
161
+ function mergeModel(model: ModelDraft, fields: CapabilityFields, overwrite: boolean): ModelDraft {
162
+ const next: ModelDraft = { ...model }
163
+ for (const [key, value] of Object.entries(fields)) {
164
+ if (key === 'name' || value === undefined) continue
165
+ if (!overwrite && !isBlank(next[key])) continue
166
+ next[key] = value
167
+ }
168
+ return next
169
+ }
170
+
171
+ /**
172
+ * The fields applying one row would actually rewrite.
173
+ * @param model - the entry as stored.
174
+ * @param fields - catalog capabilities.
175
+ * @param overwrite - replace values the user already set.
176
+ * @returns one entry per changed field, in field order; empty when nothing moves.
177
+ */
178
+ function changesBetween(model: ModelDraft, fields: CapabilityFields, overwrite: boolean): FieldChange[] {
179
+ const next = mergeModel(model, fields, overwrite)
180
+ const changes: FieldChange[] = []
181
+ for (const key of Object.keys(fields) as Array<keyof CapabilityFields>) {
182
+ const before = model[key]
183
+ const after = next[key]
184
+ if (JSON.stringify(before) === JSON.stringify(after)) continue
185
+ changes.push({ key, before, after })
186
+ }
187
+ return changes
188
+ }
189
+
190
+ /** The image verdict of one modality list. */
191
+ function imageText(value: unknown, t: SyncSectionProps['t']): string {
192
+ if (Array.isArray(value) && value.includes('image')) return t('imageYes')
193
+ if (isBlank(value)) return t('fieldEmpty')
194
+ return t('imageNo')
195
+ }
196
+
197
+ /** The reasoning verdict of one effort value. */
198
+ function reasoningText(value: unknown): string {
199
+ if (value === false) return 'off'
200
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
201
+ const keys = Object.keys(value as Record<string, unknown>)
202
+ return keys.length === 0 ? '—' : keys.join(' / ')
203
+ }
204
+ return '—'
205
+ }
206
+
207
+ /** The image verdict the owning adapter reports right now. */
208
+ function liveLabel(row: LiveRow | undefined, t: SyncSectionProps['t']): string {
209
+ if (row === undefined) return '—'
210
+ if (row.error !== undefined) return t('liveError')
211
+ if (row.input === undefined) return '—'
212
+ return row.input.includes('image') ? t('imageYes') : t('imageNo')
213
+ }
214
+
215
+ /**
216
+ * One value cell: the stored value alone, or `before → after` when it moves.
217
+ * @param change - the change for this field, when there is one.
218
+ * @param current - the stored value.
219
+ * @param format - renders one value as section copy.
220
+ * @returns the cell body.
221
+ */
222
+ function valueCell(change: FieldChange | undefined, current: unknown, format: (value: unknown) => string): ReactNode {
223
+ if (change === undefined) return <span className="cs-dim">{format(current)}</span>
224
+ return (
225
+ <span className="cs-change">
226
+ <span className="cs-from">{format(change.before)}</span>
227
+ {' → '}{format(change.after)}
228
+ </span>
229
+ )
230
+ }
231
+
232
+ /**
233
+ * Render the section.
234
+ * @param props - injected wire face plus the bound translate function.
235
+ * @returns the section body.
236
+ */
237
+ export function SyncSection(props: SyncSectionProps): ReactNode {
238
+ const { t, preview, live, describeSettings, mutateSettings } = props
239
+ const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
240
+ const [errorText, setErrorText] = useState<string | undefined>(undefined)
241
+ const [descriptors, setDescriptors] = useState<SettingsDescriptor[]>([])
242
+ const [targetNs, setTargetNs] = useState(TARGET_DEFAULT)
243
+ const [providerKey, setProviderKey] = useState('')
244
+ const [models, setModels] = useState<ModelDraft[]>([])
245
+ const [rows, setRows] = useState<PreviewRow[] | undefined>(undefined)
246
+ const [liveRows, setLiveRows] = useState<ReadonlyMap<string, LiveRow>>(new Map())
247
+ const [selected, setSelected] = useState<ReadonlySet<string>>(new Set())
248
+ const [overwrite, setOverwrite] = useState(false)
249
+ const [query, setQuery] = useState('')
250
+ const [onlyChanged, setOnlyChanged] = useState(false)
251
+ const [busy, setBusy] = useState<'idle' | 'analyzing' | 'applying'>('idle')
252
+ const [notice, setNotice] = useState<string | undefined>(undefined)
253
+ const [catalogInfo, setCatalogInfo] = useState<string | undefined>(undefined)
254
+ const [staleInfo, setStaleInfo] = useState<string | undefined>(undefined)
255
+
256
+ /** Everything a write would touch, joined with the stored entries. */
257
+ const plan: PlanRow[] = (() => {
258
+ if (rows === undefined) return []
259
+ const stored = new Map(models.map(model => [textOf(model, 'id'), model]))
260
+ return rows.map((row) => {
261
+ const model = stored.get(row.id) ?? {}
262
+ const fields = row.fields
263
+ return {
264
+ row,
265
+ model,
266
+ changes: fields === undefined ? [] : changesBetween(model, fields, overwrite),
267
+ }
268
+ })
269
+ })()
270
+ const matched = plan.filter(entry => entry.row.fields !== undefined)
271
+ const unmatched = plan.filter(entry => entry.row.fields === undefined)
272
+ const changing = matched.filter(entry => entry.changes.length > 0)
273
+ const changeFields = changing.reduce((total, entry) => total + entry.changes.length, 0)
274
+ const liveImage = matched.filter(entry => liveRows.get(entry.row.id)?.input?.includes('image') === true).length
275
+ const liveText = matched.filter((entry) => {
276
+ const answer = liveRows.get(entry.row.id)
277
+ return answer?.input !== undefined && !answer.input.includes('image')
278
+ }).length
279
+
280
+ /** Read every configurable entry and select the default target. */
281
+ const load = async (): Promise<void> => {
282
+ setStatus('loading')
283
+ setErrorText(undefined)
284
+ try {
285
+ const list = await describeSettings()
286
+ setDescriptors(list)
287
+ const candidates = list.filter(entry => Object.keys(providersOf(entry)).length > 0)
288
+ const target = candidates.find(entry => entry.ns === TARGET_DEFAULT) ?? candidates[0]
289
+ if (target === undefined) {
290
+ setStatus('error')
291
+ setErrorText(t('noTarget'))
292
+ return
293
+ }
294
+ const providers = providersOf(target)
295
+ const firstKey = Object.keys(providers)[0] ?? ''
296
+ setTargetNs(target.ns)
297
+ setProviderKey(firstKey)
298
+ setModels(sortedModelsOf(providers[firstKey]))
299
+ setRows(undefined)
300
+ setLiveRows(new Map())
301
+ setSelected(new Set())
302
+ setQuery('')
303
+ setStatus('ready')
304
+ } catch (error) {
305
+ setStatus('error')
306
+ setErrorText(error instanceof Error ? error.message : String(error))
307
+ }
308
+ }
309
+
310
+ useEffect(() => { void load() }, [])
311
+
312
+ /** Show one provider's models, in display order, with a clean slate. */
313
+ const showProvider = (ns: string, key: string): void => {
314
+ const providers = providersOf(descriptors.find(entry => entry.ns === ns))
315
+ const resolved = key !== '' && providers[key] !== undefined ? key : Object.keys(providers)[0] ?? ''
316
+ setTargetNs(ns)
317
+ setProviderKey(resolved)
318
+ setModels(sortedModelsOf(providers[resolved]))
319
+ setRows(undefined)
320
+ setLiveRows(new Map())
321
+ setSelected(new Set())
322
+ setNotice(undefined)
323
+ setQuery('')
324
+ }
325
+
326
+ /** Ask the owning adapter what a request sees for these ids. */
327
+ const refreshLive = async (provider: string, ids: string[]): Promise<void> => {
328
+ try {
329
+ const answer = await live({ provider, ids })
330
+ setLiveRows(answer.ok ? new Map(answer.value.rows.map(row => [row.id, row])) : new Map())
331
+ } catch {
332
+ setLiveRows(new Map())
333
+ }
334
+ }
335
+
336
+ /** Re-read the configuration without losing the analysis on screen. */
337
+ const reloadConfig = async (): Promise<void> => {
338
+ const list = await describeSettings()
339
+ setDescriptors(list)
340
+ const descriptor = list.find(entry => entry.ns === targetNs)
341
+ if (descriptor !== undefined) setModels(sortedModelsOf(providersOf(descriptor)[providerKey]))
342
+ }
343
+
344
+ /** Preselect every row that would actually change. */
345
+ const selectChanging = (source: readonly PreviewRow[], stored: readonly ModelDraft[], mode: boolean): void => {
346
+ const byId = new Map(stored.map(model => [textOf(model, 'id'), model]))
347
+ setSelected(new Set(source
348
+ .filter(row => row.fields !== undefined && changesBetween(byId.get(row.id) ?? {}, row.fields, mode).length > 0)
349
+ .map(row => row.id)))
350
+ }
351
+
352
+ /** Resolve every configured id against the catalog. */
353
+ const analyze = async (refresh: boolean): Promise<void> => {
354
+ setBusy('analyzing')
355
+ setErrorText(undefined)
356
+ setNotice(undefined)
357
+ setStaleInfo(undefined)
358
+ try {
359
+ const ids = models.map(model => textOf(model, 'id')).filter(id => id.length > 0)
360
+ const result = await preview({ ids, ...refresh ? { refresh: true } : {} })
361
+ if (!result.ok) throw new Error(result.error.message)
362
+ setRows(result.value.rows)
363
+ selectChanging(result.value.rows, models, overwrite)
364
+ setCatalogInfo(t('catalogInfo', {
365
+ models: result.value.catalogModels,
366
+ time: new Date(result.value.fetchedAt).toLocaleString(),
367
+ }))
368
+ setStaleInfo(result.value.stale === true
369
+ ? t('catalogStale', { time: new Date(result.value.fetchedAt).toLocaleString() })
370
+ : undefined)
371
+ // The entry we are about to write may not be the route a request takes,
372
+ // so this is the only honest answer to "did the write take effect".
373
+ await refreshLive(providerKey, ids)
374
+ } catch (error) {
375
+ setErrorText(error instanceof Error ? error.message : String(error))
376
+ } finally {
377
+ setBusy('idle')
378
+ }
379
+ }
380
+
381
+ /** Switch the overwrite mode and follow it with the selection. */
382
+ const setOverwriteMode = (mode: boolean): void => {
383
+ setOverwrite(mode)
384
+ if (rows === undefined) return
385
+ selectChanging(rows, models, mode)
386
+ }
387
+
388
+ /** Write the selected rows into the provider entry. */
389
+ const applyRows = async (): Promise<void> => {
390
+ const descriptor = descriptors.find(entry => entry.ns === targetNs)
391
+ if (descriptor === undefined) return
392
+ const targets = new Map(changing
393
+ .filter(entry => selected.has(entry.row.id))
394
+ .map(entry => [entry.row.id, entry]))
395
+ if (targets.size === 0) {
396
+ setNotice(t('nothingSelected'))
397
+ return
398
+ }
399
+ let written = 0
400
+ const next = models.map((model) => {
401
+ const entry = targets.get(textOf(model, 'id'))
402
+ if (entry?.row.fields === undefined) return model
403
+ written += 1
404
+ return mergeModel(model, entry.row.fields, overwrite)
405
+ })
406
+ setBusy('applying')
407
+ setErrorText(undefined)
408
+ setNotice(undefined)
409
+ try {
410
+ const view = await mutateSettings(targetNs, [{
411
+ op: 'set',
412
+ path: ['providers', providerKey, 'models'],
413
+ value: next,
414
+ }], descriptor.revision)
415
+ setModels(next)
416
+ // Keep the held revision current, so a second apply does not conflict.
417
+ setDescriptors(previous => previous.map(entry =>
418
+ entry.ns === targetNs && typeof view.revision === 'number'
419
+ ? { ...entry, revision: view.revision }
420
+ : entry))
421
+ setNotice(t('applied', { count: written }))
422
+ await refreshLive(providerKey, next.map(model => textOf(model, 'id')).filter(id => id.length > 0))
423
+ } catch (error) {
424
+ const code = (error as { code?: string } | undefined)?.code
425
+ if (code === 'settings/conflict') {
426
+ // Someone else wrote this entry while the page was open. Reload the
427
+ // baseline instead of retrying blindly: a silent retry would overwrite
428
+ // whatever they just saved.
429
+ try {
430
+ await reloadConfig()
431
+ } catch {
432
+ // Keep the conflict message; the reload is only a convenience.
433
+ }
434
+ setNotice(t('conflictReload'))
435
+ } else {
436
+ setErrorText(error instanceof Error ? error.message : String(error))
437
+ }
438
+ } finally {
439
+ setBusy('idle')
440
+ }
441
+ }
442
+
443
+ if (status === 'loading') return <div className="cs-hint">{t('loading')}</div>
444
+
445
+ if (status === 'error') {
446
+ return (
447
+ <div className="cs-field">
448
+ <p className="cs-error">{t('loadFailed')}: {errorText}</p>
449
+ <button type="button" className="cs-button" onClick={() => { void load() }}>{t('retry')}</button>
450
+ </div>
451
+ )
452
+ }
453
+
454
+ const descriptor = descriptors.find(entry => entry.ns === targetNs)
455
+ const candidates = descriptors.filter(entry => Object.keys(providersOf(entry)).length > 0)
456
+ const providerKeys = Object.keys(providersOf(descriptor))
457
+ const needle = query.trim().toLowerCase()
458
+ const visible = matched.filter((entry) => {
459
+ if (onlyChanged && entry.changes.length === 0) return false
460
+ if (needle === '') return true
461
+ return entry.row.id.toLowerCase().includes(needle)
462
+ || entry.row.match?.modelId.toLowerCase().includes(needle) === true
463
+ })
464
+ const selectable = visible.filter(entry => entry.changes.length > 0)
465
+ const toggle = (id: string): void => {
466
+ const next = new Set(selected)
467
+ if (next.has(id)) next.delete(id)
468
+ else next.add(id)
469
+ setSelected(next)
470
+ }
471
+
472
+ return (
473
+ <div>
474
+ <p className="cs-hint">{t('intro')}</p>
475
+
476
+ <div className="cs-row">
477
+ {candidates.length > 1
478
+ ? (
479
+ <label className="cs-field">
480
+ <span className="cs-hint">{t('writeTarget')}</span>
481
+ <select className="cs-select" value={targetNs} onChange={(event) => { showProvider(event.target.value, '') }}>
482
+ {candidates.map(entry => (
483
+ <option key={entry.ns} value={entry.ns}>{targetLabelOf(entry.ns, t)}</option>
484
+ ))}
485
+ </select>
486
+ </label>
487
+ )
488
+ : (
489
+ <div className="cs-field" title={targetNs}>
490
+ <span className="cs-hint">{t('writeTarget')}</span>
491
+ <span className="cs-target">{targetLabelOf(targetNs, t)}</span>
492
+ </div>
493
+ )}
494
+ <label className="cs-field">
495
+ <span className="cs-hint">{t('provider')}</span>
496
+ <select className="cs-select" value={providerKey} onChange={(event) => { showProvider(targetNs, event.target.value) }}>
497
+ {providerKeys.map(key => <option key={key} value={key}>{key}</option>)}
498
+ </select>
499
+ </label>
500
+ <span className="cs-hint">{t('modelCount', { count: models.length })}</span>
501
+ <span className="cs-hint">{t('willSort')}</span>
502
+ </div>
503
+
504
+ {models.length === 0
505
+ ? <p className="cs-hint">{t('noModels')}</p>
506
+ : (
507
+ <div className="cs-row">
508
+ <button type="button" className="cs-button cs-button--primary" disabled={busy !== 'idle'} onClick={() => { void analyze(false) }}>
509
+ {busy === 'analyzing' ? t('analyzing') : t('analyze')}
510
+ </button>
511
+ <button type="button" className="cs-button" disabled={busy !== 'idle'} onClick={() => { void analyze(true) }}>
512
+ {t('refreshCatalog')}
513
+ </button>
514
+ <label className="cs-hint" title={t('overwriteHint')}>
515
+ <input type="checkbox" checked={overwrite} onChange={(event) => { setOverwriteMode(event.target.checked) }} />
516
+ {' '}{t('overwrite')}
517
+ </label>
518
+ </div>
519
+ )}
520
+
521
+ {errorText !== undefined ? <p className="cs-error">{errorText}</p> : null}
522
+ {notice !== undefined ? <p className="cs-hint">{notice}</p> : null}
523
+ {catalogInfo !== undefined ? <p className="cs-hint">{catalogInfo}</p> : null}
524
+ {staleInfo !== undefined ? <p className="cs-warn">{staleInfo}</p> : null}
525
+
526
+ {rows !== undefined && matched.length > 0
527
+ ? (
528
+ <>
529
+ <div className="cs-row">
530
+ <input
531
+ className="cs-input"
532
+ type="search"
533
+ value={query}
534
+ placeholder={t('search')}
535
+ onChange={(event) => { setQuery(event.target.value) }}
536
+ />
537
+ <label className="cs-hint">
538
+ <input type="checkbox" checked={onlyChanged} onChange={(event) => { setOnlyChanged(event.target.checked) }} />
539
+ {' '}{t('onlyChanged')}
540
+ </label>
541
+ <span className="cs-hint">
542
+ {changing.length === 0
543
+ ? t('noChanges')
544
+ : t('changeSummary', { rows: changing.length, fields: changeFields })}
545
+ </span>
546
+ </div>
547
+
548
+ {visible.length === 0
549
+ ? <p className="cs-hint">{t('filteredEmpty')}</p>
550
+ : (
551
+ <div className="cs-table-wrap">
552
+ <table className="cs-table">
553
+ <thead>
554
+ <tr>
555
+ <th>
556
+ <input
557
+ type="checkbox"
558
+ aria-label={t('selectAll')}
559
+ checked={selectable.length > 0 && selectable.every(entry => selected.has(entry.row.id))}
560
+ onChange={(event) => {
561
+ const keep = event.target.checked ? new Set(selectable.map(entry => entry.row.id)) : new Set<string>()
562
+ setSelected(keep)
563
+ }}
564
+ />
565
+ </th>
566
+ <th>{t('colModel')}</th>
567
+ <th>{t('colMatch')}</th>
568
+ <th>{t('colContext')}</th>
569
+ <th>{t('colOutput')}</th>
570
+ <th>{t('colImage')}</th>
571
+ <th title={t('colLiveHint')}>{t('colLive')}</th>
572
+ <th>{t('colReasoning')}</th>
573
+ </tr>
574
+ </thead>
575
+ <tbody>
576
+ {visible.map(({ row, model, changes }) => {
577
+ const changeOf = (key: keyof CapabilityFields): FieldChange | undefined =>
578
+ changes.find(change => change.key === key)
579
+ return (
580
+ <tr key={row.id} className={changes.length > 0 ? 'cs-row--changed' : undefined}>
581
+ <td>
582
+ <input
583
+ type="checkbox"
584
+ aria-label={row.id}
585
+ checked={selected.has(row.id)}
586
+ disabled={changes.length === 0}
587
+ onChange={() => { toggle(row.id) }}
588
+ />
589
+ </td>
590
+ <td className="cs-id">{row.id}</td>
591
+ <td className="cs-dim">
592
+ {row.match === undefined ? '—' : `${row.match.provider}/${row.match.modelId}`}
593
+ {' '}<span className="cs-badge">{viaLabel(row, t)}</span>
594
+ </td>
595
+ <td>{valueCell(changeOf('contextWindow'), model.contextWindow, tokens)}</td>
596
+ <td>{valueCell(changeOf('maxTokens'), model.maxTokens, tokens)}</td>
597
+ <td>
598
+ {valueCell(changeOf('input'), model.input, value => imageText(value, t))}
599
+ </td>
600
+ <td>
601
+ <span className={liveRows.get(row.id)?.input?.includes('image') === true ? 'cs-badge cs-badge--image' : 'cs-dim'}>
602
+ {liveLabel(liveRows.get(row.id), t)}
603
+ </span>
604
+ </td>
605
+ <td className="cs-dim">
606
+ {valueCell(changeOf('reasoningEfforts'), model.reasoningEfforts, reasoningText)}
607
+ </td>
608
+ </tr>
609
+ )
610
+ })}
611
+ </tbody>
612
+ </table>
613
+ </div>
614
+ )}
615
+
616
+ {liveRows.size > 0
617
+ ? <p className="cs-hint">{t('liveSummary', { image: liveImage, text: liveText })}</p>
618
+ : null}
619
+ <div className="cs-row">
620
+ <button
621
+ type="button"
622
+ className="cs-button cs-button--primary"
623
+ disabled={busy !== 'idle' || !changing.some(entry => selected.has(entry.row.id))}
624
+ onClick={() => { void applyRows() }}
625
+ >
626
+ {busy === 'applying' ? t('applying') : t('apply')}
627
+ </button>
628
+ </div>
629
+ </>
630
+ )
631
+ : null}
632
+
633
+ {unmatched.length > 0
634
+ ? (
635
+ <div>
636
+ <p className="cs-hint">{t('unmatchedHint')}</p>
637
+ <ul className="cs-unmatched">
638
+ {unmatched.map(entry => (
639
+ <li key={entry.row.id}>
640
+ {entry.row.id}
641
+ {entry.row.reason === undefined ? null : <span className="cs-dim"> — {entry.row.reason}</span>}
642
+ </li>
643
+ ))}
644
+ </ul>
645
+ </div>
646
+ )
647
+ : null}
648
+ </div>
649
+ )
650
+ }