dsh-hypatia-auto-memory 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,700 @@
1
+ /**
2
+ * Settings card for the `hypatia-auto-memory` namespace.
3
+ *
4
+ * The card owns its disclosure state and stages edits until Save. Its visual
5
+ * treatment mirrors the DSH plugin-settings cards while remaining bundle-local.
6
+ */
7
+
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
9
+ import { Button, IconChevronDownOutline14, Input } from '@deepseek-ai/dsh-client-ui-primitives'
10
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
11
+ import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
12
+ import {
13
+ consolidationModelCandidates,
14
+ consolidationModelKey,
15
+ type ConsolidationModelCandidate,
16
+ type ConsolidationModelRoute,
17
+ type LoadConsolidationModelCatalog,
18
+ } from './consolidation-models'
19
+ import { DEFAULT_SHELF, shelfChoices, type LoadShelfInventory, type ShelfInfo } from './shelves'
20
+ import css from './SettingsCard.module.css'
21
+ import { NS } from './locales'
22
+ import './slot-contract'
23
+
24
+ const DEFAULT_CONSOLIDATION = {
25
+ models: [] as ConsolidationModelRoute[],
26
+ maxInputTokens: 16000,
27
+ maxOutputTokens: 2000,
28
+ timeoutMs: 120000,
29
+ checkEveryTurns: 5,
30
+ minNewTokens: 3000,
31
+ maxWorkUnitsPerRun: 3,
32
+ adjudicate: true,
33
+ dedupMaxDistance: 0.45,
34
+ cascade: { enabled: true, batchSize: 16 },
35
+ }
36
+
37
+ export interface ConfigShape {
38
+ enabled?: boolean
39
+ autoApprove?: boolean
40
+ shelf?: string
41
+ consolidation?: typeof DEFAULT_CONSOLIDATION
42
+ recall?: { preloadRulesTaboos?: boolean }
43
+ }
44
+
45
+ function getSnapshotValue<T>(scope: SettingsScope<T>): SettingsScopeSnapshot<T> {
46
+ return scope.getSnapshot()
47
+ }
48
+
49
+ function subscribe(scope: SettingsScope<unknown>, cb: () => void) {
50
+ return scope.subscribe(cb)
51
+ }
52
+
53
+ function sectionValue<T>(snap: SettingsScopeSnapshot<T>): T | undefined {
54
+ return snap.value ?? (snap.base as T | undefined) ?? undefined
55
+ }
56
+
57
+ function useScopeValue<T>(scope: SettingsScope<T>) {
58
+ const [snap, setSnap] = useState(() => getSnapshotValue(scope))
59
+ useEffect(() => {
60
+ setSnap(getSnapshotValue(scope))
61
+ return subscribe(scope as SettingsScope<unknown>, () => setSnap(getSnapshotValue(scope)))
62
+ }, [scope])
63
+ const value = useMemo(() => sectionValue(snap), [snap])
64
+ return { snap, value }
65
+ }
66
+
67
+ function same(a: unknown, b: unknown): boolean {
68
+ return JSON.stringify(a) === JSON.stringify(b)
69
+ }
70
+
71
+ export type SettingsCardProps = {
72
+ scope: SettingsScope<ConfigShape>
73
+ loadModelCatalog: LoadConsolidationModelCatalog
74
+ loadShelfInventory: LoadShelfInventory
75
+ } & PropsLocale<typeof NS>
76
+
77
+ export function SettingsCard({ scope, loadModelCatalog, loadShelfInventory, t }: SettingsCardProps) {
78
+ const { snap, value } = useScopeValue(scope)
79
+ const disabled = !snap.writable
80
+ const base = useMemo(() => (snap.base ?? {}) as ConfigShape, [snap.base])
81
+
82
+ const resolved: ConfigShape = useMemo(() => (value ?? base ?? {
83
+ enabled: true,
84
+ autoApprove: true,
85
+ shelf: DEFAULT_SHELF,
86
+ consolidation: DEFAULT_CONSOLIDATION,
87
+ recall: { preloadRulesTaboos: true },
88
+ }), [value, base])
89
+
90
+ const [draft, setDraft] = useState(resolved)
91
+ const [open, setOpen] = useState(false)
92
+ const [saving, setSaving] = useState(false)
93
+ const [error, setError] = useState<string | null>(null)
94
+ const [catalogStatus, setCatalogStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
95
+ const [catalogGroups, setCatalogGroups] = useState<Parameters<typeof consolidationModelCandidates>[0]>([])
96
+ const [catalogPartial, setCatalogPartial] = useState(false)
97
+ const catalogGeneration = useRef(0)
98
+ const [shelfStatus, setShelfStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
99
+ const [shelves, setShelves] = useState<ShelfInfo[]>([])
100
+ const [shelfListingError, setShelfListingError] = useState('')
101
+ const shelfGeneration = useRef(0)
102
+
103
+ useEffect(() => {
104
+ setDraft(resolved)
105
+ }, [resolved])
106
+
107
+ const dirty = useMemo(() => !same(draft, resolved), [draft, resolved])
108
+ const consolidation = draft.consolidation ?? DEFAULT_CONSOLIDATION
109
+ const recall = draft.recall ?? { preloadRulesTaboos: true }
110
+ const saveBlocked = disabled || !dirty || saving
111
+
112
+ const updateConsolidation = useCallback((patch: Partial<typeof DEFAULT_CONSOLIDATION>) => {
113
+ setDraft((prev) => ({
114
+ ...prev,
115
+ consolidation: { ...(prev.consolidation ?? DEFAULT_CONSOLIDATION), ...patch },
116
+ }))
117
+ }, [])
118
+
119
+ const loadCatalog = useCallback(async () => {
120
+ const generation = ++catalogGeneration.current
121
+ setCatalogStatus('loading')
122
+ setCatalogPartial(false)
123
+ try {
124
+ const catalog = await loadModelCatalog()
125
+ if (generation !== catalogGeneration.current) return
126
+ setCatalogGroups(catalog.groups)
127
+ setCatalogPartial(catalog.partial)
128
+ setCatalogStatus('ready')
129
+ } catch {
130
+ if (generation !== catalogGeneration.current) return
131
+ setCatalogStatus('error')
132
+ }
133
+ }, [loadModelCatalog])
134
+
135
+ useEffect(() => {
136
+ if (!open) return
137
+ void loadCatalog()
138
+ return () => { catalogGeneration.current += 1 }
139
+ }, [open, loadCatalog])
140
+
141
+ const loadShelves = useCallback(async () => {
142
+ const generation = ++shelfGeneration.current
143
+ setShelfStatus('loading')
144
+ try {
145
+ const inventory = await loadShelfInventory()
146
+ if (generation !== shelfGeneration.current) return
147
+ setShelves(inventory.shelves)
148
+ setShelfListingError(inventory.error)
149
+ setShelfStatus('ready')
150
+ } catch {
151
+ if (generation !== shelfGeneration.current) return
152
+ setShelfStatus('error')
153
+ }
154
+ }, [loadShelfInventory])
155
+
156
+ useEffect(() => {
157
+ if (!open) return
158
+ void loadShelves()
159
+ return () => { shelfGeneration.current += 1 }
160
+ }, [open, loadShelves])
161
+
162
+ const draftShelf = draft.shelf ?? DEFAULT_SHELF
163
+ const baseShelf = base.shelf ?? DEFAULT_SHELF
164
+ const choices = useMemo(
165
+ () => shelfChoices(shelves, [draftShelf, resolved.shelf ?? DEFAULT_SHELF, baseShelf]),
166
+ [shelves, draftShelf, resolved.shelf, baseShelf],
167
+ )
168
+ const chosen = choices.find(choice => choice.name === draftShelf)
169
+
170
+ const candidates = useMemo(() => {
171
+ const models = consolidation.models ?? []
172
+ return consolidationModelCandidates(
173
+ catalogGroups,
174
+ models,
175
+ new Set(models.map(consolidationModelKey)),
176
+ )
177
+ }, [catalogGroups, consolidation.models])
178
+
179
+ const toggleModel = useCallback((candidate: ConsolidationModelCandidate) => {
180
+ if (disabled || saving) return
181
+ setDraft((current) => {
182
+ const currentConsolidation = current.consolidation ?? DEFAULT_CONSOLIDATION
183
+ const currentModels = currentConsolidation.models ?? []
184
+ const key = consolidationModelKey(candidate)
185
+ const selected = currentModels.some(route => consolidationModelKey(route) === key)
186
+ const models = selected
187
+ ? currentModels.filter(route => consolidationModelKey(route) !== key)
188
+ : [...currentModels, { provider: candidate.provider, model: candidate.model }]
189
+ return {
190
+ ...current,
191
+ consolidation: { ...currentConsolidation, models },
192
+ }
193
+ })
194
+ }, [disabled, saving])
195
+
196
+ const discard = useCallback(() => {
197
+ setDraft(resolved)
198
+ setError(null)
199
+ }, [resolved])
200
+
201
+ const save = useCallback(async () => {
202
+ if (saveBlocked) return
203
+ setSaving(true)
204
+ setError(null)
205
+ try {
206
+ const tasks: Promise<void>[] = []
207
+ if (!same(draft.enabled, resolved.enabled)) {
208
+ tasks.push(scope.set('enabled', draft.enabled))
209
+ }
210
+ if (!same(draft.autoApprove, resolved.autoApprove)) {
211
+ tasks.push(scope.set('autoApprove', draft.autoApprove))
212
+ }
213
+ if (!same(draft.shelf, resolved.shelf)) {
214
+ tasks.push(scope.set('shelf', draft.shelf))
215
+ }
216
+ if (!same(draft.consolidation, resolved.consolidation)) {
217
+ tasks.push(scope.set('consolidation', draft.consolidation))
218
+ }
219
+ if (!same(draft.recall, resolved.recall)) {
220
+ tasks.push(scope.set('recall', draft.recall))
221
+ }
222
+ await Promise.all(tasks)
223
+ } catch (err: unknown) {
224
+ const message = err instanceof Error ? err.message : String(err)
225
+ // eslint-disable-next-line no-console
226
+ console.error(`[${NS}] save failed:`, err)
227
+ setError(message)
228
+ } finally {
229
+ setSaving(false)
230
+ }
231
+ }, [draft, resolved, saveBlocked, scope])
232
+
233
+ if (snap.status === 'loading') {
234
+ return <li className={css.status}>{t('loading', { ns: NS })}</li>
235
+ }
236
+ if (snap.status === 'unavailable') {
237
+ return <li className={css.status}>{t('unavailable', { ns: NS })}</li>
238
+ }
239
+
240
+ const availableGroups = new Map<string, {
241
+ providerName: string
242
+ candidates: ConsolidationModelCandidate[]
243
+ }>()
244
+ const unavailable: ConsolidationModelCandidate[] = []
245
+ for (const candidate of candidates) {
246
+ if (!candidate.available) {
247
+ unavailable.push(candidate)
248
+ continue
249
+ }
250
+ const group = availableGroups.get(candidate.provider)
251
+ if (group === undefined) {
252
+ availableGroups.set(candidate.provider, {
253
+ providerName: candidate.providerName,
254
+ candidates: [candidate],
255
+ })
256
+ } else {
257
+ group.candidates.push(candidate)
258
+ }
259
+ }
260
+
261
+ const renderCandidate = (candidate: ConsolidationModelCandidate) => (
262
+ <label key={candidate.key} className={css.model}>
263
+ <input
264
+ type="checkbox"
265
+ checked={candidate.selected}
266
+ disabled={disabled || saving}
267
+ onChange={() => toggleModel(candidate)}
268
+ />
269
+ <span>
270
+ <span className={css.modelName}>{candidate.modelName}</span>
271
+ <span className={css.route}>{`${candidate.providerName} · ${candidate.provider}/${candidate.model}`}</span>
272
+ </span>
273
+ {!candidate.available ? <span className={css.unavailable}>{t('modelUnavailable')}</span> : null}
274
+ </label>
275
+ )
276
+
277
+ return (
278
+ <li className={`${css.card} ${open ? css.cardOpen : ''}`}>
279
+ <button
280
+ type="button"
281
+ className={css.header}
282
+ onClick={() => setOpen(!open)}
283
+ aria-expanded={open}
284
+ >
285
+ <span className={css.headText}>
286
+ <span className={css.name}>{t('title')}</span>
287
+ <span className={css.description}>{t('description')}</span>
288
+ </span>
289
+ {dirty ? <span className={css.pending}>{t('unsaved')}</span> : null}
290
+ <IconChevronDownOutline14 className={`${css.chevron} ${open ? css.chevronOpen : ''}`} />
291
+ </button>
292
+
293
+ {open ? (
294
+ <div className={css.body}>
295
+ {!snap.writable ? <p className={css.readOnly} role="status">{t('readOnly')}</p> : null}
296
+
297
+ <div className={css.field}>
298
+ <div className={css.fieldHead}>
299
+ <label className={css.label} htmlFor="ham-enabled">{t('enable')}</label>
300
+ {draft.enabled !== (base.enabled ?? true) ? (
301
+ <button
302
+ type="button"
303
+ className={css.reset}
304
+ disabled={disabled}
305
+ onClick={() => setDraft((current) => ({ ...current, enabled: base.enabled ?? true }))}
306
+ >
307
+ {t('reset')}
308
+ </button>
309
+ ) : null}
310
+ </div>
311
+ <button
312
+ id="ham-enabled"
313
+ type="button"
314
+ role="switch"
315
+ aria-checked={draft.enabled ?? true}
316
+ aria-label={t('enable')}
317
+ className={`${css.switch} ${draft.enabled ?? true ? css.switchOn : ''}`}
318
+ disabled={disabled}
319
+ onClick={() => setDraft((current) => ({ ...current, enabled: !(current.enabled ?? true) }))}
320
+ >
321
+ <span className={css.switchThumb} />
322
+ </button>
323
+ </div>
324
+
325
+ <div className={css.field}>
326
+ <div className={css.fieldHead}>
327
+ <label className={css.label} htmlFor="ham-autoApprove">{t('autoApprove')}</label>
328
+ {(draft.autoApprove ?? true) !== (base.autoApprove ?? true) ? (
329
+ <button
330
+ type="button"
331
+ className={css.reset}
332
+ disabled={disabled}
333
+ onClick={() => setDraft((current) => ({ ...current, autoApprove: base.autoApprove ?? true }))}
334
+ >
335
+ {t('reset')}
336
+ </button>
337
+ ) : null}
338
+ </div>
339
+ <button
340
+ id="ham-autoApprove"
341
+ type="button"
342
+ role="switch"
343
+ aria-checked={draft.autoApprove ?? true}
344
+ aria-label={t('autoApprove')}
345
+ className={`${css.switch} ${draft.autoApprove ?? true ? css.switchOn : ''}`}
346
+ disabled={disabled}
347
+ onClick={() => setDraft((current) => ({ ...current, autoApprove: !(current.autoApprove ?? true) }))}
348
+ >
349
+ <span className={css.switchThumb} />
350
+ </button>
351
+ <p className={css.hint}>{t('autoApproveHint')}</p>
352
+ </div>
353
+
354
+ <div className={css.field}>
355
+ <div className={css.fieldHead}>
356
+ <label className={css.label} htmlFor="ham-shelf">{t('shelf')}</label>
357
+ {draftShelf !== baseShelf ? (
358
+ <button
359
+ type="button"
360
+ className={css.reset}
361
+ disabled={disabled}
362
+ onClick={() => setDraft((current) => ({ ...current, shelf: baseShelf }))}
363
+ >
364
+ {t('reset')}
365
+ </button>
366
+ ) : null}
367
+ </div>
368
+ <select
369
+ id="ham-shelf"
370
+ className={css.select}
371
+ value={draftShelf}
372
+ disabled={disabled || saving}
373
+ onChange={(event) => {
374
+ const shelf = event.target.value
375
+ setDraft((current) => ({ ...current, shelf }))
376
+ }}
377
+ >
378
+ {choices.map(choice => (
379
+ <option key={choice.name} value={choice.name}>
380
+ {choice.name}
381
+ {choice.path !== '' ? ` — ${choice.path}` : ''}
382
+ {!choice.listed
383
+ ? t('shelfOptionStatus', { status: t('shelfNotListed') })
384
+ : !choice.connected ? t('shelfOptionStatus', { status: t('shelfDisconnected') }) : ''}
385
+ </option>
386
+ ))}
387
+ </select>
388
+ {shelfStatus === 'loading' ? <p className={css.notice} role="status">{t('shelfLoading')}</p> : null}
389
+ {shelfStatus === 'error' ? (
390
+ <div className={css.catalogError} role="alert">
391
+ <span>{t('shelfLoadFailed')}</span>
392
+ <button type="button" disabled={saving} onClick={() => { void loadShelves() }}>{t('retry')}</button>
393
+ </div>
394
+ ) : null}
395
+ {shelfStatus === 'ready' && shelfListingError !== '' ? (
396
+ <p className={css.notice} role="status">{t('shelfListingFailed', { message: shelfListingError })}</p>
397
+ ) : null}
398
+ {shelfStatus === 'ready' && shelfListingError === '' && chosen !== undefined && (!chosen.listed || !chosen.connected) ? (
399
+ <p className={css.warning} role="status">{t(chosen.listed ? 'shelfDisconnectedWarning' : 'shelfNotListedWarning', { shelf: chosen.name })}</p>
400
+ ) : null}
401
+ <p className={css.hint}>{t('shelfHint')}</p>
402
+ </div>
403
+
404
+ <section className={css.group} aria-labelledby="ham-consolidation-title">
405
+ <h3 id="ham-consolidation-title" className={css.groupTitle}>{t('consolidationTitle')}</h3>
406
+ <p className={css.hint}>{t('modelSelectionHint')}</p>
407
+
408
+ <div className={css.modelSelection}>
409
+ {catalogStatus === 'loading' ? <p className={css.notice} role="status">{t('modelCatalogLoading')}</p> : null}
410
+ {catalogStatus === 'error' ? (
411
+ <div className={css.catalogError} role="alert">
412
+ <span>{t('modelCatalogFailed')}</span>
413
+ <button type="button" disabled={saving} onClick={() => { void loadCatalog() }}>{t('retry')}</button>
414
+ </div>
415
+ ) : null}
416
+ {catalogPartial ? <p className={css.notice}>{t('modelCatalogPartial')}</p> : null}
417
+ {candidates.length > 0 ? (
418
+ <fieldset className={css.models}>
419
+ <legend>{t('selectedModels')}</legend>
420
+ {[...availableGroups].map(([provider, group]) => (
421
+ <div key={provider} className={css.modelGroup}>
422
+ <div className={css.providerName}>{group.providerName}</div>
423
+ {group.candidates.map(renderCandidate)}
424
+ </div>
425
+ ))}
426
+ {unavailable.length > 0 ? (
427
+ <div className={css.modelGroup}>
428
+ <div className={css.providerName}>{t('unavailableModels')}</div>
429
+ {unavailable.map(renderCandidate)}
430
+ </div>
431
+ ) : null}
432
+ </fieldset>
433
+ ) : catalogStatus === 'ready' ? <p className={css.notice}>{t('modelCatalogEmpty')}</p> : null}
434
+ </div>
435
+
436
+ <div className={css.pairedFields}>
437
+ <div className={css.field}>
438
+ <div className={css.fieldHead}>
439
+ <label className={css.label} htmlFor="ham-checkEveryTurns">{t('checkEveryTurns')}</label>
440
+ {consolidation.checkEveryTurns !== (base.consolidation?.checkEveryTurns ?? DEFAULT_CONSOLIDATION.checkEveryTurns) ? (
441
+ <button
442
+ type="button"
443
+ className={css.reset}
444
+ disabled={disabled}
445
+ onClick={() => updateConsolidation({ checkEveryTurns: base.consolidation?.checkEveryTurns ?? DEFAULT_CONSOLIDATION.checkEveryTurns })}
446
+ >
447
+ {t('reset')}
448
+ </button>
449
+ ) : null}
450
+ </div>
451
+ <Input
452
+ id="ham-checkEveryTurns"
453
+ className={css.input}
454
+ type="number"
455
+ min={1}
456
+ value={consolidation.checkEveryTurns}
457
+ disabled={disabled}
458
+ onChange={(event) => updateConsolidation({ checkEveryTurns: Number(event.target.value) })}
459
+ />
460
+ </div>
461
+ <div className={css.field}>
462
+ <div className={css.fieldHead}>
463
+ <label className={css.label} htmlFor="ham-minNewTokens">{t('minNewTokens')}</label>
464
+ {consolidation.minNewTokens !== (base.consolidation?.minNewTokens ?? DEFAULT_CONSOLIDATION.minNewTokens) ? (
465
+ <button
466
+ type="button"
467
+ className={css.reset}
468
+ disabled={disabled}
469
+ onClick={() => updateConsolidation({ minNewTokens: base.consolidation?.minNewTokens ?? DEFAULT_CONSOLIDATION.minNewTokens })}
470
+ >
471
+ {t('reset')}
472
+ </button>
473
+ ) : null}
474
+ </div>
475
+ <Input
476
+ id="ham-minNewTokens"
477
+ className={css.input}
478
+ type="number"
479
+ min={0}
480
+ value={consolidation.minNewTokens}
481
+ disabled={disabled}
482
+ onChange={(event) => updateConsolidation({ minNewTokens: Number(event.target.value) })}
483
+ />
484
+ </div>
485
+ </div>
486
+
487
+ <div className={css.pairedFields}>
488
+ <div className={css.field}>
489
+ <div className={css.fieldHead}>
490
+ <label className={css.label} htmlFor="ham-cascadeBatchSize">{t('cascadeBatchSize')}</label>
491
+ {(consolidation.cascade?.batchSize ?? DEFAULT_CONSOLIDATION.cascade.batchSize)
492
+ !== (base.consolidation?.cascade?.batchSize ?? DEFAULT_CONSOLIDATION.cascade.batchSize) ? (
493
+ <button
494
+ type="button"
495
+ className={css.reset}
496
+ disabled={disabled}
497
+ onClick={() => updateConsolidation({
498
+ cascade: {
499
+ ...(consolidation.cascade ?? DEFAULT_CONSOLIDATION.cascade),
500
+ batchSize: base.consolidation?.cascade?.batchSize ?? DEFAULT_CONSOLIDATION.cascade.batchSize,
501
+ },
502
+ })}
503
+ >
504
+ {t('reset')}
505
+ </button>
506
+ ) : null}
507
+ </div>
508
+ <Input
509
+ id="ham-cascadeBatchSize"
510
+ className={css.input}
511
+ type="number"
512
+ min={2}
513
+ value={consolidation.cascade?.batchSize ?? DEFAULT_CONSOLIDATION.cascade.batchSize}
514
+ disabled={disabled}
515
+ onChange={(event) => updateConsolidation({
516
+ cascade: {
517
+ ...(consolidation.cascade ?? DEFAULT_CONSOLIDATION.cascade),
518
+ batchSize: Number(event.target.value),
519
+ },
520
+ })}
521
+ />
522
+ </div>
523
+ <div className={css.field}>
524
+ <div className={css.fieldHead}>
525
+ <label className={css.label} htmlFor="ham-dedupMaxDistance">{t('dedupMaxDistance')}</label>
526
+ {(consolidation.dedupMaxDistance ?? DEFAULT_CONSOLIDATION.dedupMaxDistance)
527
+ !== (base.consolidation?.dedupMaxDistance ?? DEFAULT_CONSOLIDATION.dedupMaxDistance) ? (
528
+ <button
529
+ type="button"
530
+ className={css.reset}
531
+ disabled={disabled}
532
+ onClick={() => updateConsolidation({
533
+ dedupMaxDistance: base.consolidation?.dedupMaxDistance ?? DEFAULT_CONSOLIDATION.dedupMaxDistance,
534
+ })}
535
+ >
536
+ {t('reset')}
537
+ </button>
538
+ ) : null}
539
+ </div>
540
+ <Input
541
+ id="ham-dedupMaxDistance"
542
+ className={css.input}
543
+ type="number"
544
+ min={0}
545
+ step={0.05}
546
+ value={consolidation.dedupMaxDistance ?? DEFAULT_CONSOLIDATION.dedupMaxDistance}
547
+ disabled={disabled}
548
+ onChange={(event) => updateConsolidation({ dedupMaxDistance: Number(event.target.value) })}
549
+ />
550
+ </div>
551
+ </div>
552
+ <p className={css.hint}>{t('cascadeHint')}</p>
553
+
554
+ <h4 className={css.limitsTitle}>{t('limitsTitle')}</h4>
555
+ <p className={css.hint}>{t('limitsHint')}</p>
556
+ <div className={css.pairedFields}>
557
+ <div className={css.field}>
558
+ <div className={css.fieldHead}>
559
+ <label className={css.label} htmlFor="ham-maxInputTokens">{t('maxInputTokens')}</label>
560
+ {consolidation.maxInputTokens !== (base.consolidation?.maxInputTokens ?? DEFAULT_CONSOLIDATION.maxInputTokens) ? (
561
+ <button
562
+ type="button"
563
+ className={css.reset}
564
+ disabled={disabled}
565
+ onClick={() => updateConsolidation({ maxInputTokens: base.consolidation?.maxInputTokens ?? DEFAULT_CONSOLIDATION.maxInputTokens })}
566
+ >
567
+ {t('reset')}
568
+ </button>
569
+ ) : null}
570
+ </div>
571
+ <Input
572
+ id="ham-maxInputTokens"
573
+ className={css.input}
574
+ type="number"
575
+ min={1000}
576
+ step={1000}
577
+ value={consolidation.maxInputTokens}
578
+ disabled={disabled}
579
+ onChange={(event) => updateConsolidation({ maxInputTokens: Number(event.target.value) })}
580
+ />
581
+ </div>
582
+ <div className={css.field}>
583
+ <div className={css.fieldHead}>
584
+ <label className={css.label} htmlFor="ham-maxOutputTokens">{t('maxOutputTokens')}</label>
585
+ {consolidation.maxOutputTokens !== (base.consolidation?.maxOutputTokens ?? DEFAULT_CONSOLIDATION.maxOutputTokens) ? (
586
+ <button
587
+ type="button"
588
+ className={css.reset}
589
+ disabled={disabled}
590
+ onClick={() => updateConsolidation({ maxOutputTokens: base.consolidation?.maxOutputTokens ?? DEFAULT_CONSOLIDATION.maxOutputTokens })}
591
+ >
592
+ {t('reset')}
593
+ </button>
594
+ ) : null}
595
+ </div>
596
+ <Input
597
+ id="ham-maxOutputTokens"
598
+ className={css.input}
599
+ type="number"
600
+ min={200}
601
+ step={200}
602
+ value={consolidation.maxOutputTokens}
603
+ disabled={disabled}
604
+ onChange={(event) => updateConsolidation({ maxOutputTokens: Number(event.target.value) })}
605
+ />
606
+ </div>
607
+ <div className={css.field}>
608
+ <div className={css.fieldHead}>
609
+ <label className={css.label} htmlFor="ham-maxWorkUnitsPerRun">{t('maxWorkUnitsPerRun')}</label>
610
+ {consolidation.maxWorkUnitsPerRun !== (base.consolidation?.maxWorkUnitsPerRun ?? DEFAULT_CONSOLIDATION.maxWorkUnitsPerRun) ? (
611
+ <button
612
+ type="button"
613
+ className={css.reset}
614
+ disabled={disabled}
615
+ onClick={() => updateConsolidation({ maxWorkUnitsPerRun: base.consolidation?.maxWorkUnitsPerRun ?? DEFAULT_CONSOLIDATION.maxWorkUnitsPerRun })}
616
+ >
617
+ {t('reset')}
618
+ </button>
619
+ ) : null}
620
+ </div>
621
+ <Input
622
+ id="ham-maxWorkUnitsPerRun"
623
+ className={css.input}
624
+ type="number"
625
+ min={1}
626
+ max={10}
627
+ value={consolidation.maxWorkUnitsPerRun}
628
+ disabled={disabled}
629
+ onChange={(event) => updateConsolidation({ maxWorkUnitsPerRun: Number(event.target.value) })}
630
+ />
631
+ </div>
632
+ </div>
633
+ </section>
634
+
635
+ <div className={css.field}>
636
+ <div className={css.fieldHead}>
637
+ <label className={css.label} htmlFor="ham-preloadRulesTaboos">{t('recallPreload')}</label>
638
+ {(recall.preloadRulesTaboos ?? true) !== (base.recall?.preloadRulesTaboos ?? true) ? (
639
+ <button
640
+ type="button"
641
+ className={css.reset}
642
+ disabled={disabled}
643
+ onClick={() => setDraft((current) => ({
644
+ ...current,
645
+ recall: {
646
+ ...(current.recall ?? { preloadRulesTaboos: true }),
647
+ preloadRulesTaboos: base.recall?.preloadRulesTaboos ?? true,
648
+ },
649
+ }))}
650
+ >
651
+ {t('reset')}
652
+ </button>
653
+ ) : null}
654
+ </div>
655
+ <button
656
+ id="ham-preloadRulesTaboos"
657
+ type="button"
658
+ role="switch"
659
+ aria-checked={recall.preloadRulesTaboos ?? true}
660
+ aria-label={t('recallPreload')}
661
+ className={`${css.switch} ${recall.preloadRulesTaboos ?? true ? css.switchOn : ''}`}
662
+ disabled={disabled}
663
+ onClick={() => setDraft((current) => ({
664
+ ...current,
665
+ recall: {
666
+ ...(current.recall ?? { preloadRulesTaboos: true }),
667
+ preloadRulesTaboos: !(current.recall?.preloadRulesTaboos ?? true),
668
+ },
669
+ }))}
670
+ >
671
+ <span className={css.switchThumb} />
672
+ </button>
673
+ </div>
674
+
675
+ <p className={css.advancedHint}>{t('advancedHint', { ns: NS })}</p>
676
+ {error ? <p className={css.error} role="status">{t('saveFailed', { message: error })}</p> : null}
677
+
678
+ <div className={css.footer}>
679
+ <Button
680
+ variant="outline"
681
+ size="sm"
682
+ disabled={!dirty || saving}
683
+ onClick={discard}
684
+ >
685
+ {t('discard')}
686
+ </Button>
687
+ <Button
688
+ variant="primary"
689
+ size="sm"
690
+ disabled={saveBlocked}
691
+ onClick={save}
692
+ >
693
+ {saving ? t('saving') : t('save')}
694
+ </Button>
695
+ </div>
696
+ </div>
697
+ ) : null}
698
+ </li>
699
+ )
700
+ }