dsh-hypatia-auto-memory 1.1.0 → 2.0.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/README.md +54 -15
- package/README.zh-CN.md +40 -13
- package/lib/client.js +289 -10
- package/lib/client.js.map +1 -1
- package/package.json +6 -1
- package/src/cascade.js +39 -19
- package/src/client/SettingsCard.module.css +83 -0
- package/src/client/SettingsCard.tsx +160 -15
- package/src/client/consolidation-models.ts +151 -1
- package/src/client/locales.ts +26 -4
- package/src/config.js +20 -3
- package/src/consolidator.js +130 -86
- package/src/index.js +14 -6
- package/src/model-log.js +64 -15
- package/src/reasoning.js +209 -0
- package/src/recall.js +14 -3
- package/src/writer.js +7 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hypatia-auto-memory",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Event-driven Hypatia memory for DSH: automatic conversation logging via session events, queued background consolidation (span summaries + work-unit extraction) with a dedicated model route, and recall injection — zero per-call model work in the main session.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -62,6 +62,8 @@
|
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
65
|
+
"@deepseek-ai/cordis-plugin-loader": "^1.0.5",
|
|
66
|
+
"@deepseek-ai/dsh-agent": "^0.1.7-rc.1",
|
|
65
67
|
"@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.1",
|
|
66
68
|
"@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.1",
|
|
67
69
|
"@deepseek-ai/dsh-client-locale": "^0.1.7-rc.1",
|
|
@@ -73,7 +75,10 @@
|
|
|
73
75
|
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.7-rc.1",
|
|
74
76
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.1",
|
|
75
77
|
"@deepseek-ai/dsh-llm": "^0.1.7-rc.1",
|
|
78
|
+
"@deepseek-ai/dsh-session": "^0.1.7-rc.1",
|
|
76
79
|
"@deepseek-ai/dsh-storage-domain": "^0.1.7-rc.1",
|
|
80
|
+
"@deepseek-ai/dsh-tools": "^0.1.7-rc.1",
|
|
81
|
+
"@deepseek-ai/dsh-user-approval": "^0.1.7-rc.1",
|
|
77
82
|
"@types/react": "^18.2.0",
|
|
78
83
|
"lightningcss": "^1.32.0",
|
|
79
84
|
"react": "^18.2.0",
|
package/src/cascade.js
CHANGED
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
|
|
22
22
|
import { createHash } from 'node:crypto'
|
|
23
23
|
|
|
24
|
-
import { PLUGIN_NAME,
|
|
24
|
+
import { PLUGIN_NAME, budgetForEffort } from './consolidator.js'
|
|
25
25
|
import { NULL_MODEL_LOG } from './model-log.js'
|
|
26
|
+
import { createReasoningResolver } from './reasoning.js'
|
|
26
27
|
|
|
27
28
|
/** Tag marking the tier an entry belongs to. */
|
|
28
29
|
export function levelTag(level) {
|
|
@@ -78,31 +79,39 @@ function archiveInstruction(level, count) {
|
|
|
78
79
|
* @param {{
|
|
79
80
|
* cli: ReturnType<import('./hypatia-client.js').createHypatiaClient>,
|
|
80
81
|
* llm: any,
|
|
81
|
-
* selectRoute: (routes: any) => any,
|
|
82
|
+
* selectRoute: (routes: any, attempt?: number) => any,
|
|
82
83
|
* getConfig: () => any,
|
|
83
84
|
* status: import('./status.js').StatusLog,
|
|
84
85
|
* modelLog?: ReturnType<import('./model-log.js').createModelLog>,
|
|
85
|
-
*
|
|
86
|
+
* reasoning?: ReturnType<import('./reasoning.js').createReasoningResolver>,
|
|
87
|
+
* }} deps - `reasoning` is shared with the consolidator (index.js), so one
|
|
88
|
+
* capability cache and one set of warnings cover both executors.
|
|
86
89
|
*/
|
|
87
|
-
export function createCascade({ cli, llm, selectRoute, getConfig, status, modelLog = NULL_MODEL_LOG }) {
|
|
90
|
+
export function createCascade({ cli, llm, selectRoute, getConfig, status, modelLog = NULL_MODEL_LOG, reasoning }) {
|
|
91
|
+
const effortFor = reasoning ?? createReasoningResolver({ llm, status })
|
|
92
|
+
|
|
88
93
|
/**
|
|
89
94
|
* Ask the model to archive one batch.
|
|
95
|
+
*
|
|
96
|
+
* @param {string | undefined} effort - the resolved thinking level for this
|
|
97
|
+
* run, decided once in `execute` so every tier is archived the same way.
|
|
90
98
|
* @returns {Promise<{title: string, summary: string} | undefined>}
|
|
91
99
|
*/
|
|
92
|
-
async function archiveBatch(level, rows, route, timeoutMs) {
|
|
100
|
+
async function archiveBatch(level, rows, route, timeoutMs, effort) {
|
|
93
101
|
const body = rows.map((row, i) => {
|
|
94
102
|
const data = typeof row?.content?.data === 'string' ? row.content.data : ''
|
|
95
103
|
return `### ${i + 1}. ${row.name}\n${data}`
|
|
96
104
|
}).join('\n\n')
|
|
97
105
|
|
|
98
|
-
const attempt = modelLog.begin('memory-cascade', route)
|
|
106
|
+
const attempt = modelLog.begin('memory-cascade', route, effort)
|
|
99
107
|
const controller = new AbortController()
|
|
100
108
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
101
109
|
let outcome = 'incomplete'
|
|
102
110
|
let detail = ''
|
|
111
|
+
let assembler
|
|
103
112
|
try {
|
|
104
113
|
const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
|
|
105
|
-
|
|
114
|
+
assembler = new BlockAssembler()
|
|
106
115
|
for await (const chunk of llm.stream({
|
|
107
116
|
provider: route.provider,
|
|
108
117
|
model: route.model,
|
|
@@ -111,14 +120,18 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status, modelL
|
|
|
111
120
|
content: [{ type: 'text', text: `${archiveInstruction(level, rows.length)}\n\n${body}` }],
|
|
112
121
|
source: { kind: 'plugin', plugin: PLUGIN_NAME },
|
|
113
122
|
})],
|
|
114
|
-
// Thinking stays ON here, unlike the extraction call: an
|
|
115
|
-
// semantic compression of sixteen summaries, not a
|
|
116
|
-
// into a fixed shape, and reasoning plausibly earns
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
123
|
+
// Thinking stays ON by default here, unlike the extraction call: an
|
|
124
|
+
// archive is a semantic compression of sixteen summaries, not a
|
|
125
|
+
// mechanical transform into a fixed shape, and reasoning plausibly earns
|
|
126
|
+
// its cost. So the purpose policy asks for nothing and leaves the
|
|
127
|
+
// adapter's own default in place — unless the route's configuration
|
|
128
|
+
// named a level, which `effortFor` resolved above. Either way the budget
|
|
129
|
+
// carries the thinking allowance unless the level is `off`. (This call
|
|
130
|
+
// fails SOFT — a truncated archive just returns undefined and the tier
|
|
131
|
+
// is retried by the next batch — which is why the permanent-failure path
|
|
132
|
+
// never applied to it.)
|
|
133
|
+
maxTokens: budgetForEffort(getConfig().consolidation.maxOutputTokens, effort),
|
|
134
|
+
...effort === undefined ? {} : { reasoningEffort: effort },
|
|
122
135
|
purpose: 'memory-cascade',
|
|
123
136
|
signal: controller.signal,
|
|
124
137
|
})) {
|
|
@@ -155,7 +168,9 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status, modelL
|
|
|
155
168
|
throw error
|
|
156
169
|
} finally {
|
|
157
170
|
clearTimeout(timer)
|
|
158
|
-
|
|
171
|
+
// Read even on a throw: a truncated archive is the case whose token split
|
|
172
|
+
// is worth having.
|
|
173
|
+
void attempt.finish(outcome, detail, assembler?.usage)
|
|
159
174
|
}
|
|
160
175
|
}
|
|
161
176
|
|
|
@@ -163,15 +178,20 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status, modelL
|
|
|
163
178
|
* Run the cascade for one project, from tier 2 upward, until a tier has fewer
|
|
164
179
|
* than `batchSize` unarchived entries.
|
|
165
180
|
*
|
|
166
|
-
* @param {{project: string}} task
|
|
181
|
+
* @param {{project: string, attempts?: number}} task
|
|
167
182
|
*/
|
|
168
183
|
async function execute(task) {
|
|
169
184
|
const config = getConfig()
|
|
170
185
|
const consolidation = config.consolidation
|
|
171
186
|
if (consolidation.cascade?.enabled === false) return
|
|
172
187
|
const batchSize = consolidation.cascade?.batchSize ?? 16
|
|
173
|
-
|
|
188
|
+
// Same priority order as consolidation: the first attempt archives on the
|
|
189
|
+
// preferred route, a retry degrades. The run picks once and archives every
|
|
190
|
+
// tier with that one choice — and resolves its thinking level once, the same
|
|
191
|
+
// way, so a multi-tier climb cannot change personality halfway.
|
|
192
|
+
const route = selectRoute(consolidation.models, task.attempts)
|
|
174
193
|
if (route === undefined) return
|
|
194
|
+
const effort = await effortFor.effort(route, 'memory-cascade')
|
|
175
195
|
|
|
176
196
|
// A ceiling on tiers, not an expected depth: reaching tier 8 at 16:1 would
|
|
177
197
|
// mean ~4 billion source entries. It exists so a malformed shelf cannot spin.
|
|
@@ -192,7 +212,7 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status, modelL
|
|
|
192
212
|
|
|
193
213
|
const existing = await cli.knowledgeGet(name)
|
|
194
214
|
if (existing.found === false) {
|
|
195
|
-
const archived = await archiveBatch(level, rows, route, consolidation.timeoutMs)
|
|
215
|
+
const archived = await archiveBatch(level, rows, route, consolidation.timeoutMs, effort)
|
|
196
216
|
if (archived === undefined) {
|
|
197
217
|
status.warn(`cascade archive produced nothing usable at tier ${level}`)
|
|
198
218
|
return
|
|
@@ -334,6 +334,89 @@
|
|
|
334
334
|
background: var(--dsw-alias-bg-layer-4);
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
+
/* Flex, not a grid: the availability badge is absent on most rows, and a fixed
|
|
338
|
+
template would shift the action buttons by one column when it disappears. */
|
|
339
|
+
.selectedRow {
|
|
340
|
+
display: flex;
|
|
341
|
+
align-items: center;
|
|
342
|
+
gap: 8px;
|
|
343
|
+
min-width: 0;
|
|
344
|
+
border-radius: 6px;
|
|
345
|
+
padding: 6px;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.selectedRow:hover {
|
|
349
|
+
background: var(--dsw-alias-bg-layer-4);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.order {
|
|
353
|
+
min-width: 16px;
|
|
354
|
+
color: var(--dsw-alias-label-tertiary);
|
|
355
|
+
font-size: 11px;
|
|
356
|
+
font-variant-numeric: tabular-nums;
|
|
357
|
+
text-align: right;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.selectedText {
|
|
361
|
+
flex: 1 1 auto;
|
|
362
|
+
min-width: 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* Compact on purpose: one per priority row, and the row already carries an
|
|
366
|
+
ordinal, a name, a route, an availability badge and three buttons. */
|
|
367
|
+
.effort {
|
|
368
|
+
flex: 0 0 auto;
|
|
369
|
+
max-width: 132px;
|
|
370
|
+
padding: 2px 4px;
|
|
371
|
+
border: 0.5px solid var(--dsw-alias-border-l4);
|
|
372
|
+
border-radius: 5px;
|
|
373
|
+
background: transparent;
|
|
374
|
+
color: var(--dsw-alias-label-secondary);
|
|
375
|
+
cursor: pointer;
|
|
376
|
+
font: inherit;
|
|
377
|
+
font-size: 12px;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.effort:disabled {
|
|
381
|
+
cursor: default;
|
|
382
|
+
opacity: 0.5;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.effort:focus-visible {
|
|
386
|
+
border-color: var(--dsw-alias-brand-primary);
|
|
387
|
+
outline: none;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.rowActions {
|
|
391
|
+
display: flex;
|
|
392
|
+
align-items: center;
|
|
393
|
+
gap: 2px;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.move {
|
|
397
|
+
width: 22px;
|
|
398
|
+
height: 22px;
|
|
399
|
+
padding: 0;
|
|
400
|
+
border: 0.5px solid var(--dsw-alias-border-l4);
|
|
401
|
+
border-radius: 5px;
|
|
402
|
+
background: transparent;
|
|
403
|
+
color: var(--dsw-alias-label-secondary);
|
|
404
|
+
cursor: pointer;
|
|
405
|
+
font: inherit;
|
|
406
|
+
font-size: 12px;
|
|
407
|
+
line-height: 1;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
.move:hover:not(:disabled) {
|
|
411
|
+
background: var(--dsw-alias-bg-layer-4);
|
|
412
|
+
color: var(--dsw-alias-label-primary);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.move:disabled {
|
|
416
|
+
cursor: default;
|
|
417
|
+
opacity: 0.4;
|
|
418
|
+
}
|
|
419
|
+
|
|
337
420
|
.modelName,
|
|
338
421
|
.route {
|
|
339
422
|
display: block;
|
|
@@ -14,9 +14,13 @@ import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-
|
|
|
14
14
|
import {
|
|
15
15
|
consolidationModelCandidates,
|
|
16
16
|
consolidationModelKey,
|
|
17
|
+
moveConsolidationModel,
|
|
18
|
+
orderedConsolidationModels,
|
|
19
|
+
reasoningEffortChoices,
|
|
17
20
|
type ConsolidationModelCandidate,
|
|
18
21
|
type ConsolidationModelRoute,
|
|
19
22
|
type LoadConsolidationModelCatalog,
|
|
23
|
+
type ReasoningEffortChoice,
|
|
20
24
|
} from './consolidation-models'
|
|
21
25
|
import { DEFAULT_SHELF, shelfChoices, type LoadShelfInventory, type ShelfInfo } from './shelves'
|
|
22
26
|
import css from './SettingsCard.module.css'
|
|
@@ -180,6 +184,13 @@ export function SettingsCard({ scope, loadModelCatalog, loadShelfInventory, t }:
|
|
|
180
184
|
)
|
|
181
185
|
}, [catalogGroups, consolidation.models])
|
|
182
186
|
|
|
187
|
+
// The draft's own order, NOT the catalog's: this list is what the Host reads
|
|
188
|
+
// as the priority order, so it has to show the stored array verbatim.
|
|
189
|
+
const selectedModels = useMemo(
|
|
190
|
+
() => orderedConsolidationModels(consolidation.models ?? [], candidates),
|
|
191
|
+
[consolidation.models, candidates],
|
|
192
|
+
)
|
|
193
|
+
|
|
183
194
|
const toggleModel = useCallback((candidate: ConsolidationModelCandidate) => {
|
|
184
195
|
if (disabled || saving) return
|
|
185
196
|
setDraft((current) => {
|
|
@@ -197,6 +208,75 @@ export function SettingsCard({ scope, loadModelCatalog, loadShelfInventory, t }:
|
|
|
197
208
|
})
|
|
198
209
|
}, [disabled, saving])
|
|
199
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Reorder one entry of the priority list. A no-op move leaves the draft
|
|
213
|
+
* untouched (the mover returns the original array), so the "unsaved" badge
|
|
214
|
+
* does not light up for a click that changed nothing.
|
|
215
|
+
*/
|
|
216
|
+
const moveModel = useCallback((from: number, to: number) => {
|
|
217
|
+
if (disabled || saving) return
|
|
218
|
+
setDraft((current) => {
|
|
219
|
+
const currentConsolidation = current.consolidation ?? DEFAULT_CONSOLIDATION
|
|
220
|
+
const currentModels = currentConsolidation.models ?? []
|
|
221
|
+
const models = moveConsolidationModel(currentModels, from, to)
|
|
222
|
+
if (models === currentModels) return current
|
|
223
|
+
return {
|
|
224
|
+
...current,
|
|
225
|
+
consolidation: { ...currentConsolidation, models },
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
}, [disabled, saving])
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Set or clear one route's thinking level. Clearing REMOVES the key rather
|
|
232
|
+
* than storing `undefined`, so the saved patch carries only what the user
|
|
233
|
+
* chose and nothing reading the profile can confuse "not configured" with
|
|
234
|
+
* "configured as nothing".
|
|
235
|
+
*/
|
|
236
|
+
const setModelEffort = useCallback((index: number, effort: string | undefined) => {
|
|
237
|
+
if (disabled || saving) return
|
|
238
|
+
setDraft((current) => {
|
|
239
|
+
const currentConsolidation = current.consolidation ?? DEFAULT_CONSOLIDATION
|
|
240
|
+
const currentModels = currentConsolidation.models ?? []
|
|
241
|
+
if (index < 0 || index >= currentModels.length) return current
|
|
242
|
+
const models = currentModels.map((route, at) => {
|
|
243
|
+
if (at !== index) return route
|
|
244
|
+
if (effort === undefined) {
|
|
245
|
+
const cleared = { ...route }
|
|
246
|
+
delete cleared.reasoningEffort
|
|
247
|
+
return cleared
|
|
248
|
+
}
|
|
249
|
+
return { ...route, reasoningEffort: effort }
|
|
250
|
+
})
|
|
251
|
+
return {
|
|
252
|
+
...current,
|
|
253
|
+
consolidation: { ...currentConsolidation, models },
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
}, [disabled, saving])
|
|
257
|
+
|
|
258
|
+
/** Display label for one thinking-level option, in the card's own words. */
|
|
259
|
+
const effortChoiceLabel = useCallback((choice: ReasoningEffortChoice): string => {
|
|
260
|
+
if (choice.effort === undefined) return t('reasoningFollowPurpose')
|
|
261
|
+
const name = choice.name ?? choice.effort
|
|
262
|
+
// A value the route no longer declares is shown, not hidden: rewriting the
|
|
263
|
+
// user's configuration behind their back is worse than showing a stale one.
|
|
264
|
+
return choice.stale === true ? `${name} · ${t('reasoningStale')}` : name
|
|
265
|
+
}, [t])
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* What the adapter does when no effort is sent. Only a hint: this plugin
|
|
269
|
+
* cannot pin it, because omitting the field is what lets its own per-purpose
|
|
270
|
+
* policy apply.
|
|
271
|
+
*/
|
|
272
|
+
const routeDefaultLabel = useCallback((candidate: ConsolidationModelCandidate): string | undefined => {
|
|
273
|
+
const reasoning = candidate.reasoning
|
|
274
|
+
const fallback = reasoning?.defaultEffort
|
|
275
|
+
if (reasoning === undefined || fallback === undefined) return undefined
|
|
276
|
+
const named = reasoning.efforts.find(effort => effort.id === fallback)
|
|
277
|
+
return t('reasoningDefault', { effort: named?.name ?? fallback })
|
|
278
|
+
}, [t])
|
|
279
|
+
|
|
200
280
|
const discard = useCallback(() => {
|
|
201
281
|
setDraft(resolved)
|
|
202
282
|
setError(null)
|
|
@@ -411,22 +491,87 @@ export function SettingsCard({ scope, loadModelCatalog, loadShelfInventory, t }:
|
|
|
411
491
|
) : null}
|
|
412
492
|
{catalogPartial ? <p className={css.notice}>{t('modelCatalogPartial')}</p> : null}
|
|
413
493
|
{candidates.length > 0 ? (
|
|
414
|
-
|
|
415
|
-
<
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
<
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
494
|
+
<>
|
|
495
|
+
<fieldset className={css.models}>
|
|
496
|
+
<legend>{t('selectedModels')}</legend>
|
|
497
|
+
{selectedModels.length === 0 ? (
|
|
498
|
+
<p className={css.notice}>{t('selectedModelsEmpty')}</p>
|
|
499
|
+
) : selectedModels.map((candidate, index) => (
|
|
500
|
+
<div key={candidate.key} className={css.selectedRow}>
|
|
501
|
+
<span className={css.order}>{index + 1}</span>
|
|
502
|
+
<span className={css.selectedText}>
|
|
503
|
+
<span className={css.modelName}>{candidate.modelName}</span>
|
|
504
|
+
<span className={css.route}>{`${candidate.providerName} · ${candidate.provider}/${candidate.model}`}</span>
|
|
505
|
+
</span>
|
|
506
|
+
<select
|
|
507
|
+
className={css.effort}
|
|
508
|
+
aria-label={`${t('reasoningEffort')}: ${candidate.modelName}`}
|
|
509
|
+
title={candidate.reasoning === undefined
|
|
510
|
+
? t('reasoningUnavailable')
|
|
511
|
+
: routeDefaultLabel(candidate) ?? t('reasoningFollowPurpose')}
|
|
512
|
+
value={candidate.reasoningEffort ?? ''}
|
|
513
|
+
disabled={disabled || saving || candidate.reasoning === undefined}
|
|
514
|
+
onChange={(event) => setModelEffort(index, event.target.value === '' ? undefined : event.target.value)}
|
|
515
|
+
>
|
|
516
|
+
{reasoningEffortChoices(candidate.reasoning, candidate.reasoningEffort).map(choice => (
|
|
517
|
+
<option key={choice.key} value={choice.effort ?? ''}>{effortChoiceLabel(choice)}</option>
|
|
518
|
+
))}
|
|
519
|
+
</select>
|
|
520
|
+
{!candidate.available ? <span className={css.unavailable}>{t('modelUnavailable')}</span> : null}
|
|
521
|
+
<span className={css.rowActions}>
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
className={css.move}
|
|
525
|
+
title={t('moveUp')}
|
|
526
|
+
aria-label={`${t('moveUp')}: ${candidate.modelName}`}
|
|
527
|
+
disabled={disabled || saving || index === 0}
|
|
528
|
+
onClick={() => moveModel(index, index - 1)}
|
|
529
|
+
>
|
|
530
|
+
↑
|
|
531
|
+
</button>
|
|
532
|
+
<button
|
|
533
|
+
type="button"
|
|
534
|
+
className={css.move}
|
|
535
|
+
title={t('moveDown')}
|
|
536
|
+
aria-label={`${t('moveDown')}: ${candidate.modelName}`}
|
|
537
|
+
disabled={disabled || saving || index === selectedModels.length - 1}
|
|
538
|
+
onClick={() => moveModel(index, index + 1)}
|
|
539
|
+
>
|
|
540
|
+
↓
|
|
541
|
+
</button>
|
|
542
|
+
<button
|
|
543
|
+
type="button"
|
|
544
|
+
className={css.move}
|
|
545
|
+
title={t('remove')}
|
|
546
|
+
aria-label={`${t('remove')}: ${candidate.modelName}`}
|
|
547
|
+
disabled={disabled || saving}
|
|
548
|
+
onClick={() => toggleModel(candidate)}
|
|
549
|
+
>
|
|
550
|
+
×
|
|
551
|
+
</button>
|
|
552
|
+
</span>
|
|
553
|
+
</div>
|
|
554
|
+
))}
|
|
555
|
+
</fieldset>
|
|
556
|
+
|
|
557
|
+
<fieldset className={css.models}>
|
|
558
|
+
<legend>{t('availableModels')}</legend>
|
|
559
|
+
{[...availableGroups].map(([provider, group]) => (
|
|
560
|
+
<div key={provider} className={css.modelGroup}>
|
|
561
|
+
<div className={css.providerName}>{group.providerName}</div>
|
|
562
|
+
{group.candidates.map(renderCandidate)}
|
|
563
|
+
</div>
|
|
564
|
+
))}
|
|
565
|
+
{unavailable.length > 0 ? (
|
|
566
|
+
<div className={css.modelGroup}>
|
|
567
|
+
<div className={css.providerName}>{t('unavailableModels')}</div>
|
|
568
|
+
{unavailable.map(renderCandidate)}
|
|
569
|
+
</div>
|
|
570
|
+
) : null}
|
|
571
|
+
</fieldset>
|
|
572
|
+
</>
|
|
429
573
|
) : catalogStatus === 'ready' ? <p className={css.notice}>{t('modelCatalogEmpty')}</p> : null}
|
|
574
|
+
<p className={css.hint}>{t('reasoningBudgetHint')}</p>
|
|
430
575
|
</div>
|
|
431
576
|
|
|
432
577
|
<div className={css.pairedFields}>
|
|
@@ -1,11 +1,39 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Model-directory projection shared by the consolidation settings card.
|
|
3
|
+
*
|
|
4
|
+
* Two projections of the same data, deliberately kept apart: the CATALOG one
|
|
5
|
+
* (every advertised route, in directory order, with a `selected` flag) is the
|
|
6
|
+
* pool a user picks from, and the ORDERED one (the stored array as-is) is the
|
|
7
|
+
* priority list consolidation actually reads. Directory order must never leak
|
|
8
|
+
* into the latter — it did, and the card then showed a rotation order the
|
|
9
|
+
* configuration did not have.
|
|
10
|
+
*/
|
|
2
11
|
|
|
3
12
|
import type { ModelProviderGroup } from '@deepseek-ai/dsh-api-remotes/client'
|
|
4
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Adapter-owned reasoning metadata for one catalog model. Derived from the
|
|
16
|
+
* catalog type rather than re-declared, so a change upstream cannot drift from
|
|
17
|
+
* what the card renders: `efforts` is the vocabulary the adapter accepts, in its
|
|
18
|
+
* own preferred display order, and `defaultEffort` (optional) is what the
|
|
19
|
+
* adapter materializes when a caller omits an effort.
|
|
20
|
+
*/
|
|
21
|
+
export type ConsolidationModelReasoning = NonNullable<ModelProviderGroup['models'][number]['reasoning']>
|
|
22
|
+
|
|
5
23
|
/** One exact provider/model route used for a consolidation attempt. */
|
|
6
24
|
export interface ConsolidationModelRoute {
|
|
7
25
|
provider: string
|
|
8
26
|
model: string
|
|
27
|
+
/**
|
|
28
|
+
* Optional thinking level for this route. Absent means "follow the purpose
|
|
29
|
+
* policy", which is what every call did before the field existed: extraction
|
|
30
|
+
* and adjudication ask for no reasoning where the route allows it, and the
|
|
31
|
+
* archive call leaves the adapter's own default in place.
|
|
32
|
+
*
|
|
33
|
+
* The vocabulary is adapter-owned and opaque, so the card only ever offers
|
|
34
|
+
* what this model's catalog entry declares.
|
|
35
|
+
*/
|
|
36
|
+
reasoningEffort?: string
|
|
9
37
|
}
|
|
10
38
|
|
|
11
39
|
/** One catalog route, including stored routes no longer advertised by an adapter. */
|
|
@@ -15,6 +43,20 @@ export interface ConsolidationModelCandidate extends ConsolidationModelRoute {
|
|
|
15
43
|
modelName: string
|
|
16
44
|
available: boolean
|
|
17
45
|
selected: boolean
|
|
46
|
+
/** Absent for a route the catalog no longer advertises. */
|
|
47
|
+
reasoning?: ConsolidationModelReasoning
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One row of the thinking-level picker. */
|
|
51
|
+
export interface ReasoningEffortChoice {
|
|
52
|
+
/** Stable key for React and for tests. */
|
|
53
|
+
key: string
|
|
54
|
+
/** Value to store; `undefined` means "follow the purpose policy". */
|
|
55
|
+
effort: string | undefined
|
|
56
|
+
/** Adapter-provided display name; the card supplies its own label when absent. */
|
|
57
|
+
name?: string
|
|
58
|
+
/** The stored value is no longer declared by this route. */
|
|
59
|
+
stale?: boolean
|
|
18
60
|
}
|
|
19
61
|
|
|
20
62
|
/** One catalog response retained by the settings card. */
|
|
@@ -31,6 +73,110 @@ export function consolidationModelKey(route: ConsolidationModelRoute): string {
|
|
|
31
73
|
return `${route.provider}\0${route.model}`
|
|
32
74
|
}
|
|
33
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Move one entry of the priority list, returning a new array.
|
|
78
|
+
*
|
|
79
|
+
* The stored `consolidation.models` array IS the priority order, so this is the
|
|
80
|
+
* only thing a reorder has to do: reorder the array. `to` is clamped instead of
|
|
81
|
+
* rejected, so a click that cannot move anything (the first row's "up") is a
|
|
82
|
+
* no-op rather than an error. A no-op returns the ORIGINAL reference, which lets
|
|
83
|
+
* a caller use identity to skip the state update.
|
|
84
|
+
*
|
|
85
|
+
* @param models - Priority-ordered routes; not mutated.
|
|
86
|
+
* @param from - Index to move.
|
|
87
|
+
* @param to - Target index, clamped into `models`.
|
|
88
|
+
*/
|
|
89
|
+
export function moveConsolidationModel(
|
|
90
|
+
models: ConsolidationModelRoute[],
|
|
91
|
+
from: number,
|
|
92
|
+
to: number,
|
|
93
|
+
): ConsolidationModelRoute[] {
|
|
94
|
+
if (!Number.isInteger(from) || from < 0 || from >= models.length) return models
|
|
95
|
+
if (!Number.isFinite(to)) return models
|
|
96
|
+
const target = Math.min(Math.max(0, Math.trunc(to)), models.length - 1)
|
|
97
|
+
if (target === from) return models
|
|
98
|
+
const next = [...models]
|
|
99
|
+
const [moved] = next.splice(from, 1)
|
|
100
|
+
next.splice(target, 0, moved)
|
|
101
|
+
return next
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The stored routes in their configured (priority) order, each resolved to its
|
|
106
|
+
* catalog entry for a display name, an availability flag and the reasoning
|
|
107
|
+
* levels the route declares.
|
|
108
|
+
*
|
|
109
|
+
* `consolidationModelCandidates` already guarantees every stored route appears —
|
|
110
|
+
* taken from the catalog, or appended as unavailable — so the candidate list is
|
|
111
|
+
* a lookup table for this. A route that somehow is not in it is still shown,
|
|
112
|
+
* named by its raw ids and marked unavailable, because dropping it from the
|
|
113
|
+
* priority list would hide a configured route from the only UI that can remove
|
|
114
|
+
* it.
|
|
115
|
+
*
|
|
116
|
+
* `reasoningEffort` is taken from the STORED route, never from the catalog: the
|
|
117
|
+
* catalog describes what a model can do, the stored route is what the user chose.
|
|
118
|
+
*/
|
|
119
|
+
export function orderedConsolidationModels(
|
|
120
|
+
stored: readonly ConsolidationModelRoute[],
|
|
121
|
+
candidates: readonly ConsolidationModelCandidate[],
|
|
122
|
+
): ConsolidationModelCandidate[] {
|
|
123
|
+
const byKey = new Map(candidates.map(candidate => [candidate.key, candidate]))
|
|
124
|
+
return stored.map((route) => {
|
|
125
|
+
const key = consolidationModelKey(route)
|
|
126
|
+
const base = byKey.get(key) ?? {
|
|
127
|
+
...route,
|
|
128
|
+
key,
|
|
129
|
+
providerName: route.provider,
|
|
130
|
+
modelName: route.model,
|
|
131
|
+
available: false,
|
|
132
|
+
selected: true,
|
|
133
|
+
}
|
|
134
|
+
return { ...base, reasoningEffort: route.reasoningEffort }
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The thinking-level options for one route, in the order they are offered.
|
|
140
|
+
*
|
|
141
|
+
* Two rules, both about not inventing a vocabulary this plugin does not own:
|
|
142
|
+
*
|
|
143
|
+
* - The list comes from the route's own declared `efforts`, in the adapter's
|
|
144
|
+
* preferred order, labelled with the adapter's own names. Nothing is
|
|
145
|
+
* hard-coded — the ids are opaque, and a level one adapter accepts is not one
|
|
146
|
+
* another does.
|
|
147
|
+
* - "Follow the purpose policy" (`undefined`) is always first and is what an
|
|
148
|
+
* absent field stores. It is NOT the same as the adapter's `defaultEffort`:
|
|
149
|
+
* omitting the field lets the plugin's own per-purpose policy apply, which for
|
|
150
|
+
* extraction and adjudication means asking for no reasoning at all.
|
|
151
|
+
*
|
|
152
|
+
* A stored value the route no longer declares is appended and flagged rather
|
|
153
|
+
* than dropped, so the card can show what is actually configured instead of
|
|
154
|
+
* silently rewriting it — and so a route whose catalog entry disappeared still
|
|
155
|
+
* displays its setting.
|
|
156
|
+
*
|
|
157
|
+
* @param reasoning - The route's catalog metadata; absent when the catalog does
|
|
158
|
+
* not advertise the route, or the model declares no reasoning control.
|
|
159
|
+
* @param stored - The configured value, if any.
|
|
160
|
+
*/
|
|
161
|
+
export function reasoningEffortChoices(
|
|
162
|
+
reasoning: ConsolidationModelReasoning | undefined,
|
|
163
|
+
stored: string | undefined,
|
|
164
|
+
): ReasoningEffortChoice[] {
|
|
165
|
+
const efforts = Array.isArray(reasoning?.efforts) ? reasoning.efforts : []
|
|
166
|
+
const choices: ReasoningEffortChoice[] = [{ key: 'purpose', effort: undefined }]
|
|
167
|
+
for (const effort of efforts) {
|
|
168
|
+
const id = typeof effort?.id === 'string' ? effort.id : ''
|
|
169
|
+
if (id === '') continue
|
|
170
|
+
const name = typeof effort.name === 'string' && effort.name !== '' ? effort.name : id
|
|
171
|
+
choices.push({ key: `effort:${id}`, effort: id, name })
|
|
172
|
+
}
|
|
173
|
+
const value = typeof stored === 'string' ? stored.trim() : ''
|
|
174
|
+
if (value !== '' && !efforts.some(effort => effort?.id === value)) {
|
|
175
|
+
choices.push({ key: `stale:${value}`, effort: value, stale: true })
|
|
176
|
+
}
|
|
177
|
+
return choices
|
|
178
|
+
}
|
|
179
|
+
|
|
34
180
|
/**
|
|
35
181
|
* Join the live model directory with selected routes that disappeared from it.
|
|
36
182
|
* Disappeared routes remain visible so users can remove them deliberately.
|
|
@@ -52,6 +198,10 @@ export function consolidationModelCandidates(
|
|
|
52
198
|
modelName: model.name,
|
|
53
199
|
available: true,
|
|
54
200
|
selected: selected.has(key),
|
|
201
|
+
// The adapter-owned vocabulary this route accepts, straight from the
|
|
202
|
+
// catalog the card already loaded — no extra request, and no client-side
|
|
203
|
+
// guess at what "low" or "high" mean.
|
|
204
|
+
...model.reasoning === undefined ? {} : { reasoning: model.reasoning },
|
|
55
205
|
}
|
|
56
206
|
}))
|
|
57
207
|
for (const route of storedByKey.values()) {
|