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.
- package/LICENSE +21 -0
- package/README.md +114 -3
- package/cordis.patch.yml +8 -0
- package/lib/client.js +746 -0
- package/lib/client.js.map +7 -0
- package/lib/index.js +332 -0
- package/lib/index.js.map +7 -0
- package/package.json +88 -4
- package/scripts/build-client.mjs +42 -0
- package/scripts/build-host.mjs +22 -0
- package/scripts/smoke.mjs +56 -0
- package/scripts/verify-package.mjs +64 -0
- package/scripts/warm-cache.mjs +15 -0
- package/src/catalog.ts +204 -0
- package/src/client/SyncSection.tsx +650 -0
- package/src/client/apply.ts +223 -0
- package/src/client/index.ts +6 -0
- package/src/client/locale.ts +124 -0
- package/src/index.ts +148 -0
- package/src/mapping.ts +71 -0
- package/src/match.ts +130 -0
- package/src/sort.ts +62 -0
- package/src/types.ts +120 -0
|
@@ -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
|
+
}
|