dsh-context-compression-improved 0.5.0 → 0.5.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/CHANGELOG.ja.md +51 -0
- package/CHANGELOG.ko.md +51 -0
- package/CHANGELOG.md +55 -0
- package/CHANGELOG.zh.md +45 -0
- package/package.json +1 -1
- package/packages/selector/lib/advisor-state.js +4 -231
- package/packages/selector/lib/client.d.ts +0 -24
- package/packages/selector/lib/client.js +6 -501
- package/packages/selector/lib/index.d.ts +4 -10
- package/packages/selector/lib/index.js +65 -235
- package/packages/selector/lib/pruner.d.ts +13 -248
- package/packages/selector/lib/pruner.js +148 -552
- package/packages/selector/src/client/EstimatorControls.tsx +277 -378
- package/packages/selector/src/client/index.ts +0 -17
- package/packages/selector/src/client/locales.ts +196 -234
- package/packages/selector/src/client/preset-options.ts +3 -2
- package/packages/selector/src/client/settings-section.tsx +8 -17
- package/packages/selector/src/index.ts +463 -710
- package/packages/selector/src/preset-overlay.ts +60 -1
- package/packages/selector/src/profiles.ts +4 -27
- package/packages/selector/src/pruner/state.ts +50 -73
- package/packages/selector/src/pruner.ts +2402 -2730
- package/packages/selector/src/runtime/audit.ts +27 -21
- package/packages/selector/src/runtime/config.ts +6 -32
- package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +16 -0
- package/packages/selector/src/runtime/tokenpilot/benefit.ts +200 -0
- package/packages/selector/src/runtime/types.ts +0 -17
- package/packages/selector/tests/built/client-artifact.spec.ts +9 -5
- package/packages/selector/tests/preset-options-write.client.spec.ts +7 -23
- package/packages/selector/tests/runtime/advice-never-withholds.host.spec.ts +232 -0
- package/packages/selector/tests/runtime/audit.spec.ts +35 -21
- package/packages/selector/tests/runtime/deprecated-preset-options.spec.ts +96 -0
- package/packages/selector/tests/runtime/tokenpilot/benefit.spec.ts +217 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +4 -5
- package/packages/selector/tests/settings-seat.client.spec.ts +16 -10
- package/packages/selector/tests/standing-generation.host.spec.ts +54 -5
- package/scripts/packed-components-smoke.mjs +30 -8
- package/scripts/packed-install-e2e.mjs +69 -15
- package/packages/selector/src/client/ReviewOverlay.tsx +0 -320
- package/packages/selector/src/client/review-scope.ts +0 -16
- package/packages/selector/src/runtime/tokenpilot/proposal.ts +0 -267
- package/packages/selector/src/runtime/tokenpilot/review-queue.ts +0 -231
- package/packages/selector/src/runtime/tokenpilot/review-registry.ts +0 -117
- package/packages/selector/src/runtime/tokenpilot/review-storage.ts +0 -122
- package/packages/selector/tests/review-overlay.client.spec.tsx +0 -118
- package/packages/selector/tests/review-routes-registry.host.spec.ts +0 -142
- package/packages/selector/tests/review-routes.host.spec.ts +0 -290
- package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +0 -393
- package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +0 -382
- package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +0 -168
|
@@ -1,378 +1,277 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TokenPilot-inspired estimator channel card components.
|
|
3
|
-
*
|
|
4
|
-
* Extracted from CompressionProfileSelector.tsx to reduce god-module size.
|
|
5
|
-
*
|
|
6
|
-
* @module dsh-context-compression-improved/client/EstimatorControls
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { useEffect, useState } from 'react'
|
|
10
|
-
import type { ContextCompressionLocaleKey } from './locales.ts'
|
|
11
|
-
import type { PresetOptionsPatch } from './preset-options.ts'
|
|
12
|
-
import type { PresetOptionsSettings } from '../profiles.ts'
|
|
13
|
-
import css from './CompressionProfileSelector.module.css'
|
|
14
|
-
|
|
15
|
-
interface EstimatorControlsProps {
|
|
16
|
-
options: PresetOptionsSettings
|
|
17
|
-
disabled: boolean
|
|
18
|
-
save: (options: PresetOptionsPatch) => Promise<void>
|
|
19
|
-
settle: (operation: () => Promise<void>) => void
|
|
20
|
-
t: (key: ContextCompressionLocaleKey) => string
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface EstimatorInactiveNoticeProps {
|
|
24
|
-
/** Already-localized label of the profile that currently owns the selector. */
|
|
25
|
-
profile: string
|
|
26
|
-
t: (key: ContextCompressionLocaleKey) => string
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* The estimator card is gated on the tokenpilot-inspired profile, because
|
|
31
|
-
* `presetOptions` is merged into that profile alone. A card that merely
|
|
32
|
-
* disappears reads as a missing feature — the first real-machine report was
|
|
33
|
-
* exactly that — so keep the heading and its anchor id in place and spend them
|
|
34
|
-
* on the reason plus the profile that unlocks the card. The gate itself is
|
|
35
|
-
* unchanged: no estimator control exists outside tokenpilot-inspired.
|
|
36
|
-
*
|
|
37
|
-
* Exported from the estimator card's own module so the gated card and its
|
|
38
|
-
* explanation cannot drift apart.
|
|
39
|
-
*/
|
|
40
|
-
export function EstimatorInactiveNotice({ profile, t }: EstimatorInactiveNoticeProps) {
|
|
41
|
-
return (
|
|
42
|
-
<section className={css.autoCompact} aria-labelledby="context-compression-estimator-title">
|
|
43
|
-
<h3 id="context-compression-estimator-title" className={css.autoCompactTitle}>{t('estimator.title')}</h3>
|
|
44
|
-
<p className={css.customNote}>{t('estimator.inactive').replace('{profile}', profile)}</p>
|
|
45
|
-
</section>
|
|
46
|
-
)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface CatalogModelEntry { readonly id: string, readonly name: string }
|
|
50
|
-
interface CatalogProviderEntry { readonly id: string, readonly name: string, readonly models: readonly CatalogModelEntry[], readonly error?: string }
|
|
51
|
-
interface EstimatorCatalogBody {
|
|
52
|
-
readonly ok?: boolean
|
|
53
|
-
readonly providers?: readonly CatalogProviderEntry[]
|
|
54
|
-
readonly selection?: { readonly provider: string, readonly model: string }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const ESTIMATOR_CATALOG_ROUTE = '/api/dsh-context-compression-improved/estimator-catalog'
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* TokenPilot-inspired estimator channel card. Shown only while the
|
|
61
|
-
* tokenpilot-inspired profile is selected, and split by channel:
|
|
62
|
-
*
|
|
63
|
-
* - `host` reuses the providers and credentials the user already configured in
|
|
64
|
-
* DSH through the Harness `llm` service, so this card names a provider and a
|
|
65
|
-
* model and accepts NO API key — the key field belongs to the direct channel
|
|
66
|
-
* alone.
|
|
67
|
-
* - `direct` talks to a native OpenAI-compatible endpoint, which is the only
|
|
68
|
-
* channel that carries its own base URL and write-only key.
|
|
69
|
-
*
|
|
70
|
-
* The whole card is advisory: an unconfigured or failing endpoint keeps every
|
|
71
|
-
* consumer on its rule-only fallback.
|
|
72
|
-
*/
|
|
73
|
-
export function EstimatorControls({ options, disabled, save, settle, t }: EstimatorControlsProps) {
|
|
74
|
-
const [keyDraft, setKeyDraft] = useState('')
|
|
75
|
-
const [baseUrl, setBaseUrl] = useState(options.estimatorBaseUrl ?? '')
|
|
76
|
-
const [model, setModel] = useState(options.estimatorModel ?? '')
|
|
77
|
-
const [provider, setProvider] = useState(options.estimatorProvider ?? '')
|
|
78
|
-
const mode = options.estimatorMode ?? ''
|
|
79
|
-
// Host-mode dropdown source: the live provider/model-group catalog served by
|
|
80
|
-
// the Bundle entry's estimator-catalog route (dsh-perm-gate receiver
|
|
81
|
-
// pattern). The provider/model fields are always comboboxes: an empty
|
|
82
|
-
// catalog (route missing / llm service absent) simply offers no suggestions
|
|
83
|
-
// while custom typing keeps working, so nothing breaks on hosts without the
|
|
84
|
-
// webServer.
|
|
85
|
-
const [catalog, setCatalog] = useState<EstimatorCatalogBody | undefined>()
|
|
86
|
-
useEffect(() => {
|
|
87
|
-
if (mode !== 'host') return
|
|
88
|
-
let alive = true
|
|
89
|
-
// The catalog route answers once the host webServer/llm services are up;
|
|
90
|
-
// poll briefly so the dropdowns fill without reopening the panel.
|
|
91
|
-
let attempts = 0
|
|
92
|
-
const load = async (): Promise<EstimatorCatalogBody | undefined> => {
|
|
93
|
-
try {
|
|
94
|
-
const response = await fetch(ESTIMATOR_CATALOG_ROUTE, { headers: { 'cache-control': 'no-cache' } })
|
|
95
|
-
return response.ok ? (await response.json()) as EstimatorCatalogBody : undefined
|
|
96
|
-
} catch {
|
|
97
|
-
return undefined
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const tick = (): void => {
|
|
101
|
-
attempts += 1
|
|
102
|
-
void load().then((body) => {
|
|
103
|
-
if (!alive) return
|
|
104
|
-
if (body !== undefined && (body.providers?.length ?? 0) > 0) {
|
|
105
|
-
setCatalog(body)
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
if (attempts < 10) setTimeout(tick, 3_000)
|
|
109
|
-
})
|
|
110
|
-
}
|
|
111
|
-
tick()
|
|
112
|
-
return () => { alive = false }
|
|
113
|
-
}, [mode])
|
|
114
|
-
const hostProviders = catalog?.providers ?? []
|
|
115
|
-
// Model suggestions follow the provider draft exactly like dsh-perm-gate's
|
|
116
|
-
// receiver card: an empty provider lists every group's models, a chosen
|
|
117
|
-
// provider narrows to its own. The provider error (if any) rides along in
|
|
118
|
-
// the option label so a broken group is visible without blocking typing.
|
|
119
|
-
const providerDraft = provider
|
|
120
|
-
const hostModels = hostProviders
|
|
121
|
-
.filter(entry => providerDraft === '' || entry.id === providerDraft)
|
|
122
|
-
.flatMap(entry => entry.models.map(model => ({ ...model, provider: entry.id })))
|
|
123
|
-
const hostProvider = hostProviders.find(entry => entry.id === providerDraft)
|
|
124
|
-
?? hostProviders.find(entry => entry.id === (options.estimatorProvider ?? ''))
|
|
125
|
-
const hasKey = (options.estimatorApiKey ?? '') !== ''
|
|
126
|
-
const commit = (patch: PresetOptionsPatch) => {
|
|
127
|
-
settle(() => save(patch))
|
|
128
|
-
}
|
|
129
|
-
// What the host channel would actually call right now: an explicit override
|
|
130
|
-
// wins, otherwise the session default the catalog reports. Naming the
|
|
131
|
-
// resolved route is the whole point of this channel — the user re-enters
|
|
132
|
-
// nothing the Harness already knows.
|
|
133
|
-
const overrideProvider = options.estimatorProvider ?? ''
|
|
134
|
-
const overrideModel = options.estimatorModel ?? ''
|
|
135
|
-
const effectiveProvider = overrideProvider !== '' ? overrideProvider : catalog?.selection?.provider ?? ''
|
|
136
|
-
const effectiveModel = overrideModel !== '' ? overrideModel : catalog?.selection?.model ?? ''
|
|
137
|
-
const effectiveRoute = effectiveProvider !== '' && effectiveModel !== ''
|
|
138
|
-
? `${effectiveProvider} / ${effectiveModel}`
|
|
139
|
-
: t('estimator.hostUnresolved')
|
|
140
|
-
return (
|
|
141
|
-
<section className={css.autoCompact} aria-labelledby="context-compression-estimator-title">
|
|
142
|
-
<h3 id="context-compression-estimator-title" className={css.autoCompactTitle}>{t('estimator.title')}</h3>
|
|
143
|
-
<p className={css.customNote}>{t('estimator.description')}</p>
|
|
144
|
-
<label className={css.field}>
|
|
145
|
-
<span>{t('estimator.mode')}</span>
|
|
146
|
-
<select
|
|
147
|
-
value={mode}
|
|
148
|
-
disabled={disabled}
|
|
149
|
-
onChange={(event) => { settle(() => save({ estimatorMode: event.currentTarget.value as '' | 'host' | 'direct' })) }}
|
|
150
|
-
>
|
|
151
|
-
<option value="">{t('estimator.mode.off')}</option>
|
|
152
|
-
<option value="host">{t('estimator.mode.host')}</option>
|
|
153
|
-
<option value="direct">{t('estimator.mode.direct')}</option>
|
|
154
|
-
</select>
|
|
155
|
-
</label>
|
|
156
|
-
{mode === '' ? null : mode === 'host' ? (
|
|
157
|
-
<>
|
|
158
|
-
<label className={css.field}>
|
|
159
|
-
<span>{t('estimator.provider')}</span>
|
|
160
|
-
<input
|
|
161
|
-
type="text"
|
|
162
|
-
list="estimator-provider-options"
|
|
163
|
-
value={provider}
|
|
164
|
-
disabled={disabled}
|
|
165
|
-
placeholder={t('estimator.provider.placeholder')}
|
|
166
|
-
onChange={(event) => {
|
|
167
|
-
const next = event.currentTarget.value
|
|
168
|
-
setProvider(next)
|
|
169
|
-
// Picking from the datalist fires change without blur: commit
|
|
170
|
-
// an exact catalog hit immediately; free typing waits for blur
|
|
171
|
-
// so mid-edit keystrokes never disable the panel.
|
|
172
|
-
if (next !== '' && hostProviders.some(entry => entry.id === next)) {
|
|
173
|
-
commit({ estimatorProvider: next })
|
|
174
|
-
}
|
|
175
|
-
}}
|
|
176
|
-
onBlur={() => { if (provider !== (options.estimatorProvider ?? '')) commit({ estimatorProvider: provider }) }}
|
|
177
|
-
/>
|
|
178
|
-
</label>
|
|
179
|
-
{/* Datalists ride outside the labels on purpose: option text inside a
|
|
180
|
-
label would leak into its accessible name. */}
|
|
181
|
-
<datalist id="estimator-provider-options">
|
|
182
|
-
{hostProviders.map(entry => (
|
|
183
|
-
<option key={entry.id} value={entry.id}>
|
|
184
|
-
{entry.name === '' ? entry.id : entry.name}{entry.error === undefined ? '' : ` (${entry.error})`}
|
|
185
|
-
</option>
|
|
186
|
-
))}
|
|
187
|
-
</datalist>
|
|
188
|
-
<label className={css.field}>
|
|
189
|
-
<span>{t('estimator.model')}</span>
|
|
190
|
-
<input
|
|
191
|
-
type="text"
|
|
192
|
-
list="estimator-model-options"
|
|
193
|
-
value={model}
|
|
194
|
-
disabled={disabled}
|
|
195
|
-
placeholder={t('estimator.model.placeholder')}
|
|
196
|
-
onChange={(event) => {
|
|
197
|
-
const next = event.currentTarget.value
|
|
198
|
-
setModel(next)
|
|
199
|
-
if (next !== '' && hostModels.some(entry => entry.id === next)) {
|
|
200
|
-
commit({ estimatorModel: next })
|
|
201
|
-
}
|
|
202
|
-
}}
|
|
203
|
-
onBlur={() => { if (model !== (options.estimatorModel ?? '')) commit({ estimatorModel: model }) }}
|
|
204
|
-
/>
|
|
205
|
-
</label>
|
|
206
|
-
<datalist id="estimator-model-options">
|
|
207
|
-
{hostModels.map(entry => (
|
|
208
|
-
<option key={`${entry.provider}\0${entry.id}`} value={entry.id}>{entry.name}</option>
|
|
209
|
-
))}
|
|
210
|
-
</datalist>
|
|
211
|
-
{hostProvider?.error === undefined ? null : (
|
|
212
|
-
<p className={css.customNote}>{String(hostProvider.error)}</p>
|
|
213
|
-
)}
|
|
214
|
-
<p className={css.customNote}>{t('estimator.hostReuse')}</p>
|
|
215
|
-
<p className={css.customNote}>{t('estimator.hostRoute').replace('{route}', effectiveRoute)}</p>
|
|
216
|
-
</>
|
|
217
|
-
) : (
|
|
218
|
-
<>
|
|
219
|
-
<label className={css.field}>
|
|
220
|
-
<span>{t('estimator.baseUrl')}</span>
|
|
221
|
-
<input
|
|
222
|
-
type="text"
|
|
223
|
-
value={baseUrl}
|
|
224
|
-
disabled={disabled}
|
|
225
|
-
placeholder="https://127.0.0.1:8000/v1"
|
|
226
|
-
onChange={(event) => { setBaseUrl(event.currentTarget.value) }}
|
|
227
|
-
onBlur={() => { if (baseUrl !== (options.estimatorBaseUrl ?? '')) commit({ estimatorBaseUrl: baseUrl }) }}
|
|
228
|
-
/>
|
|
229
|
-
</label>
|
|
230
|
-
<label className={css.field}>
|
|
231
|
-
<span>{t('estimator.model')}</span>
|
|
232
|
-
<input
|
|
233
|
-
type="text"
|
|
234
|
-
value={model}
|
|
235
|
-
disabled={disabled}
|
|
236
|
-
onChange={(event) => { setModel(event.currentTarget.value) }}
|
|
237
|
-
onBlur={() => { if (model !== (options.estimatorModel ?? '')) commit({ estimatorModel: model }) }}
|
|
238
|
-
/>
|
|
239
|
-
</label>
|
|
240
|
-
<label className={css.field}>
|
|
241
|
-
<span>{t('estimator.apiKey')}</span>
|
|
242
|
-
<div style={{ display: 'flex', gap: '6px' }}>
|
|
243
|
-
<input
|
|
244
|
-
type="password"
|
|
245
|
-
autoComplete="off"
|
|
246
|
-
spellCheck={false}
|
|
247
|
-
value={keyDraft}
|
|
248
|
-
disabled={disabled}
|
|
249
|
-
placeholder={hasKey ? t('estimator.apiKey.set') : t('estimator.apiKey.placeholder')}
|
|
250
|
-
onChange={(event) => { setKeyDraft(event.currentTarget.value) }}
|
|
251
|
-
onBlur={() => {
|
|
252
|
-
const next = keyDraft.trim()
|
|
253
|
-
if (next === '') return
|
|
254
|
-
settle(() => save({ estimatorApiKey: next }))
|
|
255
|
-
setKeyDraft('')
|
|
256
|
-
}}
|
|
257
|
-
onKeyDown={(event) => {
|
|
258
|
-
if (event.key === 'Enter') event.currentTarget.blur()
|
|
259
|
-
}}
|
|
260
|
-
/>
|
|
261
|
-
{hasKey ? (
|
|
262
|
-
<button
|
|
263
|
-
type="button"
|
|
264
|
-
disabled={disabled}
|
|
265
|
-
onClick={() => { settle(() => save({ estimatorApiKey: undefined })) }}
|
|
266
|
-
>
|
|
267
|
-
{t('estimator.apiKey.clear')}
|
|
268
|
-
</button>
|
|
269
|
-
) : null}
|
|
270
|
-
</div>
|
|
271
|
-
{hasKey ? <span className={css.customNote}>{t('estimator.apiKey.overwrite')}</span> : null}
|
|
272
|
-
</label>
|
|
273
|
-
</>
|
|
274
|
-
)}
|
|
275
|
-
</section>
|
|
276
|
-
)
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
interface ReviewModeControlsProps {
|
|
280
|
-
options: PresetOptionsSettings
|
|
281
|
-
disabled: boolean
|
|
282
|
-
save: (options: PresetOptionsPatch) => Promise<void>
|
|
283
|
-
settle: (operation: () => Promise<void>) => void
|
|
284
|
-
t: (key: ContextCompressionLocaleKey) => string
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* TokenPilot-inspired review-mode card (beta). When enabled, edge/high-impact
|
|
289
|
-
* reduction candidates queue for manual approval and execute in one merged
|
|
290
|
-
* batch at the next turn boundary; the numeric fields tune the benefit model.
|
|
291
|
-
* Numeric drafts commit on blur and only when they parse to a value the
|
|
292
|
-
* runtime schema accepts, so an invalid keystroke never disables the panel.
|
|
293
|
-
*/
|
|
294
|
-
export function ReviewModeControls({ options, disabled, save, settle, t }: ReviewModeControlsProps) {
|
|
295
|
-
const [turnsDraft, setTurnsDraft] = useState(String(options.reviewTimeoutTurns ?? 6))
|
|
296
|
-
const [alphaDraft, setAlphaDraft] = useState(String(options.cacheHitDiscountAlpha ?? 0.1))
|
|
297
|
-
const [highImpactDraft, setHighImpactDraft] = useState(String(options.reviewHighImpactTokens ?? 4000))
|
|
298
|
-
const reviewMode = options.reviewMode ?? false
|
|
299
|
-
const commit = (patch: PresetOptionsPatch) => {
|
|
300
|
-
settle(() => save(patch))
|
|
301
|
-
}
|
|
302
|
-
const commitTurns = (): void => {
|
|
303
|
-
const next = Number(turnsDraft)
|
|
304
|
-
if (!Number.isSafeInteger(next) || next < 1 || next === (options.reviewTimeoutTurns ?? 6)) return
|
|
305
|
-
commit({ reviewTimeoutTurns: next })
|
|
306
|
-
}
|
|
307
|
-
const commitAlpha = (): void => {
|
|
308
|
-
const next = Number(alphaDraft)
|
|
309
|
-
if (!Number.isFinite(next) || next <= 0 || next >= 1 || next === (options.cacheHitDiscountAlpha ?? 0.1)) return
|
|
310
|
-
commit({ cacheHitDiscountAlpha: next })
|
|
311
|
-
}
|
|
312
|
-
const commitHighImpact = (): void => {
|
|
313
|
-
const next = Number(highImpactDraft)
|
|
314
|
-
if (!Number.isSafeInteger(next) || next < 0 || next === (options.reviewHighImpactTokens ?? 4000)) return
|
|
315
|
-
commit({ reviewHighImpactTokens: next })
|
|
316
|
-
}
|
|
317
|
-
return (
|
|
318
|
-
<section className={css.autoCompact} aria-labelledby="context-compression-review-title">
|
|
319
|
-
<h3 id="context-compression-review-title" className={css.autoCompactTitle}>{t('review.title')}</h3>
|
|
320
|
-
<p className={css.customNote}>{t('review.description')}</p>
|
|
321
|
-
<label className={css.field}>
|
|
322
|
-
<span>{t('review.enabled')}</span>
|
|
323
|
-
<select
|
|
324
|
-
value={reviewMode ? 'on' : 'off'}
|
|
325
|
-
disabled={disabled}
|
|
326
|
-
onChange={(event) => { settle(() => save({ reviewMode: event.currentTarget.value === 'on' })) }}
|
|
327
|
-
>
|
|
328
|
-
<option value="off">{t('review.enabled.off')}</option>
|
|
329
|
-
<option value="on">{t('review.enabled.on')}</option>
|
|
330
|
-
</select>
|
|
331
|
-
</label>
|
|
332
|
-
{reviewMode ? (
|
|
333
|
-
<>
|
|
334
|
-
<label className={css.field}>
|
|
335
|
-
<span>{t('review.timeoutTurns')}</span>
|
|
336
|
-
<input
|
|
337
|
-
type="number"
|
|
338
|
-
min={1}
|
|
339
|
-
step={1}
|
|
340
|
-
value={turnsDraft}
|
|
341
|
-
disabled={disabled}
|
|
342
|
-
onChange={(event) => { setTurnsDraft(event.currentTarget.value) }}
|
|
343
|
-
onBlur={commitTurns}
|
|
344
|
-
onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }}
|
|
345
|
-
/>
|
|
346
|
-
</label>
|
|
347
|
-
<label className={css.field}>
|
|
348
|
-
<span>{t('review.alpha')}</span>
|
|
349
|
-
<input
|
|
350
|
-
type="number"
|
|
351
|
-
min={0.01}
|
|
352
|
-
max={0.99}
|
|
353
|
-
step={0.05}
|
|
354
|
-
value={alphaDraft}
|
|
355
|
-
disabled={disabled}
|
|
356
|
-
onChange={(event) => { setAlphaDraft(event.currentTarget.value) }}
|
|
357
|
-
onBlur={commitAlpha}
|
|
358
|
-
onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }}
|
|
359
|
-
/>
|
|
360
|
-
</label>
|
|
361
|
-
<label className={css.field}>
|
|
362
|
-
<span>{t('review.highImpact')}</span>
|
|
363
|
-
<input
|
|
364
|
-
type="number"
|
|
365
|
-
min={0}
|
|
366
|
-
step={500}
|
|
367
|
-
value={highImpactDraft}
|
|
368
|
-
disabled={disabled}
|
|
369
|
-
onChange={(event) => { setHighImpactDraft(event.currentTarget.value) }}
|
|
370
|
-
onBlur={commitHighImpact}
|
|
371
|
-
onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }}
|
|
372
|
-
/>
|
|
373
|
-
</label>
|
|
374
|
-
</>
|
|
375
|
-
) : null}
|
|
376
|
-
</section>
|
|
377
|
-
)
|
|
378
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* TokenPilot-inspired estimator channel card components.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from CompressionProfileSelector.tsx to reduce god-module size.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-context-compression-improved/client/EstimatorControls
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
import type { ContextCompressionLocaleKey } from './locales.ts'
|
|
11
|
+
import type { PresetOptionsPatch } from './preset-options.ts'
|
|
12
|
+
import type { PresetOptionsSettings } from '../profiles.ts'
|
|
13
|
+
import css from './CompressionProfileSelector.module.css'
|
|
14
|
+
|
|
15
|
+
interface EstimatorControlsProps {
|
|
16
|
+
options: PresetOptionsSettings
|
|
17
|
+
disabled: boolean
|
|
18
|
+
save: (options: PresetOptionsPatch) => Promise<void>
|
|
19
|
+
settle: (operation: () => Promise<void>) => void
|
|
20
|
+
t: (key: ContextCompressionLocaleKey) => string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface EstimatorInactiveNoticeProps {
|
|
24
|
+
/** Already-localized label of the profile that currently owns the selector. */
|
|
25
|
+
profile: string
|
|
26
|
+
t: (key: ContextCompressionLocaleKey) => string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The estimator card is gated on the tokenpilot-inspired profile, because
|
|
31
|
+
* `presetOptions` is merged into that profile alone. A card that merely
|
|
32
|
+
* disappears reads as a missing feature — the first real-machine report was
|
|
33
|
+
* exactly that — so keep the heading and its anchor id in place and spend them
|
|
34
|
+
* on the reason plus the profile that unlocks the card. The gate itself is
|
|
35
|
+
* unchanged: no estimator control exists outside tokenpilot-inspired.
|
|
36
|
+
*
|
|
37
|
+
* Exported from the estimator card's own module so the gated card and its
|
|
38
|
+
* explanation cannot drift apart.
|
|
39
|
+
*/
|
|
40
|
+
export function EstimatorInactiveNotice({ profile, t }: EstimatorInactiveNoticeProps) {
|
|
41
|
+
return (
|
|
42
|
+
<section className={css.autoCompact} aria-labelledby="context-compression-estimator-title">
|
|
43
|
+
<h3 id="context-compression-estimator-title" className={css.autoCompactTitle}>{t('estimator.title')}</h3>
|
|
44
|
+
<p className={css.customNote}>{t('estimator.inactive').replace('{profile}', profile)}</p>
|
|
45
|
+
</section>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface CatalogModelEntry { readonly id: string, readonly name: string }
|
|
50
|
+
interface CatalogProviderEntry { readonly id: string, readonly name: string, readonly models: readonly CatalogModelEntry[], readonly error?: string }
|
|
51
|
+
interface EstimatorCatalogBody {
|
|
52
|
+
readonly ok?: boolean
|
|
53
|
+
readonly providers?: readonly CatalogProviderEntry[]
|
|
54
|
+
readonly selection?: { readonly provider: string, readonly model: string }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ESTIMATOR_CATALOG_ROUTE = '/api/dsh-context-compression-improved/estimator-catalog'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* TokenPilot-inspired estimator channel card. Shown only while the
|
|
61
|
+
* tokenpilot-inspired profile is selected, and split by channel:
|
|
62
|
+
*
|
|
63
|
+
* - `host` reuses the providers and credentials the user already configured in
|
|
64
|
+
* DSH through the Harness `llm` service, so this card names a provider and a
|
|
65
|
+
* model and accepts NO API key — the key field belongs to the direct channel
|
|
66
|
+
* alone.
|
|
67
|
+
* - `direct` talks to a native OpenAI-compatible endpoint, which is the only
|
|
68
|
+
* channel that carries its own base URL and write-only key.
|
|
69
|
+
*
|
|
70
|
+
* The whole card is advisory: an unconfigured or failing endpoint keeps every
|
|
71
|
+
* consumer on its rule-only fallback.
|
|
72
|
+
*/
|
|
73
|
+
export function EstimatorControls({ options, disabled, save, settle, t }: EstimatorControlsProps) {
|
|
74
|
+
const [keyDraft, setKeyDraft] = useState('')
|
|
75
|
+
const [baseUrl, setBaseUrl] = useState(options.estimatorBaseUrl ?? '')
|
|
76
|
+
const [model, setModel] = useState(options.estimatorModel ?? '')
|
|
77
|
+
const [provider, setProvider] = useState(options.estimatorProvider ?? '')
|
|
78
|
+
const mode = options.estimatorMode ?? ''
|
|
79
|
+
// Host-mode dropdown source: the live provider/model-group catalog served by
|
|
80
|
+
// the Bundle entry's estimator-catalog route (dsh-perm-gate receiver
|
|
81
|
+
// pattern). The provider/model fields are always comboboxes: an empty
|
|
82
|
+
// catalog (route missing / llm service absent) simply offers no suggestions
|
|
83
|
+
// while custom typing keeps working, so nothing breaks on hosts without the
|
|
84
|
+
// webServer.
|
|
85
|
+
const [catalog, setCatalog] = useState<EstimatorCatalogBody | undefined>()
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (mode !== 'host') return
|
|
88
|
+
let alive = true
|
|
89
|
+
// The catalog route answers once the host webServer/llm services are up;
|
|
90
|
+
// poll briefly so the dropdowns fill without reopening the panel.
|
|
91
|
+
let attempts = 0
|
|
92
|
+
const load = async (): Promise<EstimatorCatalogBody | undefined> => {
|
|
93
|
+
try {
|
|
94
|
+
const response = await fetch(ESTIMATOR_CATALOG_ROUTE, { headers: { 'cache-control': 'no-cache' } })
|
|
95
|
+
return response.ok ? (await response.json()) as EstimatorCatalogBody : undefined
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const tick = (): void => {
|
|
101
|
+
attempts += 1
|
|
102
|
+
void load().then((body) => {
|
|
103
|
+
if (!alive) return
|
|
104
|
+
if (body !== undefined && (body.providers?.length ?? 0) > 0) {
|
|
105
|
+
setCatalog(body)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (attempts < 10) setTimeout(tick, 3_000)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
tick()
|
|
112
|
+
return () => { alive = false }
|
|
113
|
+
}, [mode])
|
|
114
|
+
const hostProviders = catalog?.providers ?? []
|
|
115
|
+
// Model suggestions follow the provider draft exactly like dsh-perm-gate's
|
|
116
|
+
// receiver card: an empty provider lists every group's models, a chosen
|
|
117
|
+
// provider narrows to its own. The provider error (if any) rides along in
|
|
118
|
+
// the option label so a broken group is visible without blocking typing.
|
|
119
|
+
const providerDraft = provider
|
|
120
|
+
const hostModels = hostProviders
|
|
121
|
+
.filter(entry => providerDraft === '' || entry.id === providerDraft)
|
|
122
|
+
.flatMap(entry => entry.models.map(model => ({ ...model, provider: entry.id })))
|
|
123
|
+
const hostProvider = hostProviders.find(entry => entry.id === providerDraft)
|
|
124
|
+
?? hostProviders.find(entry => entry.id === (options.estimatorProvider ?? ''))
|
|
125
|
+
const hasKey = (options.estimatorApiKey ?? '') !== ''
|
|
126
|
+
const commit = (patch: PresetOptionsPatch) => {
|
|
127
|
+
settle(() => save(patch))
|
|
128
|
+
}
|
|
129
|
+
// What the host channel would actually call right now: an explicit override
|
|
130
|
+
// wins, otherwise the session default the catalog reports. Naming the
|
|
131
|
+
// resolved route is the whole point of this channel — the user re-enters
|
|
132
|
+
// nothing the Harness already knows.
|
|
133
|
+
const overrideProvider = options.estimatorProvider ?? ''
|
|
134
|
+
const overrideModel = options.estimatorModel ?? ''
|
|
135
|
+
const effectiveProvider = overrideProvider !== '' ? overrideProvider : catalog?.selection?.provider ?? ''
|
|
136
|
+
const effectiveModel = overrideModel !== '' ? overrideModel : catalog?.selection?.model ?? ''
|
|
137
|
+
const effectiveRoute = effectiveProvider !== '' && effectiveModel !== ''
|
|
138
|
+
? `${effectiveProvider} / ${effectiveModel}`
|
|
139
|
+
: t('estimator.hostUnresolved')
|
|
140
|
+
return (
|
|
141
|
+
<section className={css.autoCompact} aria-labelledby="context-compression-estimator-title">
|
|
142
|
+
<h3 id="context-compression-estimator-title" className={css.autoCompactTitle}>{t('estimator.title')}</h3>
|
|
143
|
+
<p className={css.customNote}>{t('estimator.description')}</p>
|
|
144
|
+
<label className={css.field}>
|
|
145
|
+
<span>{t('estimator.mode')}</span>
|
|
146
|
+
<select
|
|
147
|
+
value={mode}
|
|
148
|
+
disabled={disabled}
|
|
149
|
+
onChange={(event) => { settle(() => save({ estimatorMode: event.currentTarget.value as '' | 'host' | 'direct' })) }}
|
|
150
|
+
>
|
|
151
|
+
<option value="">{t('estimator.mode.off')}</option>
|
|
152
|
+
<option value="host">{t('estimator.mode.host')}</option>
|
|
153
|
+
<option value="direct">{t('estimator.mode.direct')}</option>
|
|
154
|
+
</select>
|
|
155
|
+
</label>
|
|
156
|
+
{mode === '' ? null : mode === 'host' ? (
|
|
157
|
+
<>
|
|
158
|
+
<label className={css.field}>
|
|
159
|
+
<span>{t('estimator.provider')}</span>
|
|
160
|
+
<input
|
|
161
|
+
type="text"
|
|
162
|
+
list="estimator-provider-options"
|
|
163
|
+
value={provider}
|
|
164
|
+
disabled={disabled}
|
|
165
|
+
placeholder={t('estimator.provider.placeholder')}
|
|
166
|
+
onChange={(event) => {
|
|
167
|
+
const next = event.currentTarget.value
|
|
168
|
+
setProvider(next)
|
|
169
|
+
// Picking from the datalist fires change without blur: commit
|
|
170
|
+
// an exact catalog hit immediately; free typing waits for blur
|
|
171
|
+
// so mid-edit keystrokes never disable the panel.
|
|
172
|
+
if (next !== '' && hostProviders.some(entry => entry.id === next)) {
|
|
173
|
+
commit({ estimatorProvider: next })
|
|
174
|
+
}
|
|
175
|
+
}}
|
|
176
|
+
onBlur={() => { if (provider !== (options.estimatorProvider ?? '')) commit({ estimatorProvider: provider }) }}
|
|
177
|
+
/>
|
|
178
|
+
</label>
|
|
179
|
+
{/* Datalists ride outside the labels on purpose: option text inside a
|
|
180
|
+
label would leak into its accessible name. */}
|
|
181
|
+
<datalist id="estimator-provider-options">
|
|
182
|
+
{hostProviders.map(entry => (
|
|
183
|
+
<option key={entry.id} value={entry.id}>
|
|
184
|
+
{entry.name === '' ? entry.id : entry.name}{entry.error === undefined ? '' : ` (${entry.error})`}
|
|
185
|
+
</option>
|
|
186
|
+
))}
|
|
187
|
+
</datalist>
|
|
188
|
+
<label className={css.field}>
|
|
189
|
+
<span>{t('estimator.model')}</span>
|
|
190
|
+
<input
|
|
191
|
+
type="text"
|
|
192
|
+
list="estimator-model-options"
|
|
193
|
+
value={model}
|
|
194
|
+
disabled={disabled}
|
|
195
|
+
placeholder={t('estimator.model.placeholder')}
|
|
196
|
+
onChange={(event) => {
|
|
197
|
+
const next = event.currentTarget.value
|
|
198
|
+
setModel(next)
|
|
199
|
+
if (next !== '' && hostModels.some(entry => entry.id === next)) {
|
|
200
|
+
commit({ estimatorModel: next })
|
|
201
|
+
}
|
|
202
|
+
}}
|
|
203
|
+
onBlur={() => { if (model !== (options.estimatorModel ?? '')) commit({ estimatorModel: model }) }}
|
|
204
|
+
/>
|
|
205
|
+
</label>
|
|
206
|
+
<datalist id="estimator-model-options">
|
|
207
|
+
{hostModels.map(entry => (
|
|
208
|
+
<option key={`${entry.provider}\0${entry.id}`} value={entry.id}>{entry.name}</option>
|
|
209
|
+
))}
|
|
210
|
+
</datalist>
|
|
211
|
+
{hostProvider?.error === undefined ? null : (
|
|
212
|
+
<p className={css.customNote}>{String(hostProvider.error)}</p>
|
|
213
|
+
)}
|
|
214
|
+
<p className={css.customNote}>{t('estimator.hostReuse')}</p>
|
|
215
|
+
<p className={css.customNote}>{t('estimator.hostRoute').replace('{route}', effectiveRoute)}</p>
|
|
216
|
+
</>
|
|
217
|
+
) : (
|
|
218
|
+
<>
|
|
219
|
+
<label className={css.field}>
|
|
220
|
+
<span>{t('estimator.baseUrl')}</span>
|
|
221
|
+
<input
|
|
222
|
+
type="text"
|
|
223
|
+
value={baseUrl}
|
|
224
|
+
disabled={disabled}
|
|
225
|
+
placeholder="https://127.0.0.1:8000/v1"
|
|
226
|
+
onChange={(event) => { setBaseUrl(event.currentTarget.value) }}
|
|
227
|
+
onBlur={() => { if (baseUrl !== (options.estimatorBaseUrl ?? '')) commit({ estimatorBaseUrl: baseUrl }) }}
|
|
228
|
+
/>
|
|
229
|
+
</label>
|
|
230
|
+
<label className={css.field}>
|
|
231
|
+
<span>{t('estimator.model')}</span>
|
|
232
|
+
<input
|
|
233
|
+
type="text"
|
|
234
|
+
value={model}
|
|
235
|
+
disabled={disabled}
|
|
236
|
+
onChange={(event) => { setModel(event.currentTarget.value) }}
|
|
237
|
+
onBlur={() => { if (model !== (options.estimatorModel ?? '')) commit({ estimatorModel: model }) }}
|
|
238
|
+
/>
|
|
239
|
+
</label>
|
|
240
|
+
<label className={css.field}>
|
|
241
|
+
<span>{t('estimator.apiKey')}</span>
|
|
242
|
+
<div style={{ display: 'flex', gap: '6px' }}>
|
|
243
|
+
<input
|
|
244
|
+
type="password"
|
|
245
|
+
autoComplete="off"
|
|
246
|
+
spellCheck={false}
|
|
247
|
+
value={keyDraft}
|
|
248
|
+
disabled={disabled}
|
|
249
|
+
placeholder={hasKey ? t('estimator.apiKey.set') : t('estimator.apiKey.placeholder')}
|
|
250
|
+
onChange={(event) => { setKeyDraft(event.currentTarget.value) }}
|
|
251
|
+
onBlur={() => {
|
|
252
|
+
const next = keyDraft.trim()
|
|
253
|
+
if (next === '') return
|
|
254
|
+
settle(() => save({ estimatorApiKey: next }))
|
|
255
|
+
setKeyDraft('')
|
|
256
|
+
}}
|
|
257
|
+
onKeyDown={(event) => {
|
|
258
|
+
if (event.key === 'Enter') event.currentTarget.blur()
|
|
259
|
+
}}
|
|
260
|
+
/>
|
|
261
|
+
{hasKey ? (
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
disabled={disabled}
|
|
265
|
+
onClick={() => { settle(() => save({ estimatorApiKey: undefined })) }}
|
|
266
|
+
>
|
|
267
|
+
{t('estimator.apiKey.clear')}
|
|
268
|
+
</button>
|
|
269
|
+
) : null}
|
|
270
|
+
</div>
|
|
271
|
+
{hasKey ? <span className={css.customNote}>{t('estimator.apiKey.overwrite')}</span> : null}
|
|
272
|
+
</label>
|
|
273
|
+
</>
|
|
274
|
+
)}
|
|
275
|
+
</section>
|
|
276
|
+
)
|
|
277
|
+
}
|