dsh-loadout 0.12.6 → 0.13.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/CHANGELOG.md +28 -0
- package/README.en.md +37 -1
- package/README.md +41 -3
- package/client.js +434 -32
- package/index.js +216 -4
- package/package.json +3 -2
- package/scripts/client-smoke.mjs +303 -10
- package/scripts/selftest.mjs +150 -0
package/index.js
CHANGED
|
@@ -189,6 +189,15 @@ function normalizeStore(value) {
|
|
|
189
189
|
kits: normalizeKits(value.kits),
|
|
190
190
|
defaultKit: typeof value.defaultKit === 'string' ? value.defaultKit.trim() : '',
|
|
191
191
|
assignments: normalizeAssignments(value.assignments),
|
|
192
|
+
// A pick made in a conversation that has not started yet. Persisted on
|
|
193
|
+
// purpose: the marker has to survive a restart, or the next visit reads the
|
|
194
|
+
// binding as "this conversation was switched" and the default never wins.
|
|
195
|
+
drafts: normalizeAssignments(value.drafts),
|
|
196
|
+
// Sessions whose binding was not chosen but handed to them by the default
|
|
197
|
+
// kit at creation. They follow the default until their first turn; an
|
|
198
|
+
// explicit pick deletes the flag. Persisted for the same reason drafts are:
|
|
199
|
+
// a restart must not turn "the default I happened to have" into a decision.
|
|
200
|
+
auto: normalizeAuto(value.auto),
|
|
192
201
|
migratedAt: typeof value.migratedAt === 'string' && !Number.isNaN(Date.parse(value.migratedAt))
|
|
193
202
|
? value.migratedAt
|
|
194
203
|
: undefined,
|
|
@@ -211,8 +220,21 @@ function normalizeAssignments(value) {
|
|
|
211
220
|
return out
|
|
212
221
|
}
|
|
213
222
|
|
|
214
|
-
|
|
215
|
-
|
|
223
|
+
/**
|
|
224
|
+
* Sessions whose binding came from the default kit rather than from a pick.
|
|
225
|
+
* Stored as `{ [sessionId]: true }`; anything but `true` is ignored, so a
|
|
226
|
+
* half-written file can only lose the flag, never invent one.
|
|
227
|
+
*/
|
|
228
|
+
function normalizeAuto(value) {
|
|
229
|
+
const out = {}
|
|
230
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return out
|
|
231
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
232
|
+
if (typeof key === 'string' && key !== '' && entry === true) out[key] = true
|
|
233
|
+
}
|
|
234
|
+
return out
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function readStore() { // New file first, then the pre-0.5 copy: a rename must not silently drop the
|
|
216
238
|
// kits someone already saved. The legacy file is never written again, so the
|
|
217
239
|
// next save moves the store forward for good.
|
|
218
240
|
for (const path of [STORE_PATH, LEGACY_STORE_PATH]) {
|
|
@@ -330,6 +352,8 @@ export function apply(ctx, config = {}) {
|
|
|
330
352
|
kits: migrateUserKits(loaded?.store.kits ?? [], presets),
|
|
331
353
|
defaultKit: loaded?.store.defaultKit ?? (typeof config?.defaultKit === 'string' ? config.defaultKit.trim() : ''),
|
|
332
354
|
assignments: loaded?.store.assignments ?? {},
|
|
355
|
+
drafts: loaded?.store.drafts ?? {},
|
|
356
|
+
auto: loaded?.store.auto ?? {},
|
|
333
357
|
migratedAt: loaded?.store.migratedAt,
|
|
334
358
|
// The file the kits actually came from: the legacy path on the first boot
|
|
335
359
|
// after the rename, the new one afterwards.
|
|
@@ -354,6 +378,11 @@ export function apply(ctx, config = {}) {
|
|
|
354
378
|
})),
|
|
355
379
|
defaultKit: book.defaultKit,
|
|
356
380
|
assignments: book.assignments,
|
|
381
|
+
// Only non-empty while some conversation is still waiting for its first
|
|
382
|
+
// message; a started conversation drops its entry.
|
|
383
|
+
...(Object.keys(book.drafts).length === 0 ? {} : { drafts: book.drafts }),
|
|
384
|
+
// Same rule: only conversations that have not run a turn yet carry this.
|
|
385
|
+
...(Object.keys(book.auto).length === 0 ? {} : { auto: book.auto }),
|
|
357
386
|
...(book.migratedAt === undefined ? {} : { migratedAt: book.migratedAt }),
|
|
358
387
|
})
|
|
359
388
|
// `source` answers "where do the user's kits live". Writing assignments or a
|
|
@@ -478,6 +507,12 @@ export function apply(ctx, config = {}) {
|
|
|
478
507
|
* parentSession / origin). Optional for the same reason as sessionController.
|
|
479
508
|
*/
|
|
480
509
|
let sessions
|
|
510
|
+
/**
|
|
511
|
+
* The session-projection registry, read only for `contextPressure`: how big
|
|
512
|
+
* this conversation's prompt is right now. Optional — a host without the
|
|
513
|
+
* token meter simply shows no price estimate.
|
|
514
|
+
*/
|
|
515
|
+
let sessionProjections
|
|
481
516
|
let lastAgent
|
|
482
517
|
/** Every tool name this profile can expose; filled by learnTools below. */
|
|
483
518
|
const knownTools = new Set(OWN_TOOLS)
|
|
@@ -514,6 +549,40 @@ export function apply(ctx, config = {}) {
|
|
|
514
549
|
}
|
|
515
550
|
}
|
|
516
551
|
|
|
552
|
+
/**
|
|
553
|
+
* How large this conversation's prompt is right now, in tokens — the part a
|
|
554
|
+
* kit switch makes the provider bill again at the miss price.
|
|
555
|
+
*
|
|
556
|
+
* Read from the `contextPressure` session projection: the provider-reported
|
|
557
|
+
* prompt size of the last request, plus whatever the surface has grown by
|
|
558
|
+
* since that sample. O(1), no model call, and the same number the client
|
|
559
|
+
* shows as occupancy. `tokens: null` before the first request — which is
|
|
560
|
+
* exactly when a switch costs nothing.
|
|
561
|
+
*/
|
|
562
|
+
function contextOf(agent) {
|
|
563
|
+
const sessionId = sessionIdOf(agent)
|
|
564
|
+
if (sessionId === undefined || sessionProjections === undefined || sessions === undefined) {
|
|
565
|
+
return { tokens: null }
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const session = sessions.get?.(sessionId)
|
|
569
|
+
if (session === undefined || session === null) return { tokens: null }
|
|
570
|
+
const { values } = sessionProjections.snapshot(session, ['contextPressure'])
|
|
571
|
+
const pressure = values?.contextPressure
|
|
572
|
+
const tokens = pressure?.projectedTokens ?? pressure?.pressureTokens
|
|
573
|
+
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens <= 0) {
|
|
574
|
+
return { tokens: null, window: pressure?.contextWindow ?? null }
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
tokens: Math.round(tokens),
|
|
578
|
+
window: typeof pressure?.contextWindow === 'number' ? pressure.contextWindow : null,
|
|
579
|
+
}
|
|
580
|
+
} catch {
|
|
581
|
+
// The registry is optional; a host without it just has no number to show.
|
|
582
|
+
return { tokens: null }
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
517
586
|
/** `hasOwnProperty`, because a stored `null` (全部) is a real answer. */
|
|
518
587
|
function bindingOf(sessionId) {
|
|
519
588
|
if (typeof sessionId !== 'string') return undefined
|
|
@@ -564,12 +633,20 @@ export function apply(ctx, config = {}) {
|
|
|
564
633
|
const header = sessionHeaderOf(sessionId)
|
|
565
634
|
if (header?.origin === 'subagent') return undefined
|
|
566
635
|
let value
|
|
636
|
+
let fromDefault = false
|
|
567
637
|
const parentBinding = header?.parentSession === undefined ? undefined : bindingOf(header.parentSession)
|
|
568
638
|
if (parentBinding !== undefined) value = parentBinding
|
|
569
639
|
else if (book.migratedAt !== undefined && typeof header?.createdAt === 'number'
|
|
570
640
|
&& header.createdAt < Date.parse(book.migratedAt)) value = null
|
|
571
|
-
else
|
|
641
|
+
else {
|
|
642
|
+
value = defaultBinding()
|
|
643
|
+
fromDefault = true
|
|
644
|
+
}
|
|
572
645
|
book.assignments[sessionId] = value
|
|
646
|
+
// Not the user's pick: remember who handed this conversation its kit, so a
|
|
647
|
+
// later default change can move it. The first turn (or an explicit pick)
|
|
648
|
+
// drops the flag and the binding becomes this conversation's own.
|
|
649
|
+
if (fromDefault) book.auto[sessionId] = true
|
|
573
650
|
save()
|
|
574
651
|
return value
|
|
575
652
|
}
|
|
@@ -1045,6 +1122,8 @@ export function apply(ctx, config = {}) {
|
|
|
1045
1122
|
const next = bindingForSelection(normalized)
|
|
1046
1123
|
if (bindingOf(sessionId) !== next) {
|
|
1047
1124
|
book.assignments[sessionId] = next
|
|
1125
|
+
// Switched here, so this conversation stops following the default.
|
|
1126
|
+
delete book.auto[sessionId]
|
|
1048
1127
|
save()
|
|
1049
1128
|
}
|
|
1050
1129
|
}
|
|
@@ -1203,6 +1282,31 @@ export function apply(ctx, config = {}) {
|
|
|
1203
1282
|
if (typeof retry?.unref === 'function') retry.unref()
|
|
1204
1283
|
})
|
|
1205
1284
|
|
|
1285
|
+
try {
|
|
1286
|
+
// A draft pick stops being a draft as soon as the conversation runs a turn:
|
|
1287
|
+
// from then on it is this conversation's kit, like any explicit switch. A
|
|
1288
|
+
// conversation that is disposed without ever running one takes its draft
|
|
1289
|
+
// with it. The marker is persisted (`book.drafts`), so a restart between the
|
|
1290
|
+
// pick and the first message does not turn it into a permanent binding.
|
|
1291
|
+
const dropDraft = (id) => {
|
|
1292
|
+
if (typeof id !== 'string') return
|
|
1293
|
+
// The first turn ends the grace period for both markers: the pick is now
|
|
1294
|
+
// this conversation's kit, and so is a default it was handed at creation.
|
|
1295
|
+
const hadDraft = Object.prototype.hasOwnProperty.call(book.drafts, id)
|
|
1296
|
+
const borrowed = book.auto[id] === true
|
|
1297
|
+
if (!hadDraft && !borrowed) return
|
|
1298
|
+
if (hadDraft) delete book.drafts[id]
|
|
1299
|
+
if (borrowed) delete book.auto[id]
|
|
1300
|
+
save()
|
|
1301
|
+
}
|
|
1302
|
+
ctx.on('session/event', (session, event) => {
|
|
1303
|
+
if (event?.type === 'turn/end') dropDraft(session?.id)
|
|
1304
|
+
})
|
|
1305
|
+
ctx.on('session/disposed', (session) => dropDraft(session?.id))
|
|
1306
|
+
} catch {
|
|
1307
|
+
// An older host without these events still works; drafts just live longer.
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1206
1310
|
try {
|
|
1207
1311
|
ctx.effect(() => () => {
|
|
1208
1312
|
for (const [, entry] of state) {
|
|
@@ -1454,6 +1558,16 @@ export function apply(ctx, config = {}) {
|
|
|
1454
1558
|
// larger than the whole tool table.
|
|
1455
1559
|
const surface = new Set(visibleNamesFor(agent?.ctx?.tools, agent) ?? [])
|
|
1456
1560
|
const universe = surface.size > 0 ? surface : catalogue
|
|
1561
|
+
// What each of the two base rows would put in the prompt. Without this the
|
|
1562
|
+
// menu can only price a switch by context length, which is the same number
|
|
1563
|
+
// on every row and therefore says nothing about the row; the tool-table size
|
|
1564
|
+
// is what actually differs between 全部 / 仅内置 / each kit.
|
|
1565
|
+
const baseTokens = index.size === 0
|
|
1566
|
+
? undefined
|
|
1567
|
+
: {
|
|
1568
|
+
all: ledger?.totalTokens ?? tokensForNames([...catalogue].filter((name) => universe.has(name)), index),
|
|
1569
|
+
dsh: tokensForNames(catalog.builtins.filter((name) => universe.has(name)), index),
|
|
1570
|
+
}
|
|
1457
1571
|
const priced = (row) => {
|
|
1458
1572
|
if (index.size === 0) return row
|
|
1459
1573
|
const names = asNames(row.allow ?? row.builtins).filter((name) => universe.has(name))
|
|
@@ -1471,6 +1585,17 @@ export function apply(ctx, config = {}) {
|
|
|
1471
1585
|
// '' when the conversation has none (a subagent). The chip reads this
|
|
1472
1586
|
// instead of inferring "no kit" from the live label.
|
|
1473
1587
|
binding: bindingOf(sessionIdOf(agent)) ?? '',
|
|
1588
|
+
// `binding` cannot tell "chose 全部" from "has not chosen yet" (both read
|
|
1589
|
+
// as ''); the new-conversation picker shows the default kit until one is
|
|
1590
|
+
// chosen, so the distinction has to travel with the snapshot. A binding
|
|
1591
|
+
// that came from the default is not a choice either — the picker has to
|
|
1592
|
+
// keep following the default for it.
|
|
1593
|
+
bound: bindingOf(sessionIdOf(agent)) !== undefined && book.auto[sessionIdOf(agent)] !== true,
|
|
1594
|
+
// Prompt size, so the chip can price a switch without guessing: swapping
|
|
1595
|
+
// the kit rewrites the tool definitions, and every token after them is
|
|
1596
|
+
// billed again at the miss price.
|
|
1597
|
+
context: contextOf(agent),
|
|
1598
|
+
baseTokens,
|
|
1474
1599
|
ledger,
|
|
1475
1600
|
catalog,
|
|
1476
1601
|
tools: toolCatalog(),
|
|
@@ -1628,6 +1753,12 @@ export function apply(ctx, config = {}) {
|
|
|
1628
1753
|
const kit = resolveKit(lastAgent, wanted)
|
|
1629
1754
|
if (kit === undefined) throw new Error(`未知套装 "${wanted}"`)
|
|
1630
1755
|
book.defaultKit = kit.name
|
|
1756
|
+
// No conversation is touched here, not even one that is still blank: the
|
|
1757
|
+
// default is a rule for conversations that have not been created yet. A
|
|
1758
|
+
// conversation that borrowed the old default is repointed by the welcome
|
|
1759
|
+
// screen itself (it can see the mismatch and ask for the new one), which
|
|
1760
|
+
// keeps "changing the default never rewrites a conversation" true for
|
|
1761
|
+
// every caller, including the tool.
|
|
1631
1762
|
save()
|
|
1632
1763
|
return { defaultKit: book.defaultKit }
|
|
1633
1764
|
},
|
|
@@ -1903,6 +2034,7 @@ export function apply(ctx, config = {}) {
|
|
|
1903
2034
|
value = kit.name
|
|
1904
2035
|
}
|
|
1905
2036
|
book.assignments[sessionId] = value
|
|
2037
|
+
delete book.auto[sessionId]
|
|
1906
2038
|
save()
|
|
1907
2039
|
// Apply it live when that conversation is still open; otherwise the pin is
|
|
1908
2040
|
// picked up the next time the conversation is read.
|
|
@@ -2016,7 +2148,23 @@ export function apply(ctx, config = {}) {
|
|
|
2016
2148
|
// and an unknown id still answer from the last agent seen.
|
|
2017
2149
|
const sessionId = new URL(req.url ?? '', 'http://localhost').searchParams.get('sessionId') ?? ''
|
|
2018
2150
|
const found = await agentForSession(sessionId)
|
|
2019
|
-
|
|
2151
|
+
const view = found.ok ? { live: true, ...snapshot(found.agent) } : { live: false, ...snapshot() }
|
|
2152
|
+
// A conversation that has not sent a message yet may have no agent; its
|
|
2153
|
+
// binding is still readable by Session id, which is what lets the
|
|
2154
|
+
// new-conversation picker show the kit this conversation will start with.
|
|
2155
|
+
// These three are per-Session facts, so they are answered for the id the
|
|
2156
|
+
// caller named — not for whichever agent happened to be resolved.
|
|
2157
|
+
if (sessionId !== '') {
|
|
2158
|
+
view.binding = bindingOf(sessionId) ?? ''
|
|
2159
|
+
// "Chose 全部" and "never chose" both read as an empty binding, and
|
|
2160
|
+
// they mean different kits to show in the picker.
|
|
2161
|
+
view.bound = bindingOf(sessionId) !== undefined && book.auto[sessionId] !== true
|
|
2162
|
+
// `draft` says the pick was made on a screen that never started, so
|
|
2163
|
+
// the next visit drops it instead of inheriting it. It is read from
|
|
2164
|
+
// the store, so a restart cannot turn a draft into a decision.
|
|
2165
|
+
view.draft = Object.prototype.hasOwnProperty.call(book.drafts, sessionId)
|
|
2166
|
+
}
|
|
2167
|
+
return sendJson(res, 200, { ok: true, ...view })
|
|
2020
2168
|
}
|
|
2021
2169
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: '只支持 GET/POST' })
|
|
2022
2170
|
const body = await readJson(req)
|
|
@@ -2083,6 +2231,67 @@ export function apply(ctx, config = {}) {
|
|
|
2083
2231
|
if (!result.ok) return sendJson(res, 400, { ok: false, error: result.reason })
|
|
2084
2232
|
return sendJson(res, 200, { ok: true, kit: kit.name, ...snapshot(found.agent) })
|
|
2085
2233
|
}
|
|
2234
|
+
case 'bind': {
|
|
2235
|
+
// A conversation that has not sent anything may have no agent yet, so
|
|
2236
|
+
// there is nothing to switch — but the binding is keyed by Session id,
|
|
2237
|
+
// so the pick can be recorded now and installed the moment the agent
|
|
2238
|
+
// exists. That happens before the first request builds a prompt, which
|
|
2239
|
+
// is why this pick costs nothing (no cache reset).
|
|
2240
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
2241
|
+
if (sessionId === '') return sendJson(res, 400, { ok: false, error: '缺少会话 id' })
|
|
2242
|
+
const wanted = typeof body.name === 'string' ? body.name.trim() : ''
|
|
2243
|
+
const value = wanted === '' ? null : wanted
|
|
2244
|
+
if (value !== null && value !== DEFAULT_DSH && resolveKit(lastAgent, value) === undefined) {
|
|
2245
|
+
return sendJson(res, 404, { ok: false, error: `没有套装「${value}」` })
|
|
2246
|
+
}
|
|
2247
|
+
book.assignments[sessionId] = value
|
|
2248
|
+
if (body.follow === true) {
|
|
2249
|
+
// "Catch up with the default", not a pick: the welcome screen uses
|
|
2250
|
+
// this when the conversation still holds the kit a previous
|
|
2251
|
+
// default gave it. No draft, and the auto flag stays, so a later
|
|
2252
|
+
// default change moves it again.
|
|
2253
|
+
book.auto[sessionId] = true
|
|
2254
|
+
delete book.drafts[sessionId]
|
|
2255
|
+
save()
|
|
2256
|
+
const follow = await agentForSession(sessionId)
|
|
2257
|
+
if (!follow.ok) return sendJson(res, 200, { ok: true, ...snapshot(), binding: value ?? '', bound: false, draft: false })
|
|
2258
|
+
rememberAgent(follow.agent)
|
|
2259
|
+
return sendJson(res, 200, { ok: true, ...snapshot(follow.agent), bound: false, draft: false })
|
|
2260
|
+
}
|
|
2261
|
+
// An explicit pick stops following the default.
|
|
2262
|
+
delete book.auto[sessionId]
|
|
2263
|
+
// Remember that this was a welcome-screen pick, so the next visit
|
|
2264
|
+
// can drop it again unless the conversation has actually started.
|
|
2265
|
+
// Persisted: a restart must not launder a draft into a decision.
|
|
2266
|
+
book.drafts[sessionId] = value
|
|
2267
|
+
save()
|
|
2268
|
+
// With an agent this applies right away; without one the reply still
|
|
2269
|
+
// carries the stored binding, which is all the picker needs to show.
|
|
2270
|
+
const bound = await agentForSession(sessionId)
|
|
2271
|
+
if (!bound.ok) return sendJson(res, 200, { ok: true, ...snapshot(), binding: value ?? '', bound: true, draft: true })
|
|
2272
|
+
rememberAgent(bound.agent)
|
|
2273
|
+
// `bound` tells the picker that an empty binding was a choice, not
|
|
2274
|
+
// the absence of one — the same distinction the GET branch makes.
|
|
2275
|
+
return sendJson(res, 200, { ok: true, ...snapshot(bound.agent), bound: true, draft: true })
|
|
2276
|
+
}
|
|
2277
|
+
case 'release': {
|
|
2278
|
+
// The user came back to a welcome screen carrying a draft pick from
|
|
2279
|
+
// a previous visit. A draft that was never sent is not a decision:
|
|
2280
|
+
// drop it so the screen looks untouched, exactly like the host
|
|
2281
|
+
// resetting its own mode.
|
|
2282
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
2283
|
+
if (sessionId === '') return sendJson(res, 400, { ok: false, error: '缺少会话 id' })
|
|
2284
|
+
const hadDraft = Object.prototype.hasOwnProperty.call(book.drafts, sessionId)
|
|
2285
|
+
if (!hadDraft) {
|
|
2286
|
+
const boundHere = bindingOf(sessionId) !== undefined && book.auto[sessionId] !== true
|
|
2287
|
+
return sendJson(res, 200, { ok: true, ...snapshot(), binding: bindingOf(sessionId) ?? '', bound: boundHere, live: false })
|
|
2288
|
+
}
|
|
2289
|
+
delete book.drafts[sessionId]
|
|
2290
|
+
delete book.assignments[sessionId]
|
|
2291
|
+
delete book.auto[sessionId]
|
|
2292
|
+
save()
|
|
2293
|
+
return sendJson(res, 200, { ok: true, ...snapshot(), binding: '', bound: false, live: false, draft: false })
|
|
2294
|
+
}
|
|
2086
2295
|
case 'create': operations.create(body); break
|
|
2087
2296
|
case 'update': operations.update(body); break
|
|
2088
2297
|
case 'delete':
|
|
@@ -2211,6 +2420,9 @@ export function apply(ctx, config = {}) {
|
|
|
2211
2420
|
optional(['sessions'], (child) => {
|
|
2212
2421
|
sessions = child.sessions
|
|
2213
2422
|
})
|
|
2423
|
+
optional(['sessionProjections'], (child) => {
|
|
2424
|
+
sessionProjections = child.sessionProjections
|
|
2425
|
+
})
|
|
2214
2426
|
|
|
2215
2427
|
ctx.logger?.info(`dsh-loadout: ${book.kits.length} 个我的套装 + ${presets.length} 个预设,默认 ${book.defaultKit || '(未设置)'},来源 ${book.source}`)
|
|
2216
2428
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-loadout",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Loadout for DeepSeek Harness: group tools into kits, keep only the current kit's tools visible in each conversation, and manage everything from a settings panel.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
"inject": [
|
|
61
61
|
"@deepseek-ai/dsh-client-locale",
|
|
62
62
|
"@deepseek-ai/dsh-client-ui-slots",
|
|
63
|
-
"@deepseek-ai/dsh-client-ui-settings"
|
|
63
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
64
65
|
]
|
|
65
66
|
},
|
|
66
67
|
"manifestVersion": 1
|