dsh-loadout 0.12.4 → 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 +41 -1
- package/README.en.md +40 -2
- package/README.md +45 -5
- package/client.js +434 -32
- package/index.js +217 -5
- package/package.json +3 -2
- package/scripts/client-smoke.mjs +303 -10
- package/scripts/selftest.mjs +150 -0
- package/scripts/agent-lookup-probe.mjs +0 -24
- package/scripts/agent-probe.mjs +0 -25
- package/scripts/attribution-probe.mjs +0 -91
- package/scripts/cmd-probe.mjs +0 -30
- package/scripts/dock-probe.mjs +0 -53
- package/scripts/slots-probe.mjs +0 -59
package/scripts/client-smoke.mjs
CHANGED
|
@@ -45,7 +45,7 @@ const React = {
|
|
|
45
45
|
|
|
46
46
|
const registrations = []
|
|
47
47
|
let injections = 0
|
|
48
|
-
const SLOTS = ['settings.section', 'conversation.session.header.actions']
|
|
48
|
+
const SLOTS = ['settings.section', 'conversation.session.header.actions', 'conversation.input.right']
|
|
49
49
|
|
|
50
50
|
const slots = {
|
|
51
51
|
inject(name, callback) {
|
|
@@ -83,7 +83,9 @@ globalThis.window = {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// fetch must not run during load; it only runs inside effects, which the fake React never invokes.
|
|
86
|
-
|
|
86
|
+
// The indirection lets one test swap in a recorder and click a row for real.
|
|
87
|
+
let fetchImpl = () => Promise.reject(new Error('unexpected network call during load'))
|
|
88
|
+
globalThis.fetch = (...args) => fetchImpl(...args)
|
|
87
89
|
globalThis.AbortController = class { abort() {} signal = {} }
|
|
88
90
|
|
|
89
91
|
// The client bundle is plain script text (no exports); evaluate it with `window` in scope.
|
|
@@ -95,13 +97,21 @@ check('declares inject', Array.isArray(loaded?.inject) && loaded.inject.includes
|
|
|
95
97
|
|
|
96
98
|
loaded?.apply(ctx)
|
|
97
99
|
|
|
98
|
-
check('slots.inject called
|
|
99
|
-
check('
|
|
100
|
+
check('slots.inject called once per surface', injections === 3, String(injections))
|
|
101
|
+
check('three surfaces registered', registrations.length === 3, String(registrations.length))
|
|
100
102
|
|
|
101
103
|
const registration = registrations.find((entry) => entry.definition?.name === 'settings.section')
|
|
102
104
|
const header = registrations.find((entry) => entry.definition?.name === 'conversation.session.header.actions')
|
|
105
|
+
const hero = registrations.find((entry) => entry.definition?.name === 'conversation.input.right')
|
|
103
106
|
check('settings panel registered', registration !== undefined)
|
|
104
107
|
check('header chip registered', header !== undefined)
|
|
108
|
+
check('new-conversation picker registered', hero !== undefined)
|
|
109
|
+
// The slot renders only when a Session exists, and a Session-scoped entry gets
|
|
110
|
+
// its id through `inject`; without this the picker would render with no session
|
|
111
|
+
// to read or bind.
|
|
112
|
+
check('the picker is handed its Session id',
|
|
113
|
+
typeof hero?.definition?.inject === 'function' && hero.definition.inject('session-x').sessionId === 'session-x',
|
|
114
|
+
typeof hero?.definition?.inject)
|
|
105
115
|
|
|
106
116
|
const definition = registration?.definition ?? {}
|
|
107
117
|
check('slot name', definition.name === 'settings.section', String(definition.name))
|
|
@@ -174,7 +184,7 @@ function expand(node) {
|
|
|
174
184
|
}
|
|
175
185
|
|
|
176
186
|
// ---- the rendered menu (seeded through the documented `preview` test seam) --
|
|
177
|
-
const FOOTER = '
|
|
187
|
+
const FOOTER = '装备栏只决定「这个对话能用哪些工具」:切换后下一条消息即按新清单组装,随时可以切回「全部」;装备栏作用于工具(tool)这一层——界面面板、宿主服务类插件不注册工具,因此不受影响。'
|
|
178
188
|
const fixture = {
|
|
179
189
|
ok: true,
|
|
180
190
|
source: 'C:/fake/.dsh/loadout.json',
|
|
@@ -207,6 +217,7 @@ const fixture = {
|
|
|
207
217
|
visibleTools: 12, visibleTokens: 860,
|
|
208
218
|
totalTools: 31, totalTokens: 2410, savedTokens: 1550, unpriced: 0,
|
|
209
219
|
},
|
|
220
|
+
baseTokens: { all: 2410, dsh: 1640 },
|
|
210
221
|
}
|
|
211
222
|
|
|
212
223
|
let menu
|
|
@@ -251,8 +262,13 @@ if (menu !== undefined) {
|
|
|
251
262
|
// Presets are back from 0.12: they are the quickest way to start a known kind
|
|
252
263
|
// of work, they stay read-only, and each one now says what it leaves out.
|
|
253
264
|
check('menu lists the presets again', allText.includes('预设套装(5)') && allText.includes('写作') && allText.includes('仅聊天'), allText.slice(0, 400))
|
|
254
|
-
|
|
265
|
+
// 加挂 reads as "in this conversation / plugins there are at all", so the
|
|
266
|
+
// group answers "how much room is left" without opening it — and under 全部
|
|
267
|
+
// it reads 2/2 rather than 0/2, because everything is already in.
|
|
268
|
+
check('加挂 counts in-conversation plugins of all plugins', allText.includes('加挂(2/2)'), allText.slice(0, 400))
|
|
255
269
|
check('footer text is the approved sentence', allText.includes(FOOTER))
|
|
270
|
+
check('footer carries the before-you-start reminder',
|
|
271
|
+
allText.includes('重要提醒') && allText.includes('建议新建对话时直接选择合适套装'), allText.slice(-600))
|
|
256
272
|
check('the menu shows the note with the kit name', allText.includes('我的套(用于视频制作)'), allText.slice(0, 400))
|
|
257
273
|
check('the save panel can be cancelled', source.includes("setSaveNote('')"), 'the 取消 button does not reset the save panel')
|
|
258
274
|
|
|
@@ -297,6 +313,257 @@ if (menu !== undefined) {
|
|
|
297
313
|
const kitsOpenText = textOf(kitsOpen)
|
|
298
314
|
check('我的套装 expands to the other kits', kitsOpenText.includes('另一套'), kitsOpenText.slice(0, 400))
|
|
299
315
|
check('新建套装 is offered under 我的套装', kitsOpenText.includes('+ 新建套装'), kitsOpenText.slice(0, 400))
|
|
316
|
+
|
|
317
|
+
// Saving a kit and starting to use it are two decisions. The menu offers both,
|
|
318
|
+
// and only the second one can change what this conversation's tools are.
|
|
319
|
+
check('the builder offers save without switching',
|
|
320
|
+
source.includes("onSaveKeep: () => saveBuilder('keep')"), 'no 保存 button next to 保存并启用')
|
|
321
|
+
check('the builder labels the switching button', source.includes("saveLabel: '保存并启用'"))
|
|
322
|
+
check('the switching button warns it rewrites the tools',
|
|
323
|
+
source.includes('工具清单会变,这个对话下一条消息要按未命中价重新计算一次'), '保存并启用 has no hover text')
|
|
324
|
+
const keepBranch = source.slice(source.indexOf("if (mode === 'keep')"), source.indexOf('const appliedKit'))
|
|
325
|
+
check('save-only leaves the current selection alone', keepBranch.includes('return'), keepBranch.slice(0, 200))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---- the price of a switch, next to the switch ------------------------------
|
|
329
|
+
// Switching rewrites the tool definitions, so everything after them in the prefix
|
|
330
|
+
// is paid again at the miss price. The number is meaningless without the context
|
|
331
|
+
// that produced it, so the status line names the size too.
|
|
332
|
+
const priced = { ...fixture, context: { tokens: 200000 } }
|
|
333
|
+
let pricedMenu
|
|
334
|
+
try {
|
|
335
|
+
pricedMenu = expand(header.component({
|
|
336
|
+
sessionId: 'session-1',
|
|
337
|
+
preview: { open: true, data: priced, groups: { kits: true, presets: true, extras: true, applied: false } },
|
|
338
|
+
}))
|
|
339
|
+
} catch (error) {
|
|
340
|
+
failures.push(`priced menu render threw: ${error instanceof Error ? error.message : String(error)}`)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (pricedMenu !== undefined) {
|
|
344
|
+
const pricedText = textOf(pricedMenu)
|
|
345
|
+
check('the status line states the context size', pricedText.includes('上下文 ~200k'), pricedText.slice(0, 300))
|
|
346
|
+
// Every row states the tool table it would install. That is what differs
|
|
347
|
+
// between 全部 / 仅内置 / each kit; the switch bill is the same number for all
|
|
348
|
+
// of them, so it lives once in the footer instead of on every row.
|
|
349
|
+
check('an inactive kit states its tool tokens', pricedText.includes('≈420 tokens'), pricedText.slice(0, 900))
|
|
350
|
+
const activePriced = nodesIn(pricedMenu, (node) => node.props?.role === 'menuitem' && textOf(node).includes('我的套'))
|
|
351
|
+
check('the kit already in use still states its tokens', textOf(activePriced[0]).includes('≈860 tokens'), textOf(activePriced[0]))
|
|
352
|
+
check('the default kit is marked after its name', textOf(activePriced[0]).includes('(默认)'), textOf(activePriced[0]))
|
|
353
|
+
|
|
354
|
+
// 全部 and 仅内置 pay for different tool tables, and that difference is the
|
|
355
|
+
// reason to pick one over the other.
|
|
356
|
+
const allRow = nodesIn(pricedMenu, (node) => node.props?.role === 'menuitem' && textOf(node).trim().startsWith('全部'))
|
|
357
|
+
check('全部 states its tool tokens', allRow.length === 1 && textOf(allRow[0]).includes('≈2.4k tokens'), JSON.stringify(allRow.map(textOf)))
|
|
358
|
+
const dshRow = nodesIn(pricedMenu, (node) => node.props?.role === 'menuitem' && textOf(node).trim().startsWith('仅内置'))
|
|
359
|
+
check('仅内置 states its own, smaller tool table', dshRow.length === 1 && textOf(dshRow[0]).includes('≈1.6k tokens'), JSON.stringify(dshRow.map(textOf)))
|
|
360
|
+
|
|
361
|
+
// The bill is stated once, under the reminder — not per row. A tooltip has to
|
|
362
|
+
// be found; the reminder is the paragraph people actually read.
|
|
363
|
+
check('the footer states the bill under the reminder',
|
|
364
|
+
pricedText.includes('本对话上下文(约 200k tokens),下一条未命中价≈¥0.30,之后恢复至缓存价。'),
|
|
365
|
+
pricedText.slice(-400))
|
|
366
|
+
// The bill is the one line people are meant to notice; at 11px grey it read as
|
|
367
|
+
// a footnote to the reminder above it.
|
|
368
|
+
const billLine = nodesIn(pricedMenu, (node) => node.type === 'div' &&
|
|
369
|
+
node.props?.style?.fontWeight === 600 && textOf(node).includes('本对话上下文'))
|
|
370
|
+
check('the bill line is bold and dark', billLine.length === 1, `${billLine.length}`)
|
|
371
|
+
const rowsWithOldTip = nodesIn(pricedMenu, (node) => node.props?.role === 'menuitem' &&
|
|
372
|
+
typeof node.props?.title === 'string' && node.props.title.includes('按未命中价重新计算'))
|
|
373
|
+
check('no row repeats the bill as a tooltip', rowsWithOldTip.length === 0,
|
|
374
|
+
JSON.stringify(rowsWithOldTip.map(textOf)))
|
|
375
|
+
|
|
376
|
+
// What new conversations start from is a global decision and belongs to
|
|
377
|
+
// 设置 → 装备栏. This menu is scoped to one conversation, so it must not grow
|
|
378
|
+
// a control that writes `defaultKit` — that would be the same mistake as
|
|
379
|
+
// letting the picker edit kits.
|
|
380
|
+
const defaultButtons = nodesIn(pricedMenu, (node) => node.type === 'button' &&
|
|
381
|
+
['下次对话', '✓ 新对话', '设为默认'].includes(textOf(node).trim()))
|
|
382
|
+
check('the conversation menu cannot set the default', defaultButtons.length === 0,
|
|
383
|
+
defaultButtons.map((node) => textOf(node)).join('|'))
|
|
384
|
+
check('the conversation menu does not mention 下次对话', !pricedText.includes('下次对话'),
|
|
385
|
+
pricedText.slice(0, 300))
|
|
386
|
+
|
|
387
|
+
// Below 20k the switch is lost in the noise, and a number there would be noise.
|
|
388
|
+
const shortMenu = expand(header.component({
|
|
389
|
+
sessionId: 'session-1',
|
|
390
|
+
preview: { open: true, data: { ...fixture, context: { tokens: 5000 } }, groups: { kits: true, presets: true, extras: false } },
|
|
391
|
+
}))
|
|
392
|
+
const shortText = textOf(shortMenu)
|
|
393
|
+
const shortRows = nodesIn(shortMenu, (node) => node.props?.role === 'menuitem').map(textOf).join('|')
|
|
394
|
+
check('a short conversation quotes no price on the rows', !shortRows.includes('≈¥'), shortRows.slice(0, 400))
|
|
395
|
+
check('a short conversation still states tool tokens', shortRows.includes('≈860 tokens'), shortRows.slice(0, 400))
|
|
396
|
+
// The footer does not use that floor: it is one line whether the number is
|
|
397
|
+
// ¥0.008 or ¥0.30, and it is the sentence that explains the rows.
|
|
398
|
+
check('the footer still states the bill, however small',
|
|
399
|
+
shortText.includes('本对话上下文(约 5.0k tokens),下一条未命中价≈¥0.00'), shortText.slice(-300))
|
|
400
|
+
|
|
401
|
+
// A conversation already on the 全部 base: every plugin is in, so 加挂 reads
|
|
402
|
+
// full — it used to read 0/2 there, which looked like nothing had loaded.
|
|
403
|
+
const fullMenu = expand(header.component({
|
|
404
|
+
sessionId: 'session-1',
|
|
405
|
+
preview: {
|
|
406
|
+
open: true,
|
|
407
|
+
data: {
|
|
408
|
+
...fixture,
|
|
409
|
+
selection: { base: 'all', kit: null, extras: [] },
|
|
410
|
+
appliedPlugins: ['alpha', 'demo'],
|
|
411
|
+
label: '全部',
|
|
412
|
+
context: { tokens: 200000 },
|
|
413
|
+
},
|
|
414
|
+
groups: { kits: false, presets: false, extras: false, applied: false },
|
|
415
|
+
},
|
|
416
|
+
}))
|
|
417
|
+
const fullText = textOf(fullMenu)
|
|
418
|
+
check('加挂 reads full under 全部', fullText.includes('加挂(2/2)'), fullText.slice(0, 300))
|
|
419
|
+
const fullRows = nodesIn(fullMenu, (node) => node.props?.role === 'menuitem')
|
|
420
|
+
// Under 全部 the pair still states two different tool tables: that is the only
|
|
421
|
+
// thing separating "hand me everything" from "hand me the built-ins".
|
|
422
|
+
const pricedBase = ['全部', '仅内置'].filter((label) => {
|
|
423
|
+
const row = fullRows.find((node) => textOf(node).replace(/^[\s✓]+/, '').startsWith(label))
|
|
424
|
+
return row !== undefined && textOf(row).includes('≈') && textOf(row).includes('tokens')
|
|
425
|
+
})
|
|
426
|
+
check('both base rows state their tool tokens, even the one in use', pricedBase.length === 2,
|
|
427
|
+
JSON.stringify(fullRows.map(textOf)))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---- the blank-conversation picker (composer row, next to 标准模式) ----------
|
|
431
|
+
// This is the one place a switch is free: the agent does not exist yet, so no
|
|
432
|
+
// request prefix has been built and nothing can be re-billed.
|
|
433
|
+
try {
|
|
434
|
+
// Collapsed, the chip says what it is and keeps the sentence for the tooltip:
|
|
435
|
+
// the composer row is shared with 权限/计划 and has no room for prose.
|
|
436
|
+
const collapsed = expand(hero.component({ sessionId: 'session-new', preview: { blank: true, data: fixture } }))
|
|
437
|
+
const collapsedText = textOf(collapsed)
|
|
438
|
+
check('the chip says what it chooses and names the kit',
|
|
439
|
+
collapsedText.includes('选择对话套装') && collapsedText.includes('我的套'), collapsedText)
|
|
440
|
+
check('the sentence is not printed on the row', !collapsedText.includes('这个新对话会用'), collapsedText)
|
|
441
|
+
const tipButton = nodesIn(collapsed, (node) => typeof node.props?.title === 'string' && node.props.title.includes('这个新对话会用'))
|
|
442
|
+
check('the sentence rides on the chip as its tooltip', tipButton.length === 1, JSON.stringify(nodesIn(collapsed, (node) => node.props?.title)))
|
|
443
|
+
check('the tooltip reads as a sentence about this conversation',
|
|
444
|
+
tipButton[0]?.props?.title === '这个新对话会用「我的套」套装', String(tipButton[0]?.props?.title))
|
|
445
|
+
|
|
446
|
+
// A pick on an unsent screen is a draft: a later visit must start clean, so the
|
|
447
|
+
// first look asks the host to drop whatever a previous visit left behind.
|
|
448
|
+
check('a new visit releases the previous visit\'s draft',
|
|
449
|
+
source.includes("action: 'release'") && source.includes('next.draft === true'),
|
|
450
|
+
'the picker never releases a draft')
|
|
451
|
+
|
|
452
|
+
// With no default set, the collapsed label is 全部.
|
|
453
|
+
const noDefault = expand(hero.component({
|
|
454
|
+
sessionId: 'session-new',
|
|
455
|
+
preview: { blank: true, data: { ...fixture, defaultKit: '' } },
|
|
456
|
+
}))
|
|
457
|
+
const noDefaultText = textOf(noDefault)
|
|
458
|
+
check('with no default, the chip says 全部',
|
|
459
|
+
noDefaultText.includes('全部') && !noDefaultText.includes('我的套'), noDefaultText)
|
|
460
|
+
check('the collapsed control is a real button',
|
|
461
|
+
nodesIn(noDefault, (node) => node.props?.['aria-expanded'] === 'false').length === 1,
|
|
462
|
+
noDefaultText)
|
|
463
|
+
|
|
464
|
+
// Opened: 全部 / 仅内置 / the two groups, groups still folded.
|
|
465
|
+
const opened = expand(hero.component({
|
|
466
|
+
sessionId: 'session-new',
|
|
467
|
+
preview: { blank: true, data: fixture, open: true },
|
|
468
|
+
}))
|
|
469
|
+
const openedText = textOf(opened)
|
|
470
|
+
check('opened, it lists 全部 and 仅内置', openedText.includes('全部') && openedText.includes('仅内置'), openedText)
|
|
471
|
+
check('opened, it groups presets and my kits', openedText.includes('预设套装') && openedText.includes('我的套装'), openedText)
|
|
472
|
+
check('both kit groups start folded', !openedText.includes('写作') && !openedText.includes('另一套'), openedText)
|
|
473
|
+
check('folded groups still say how many they hold',
|
|
474
|
+
openedText.includes('(5)') && openedText.includes('(2)'), openedText)
|
|
475
|
+
// A folded group would otherwise answer "nothing chosen" on a fresh screen:
|
|
476
|
+
// the default kit stays visible and is marked the way the header menu marks it.
|
|
477
|
+
check('a folded group still shows the default kit',
|
|
478
|
+
openedText.includes('我的套(用于视频制作)(默认)') && !openedText.includes('另一套'), openedText)
|
|
479
|
+
const presetFold = expand(hero.component({
|
|
480
|
+
sessionId: 'session-new',
|
|
481
|
+
preview: { blank: true, data: { ...fixture, defaultKit: 'chat' }, open: true },
|
|
482
|
+
}))
|
|
483
|
+
check('a folded preset group shows the default preset too',
|
|
484
|
+
textOf(presetFold).includes('仅聊天(默认)') && !textOf(presetFold).includes('写作'), textOf(presetFold))
|
|
485
|
+
check('the collapsed chip stays plain',
|
|
486
|
+
!collapsedText.includes('(默认)'), collapsedText)
|
|
487
|
+
|
|
488
|
+
// The picker answers one question — which kit this conversation starts with.
|
|
489
|
+
// Creating, editing and choosing the default are someone else's job (设置), and
|
|
490
|
+
// a picker that also offered them would be a menu pretending to be a page.
|
|
491
|
+
const pickerText = textOf(hero.component({
|
|
492
|
+
sessionId: 'session-new',
|
|
493
|
+
preview: { blank: true, data: fixture, open: true, groups: { presets: true, kits: true } },
|
|
494
|
+
}))
|
|
495
|
+
check('the picker does not create kits', !pickerText.includes('新建套装'), pickerText.slice(0, 300))
|
|
496
|
+
check('the picker does not edit kits', !pickerText.includes('编辑') && !pickerText.includes('删除'), pickerText.slice(0, 300))
|
|
497
|
+
check('the picker does not set the default', !pickerText.includes('设为默认') && !pickerText.includes('下次对话'),
|
|
498
|
+
pickerText.slice(0, 300))
|
|
499
|
+
|
|
500
|
+
// Opening the list must not reflow the page, and it opens upward: the composer
|
|
501
|
+
// row sits at the bottom of the window.
|
|
502
|
+
const panel = nodesIn(opened, (node) => node.props?.style?.maxHeight === 320)
|
|
503
|
+
check('the list floats instead of pushing the page',
|
|
504
|
+
panel.length === 1 && panel[0]?.props?.style?.position === 'absolute', JSON.stringify(panel.map((node) => node.props.style)))
|
|
505
|
+
check('the list opens upward', String(panel[0]?.props?.style?.bottom ?? '').includes('100%'), JSON.stringify(panel[0]?.props?.style))
|
|
506
|
+
check('the chip anchors the floating list',
|
|
507
|
+
opened?.props?.style?.position === 'relative', JSON.stringify(opened?.props?.style))
|
|
508
|
+
|
|
509
|
+
// `@dsh` is the wire value for 仅内置; it must never be shown to the user, not
|
|
510
|
+
// in the list and not in the sentence that confirms a pick.
|
|
511
|
+
check('仅内置 is never printed as its wire value',
|
|
512
|
+
!openedText.includes('@dsh') && source.includes("if (name === DEFAULT_DSH) return '仅内置'"),
|
|
513
|
+
openedText)
|
|
514
|
+
|
|
515
|
+
// A kit inside 我的套装 is selectable once that group is opened, and picking it
|
|
516
|
+
// binds this conversation only.
|
|
517
|
+
const binds = []
|
|
518
|
+
fetchImpl = (url, init) => {
|
|
519
|
+
binds.push({ url, body: JSON.parse(String(init?.body ?? '{}')) })
|
|
520
|
+
return Promise.resolve({ ok: true, status: 200, json: async () => ({ ...fixture, bound: true, binding: 'other' }) })
|
|
521
|
+
}
|
|
522
|
+
const kitsOpen = expand(hero.component({
|
|
523
|
+
sessionId: 'session-new',
|
|
524
|
+
preview: { blank: true, data: fixture, open: true, groups: { presets: false, kits: true } },
|
|
525
|
+
}))
|
|
526
|
+
const kitRows = nodesIn(kitsOpen, (node) => node.props?.role === 'menuitem')
|
|
527
|
+
const otherRow = kitRows.find((node) => textOf(node).includes('另一套'))
|
|
528
|
+
check('my kits open to their rows', otherRow !== undefined, JSON.stringify(kitRows.map(textOf)))
|
|
529
|
+
otherRow?.props?.onClick?.()
|
|
530
|
+
check('picking a kit binds this conversation only',
|
|
531
|
+
binds.length === 1 && binds[0]?.body?.action === 'bind' && binds[0]?.body?.sessionId === 'session-new' && binds[0]?.body?.name === 'other',
|
|
532
|
+
JSON.stringify(binds))
|
|
533
|
+
check('picking a kit leaves the default alone',
|
|
534
|
+
binds[0]?.body?.base === undefined && binds[0]?.body?.apply === undefined && binds[0]?.body?.default === undefined,
|
|
535
|
+
JSON.stringify(binds[0]?.body))
|
|
536
|
+
|
|
537
|
+
// A bound conversation shows its own pick, not the default.
|
|
538
|
+
const bound = expand(hero.component({
|
|
539
|
+
sessionId: 'session-new',
|
|
540
|
+
preview: { blank: true, data: { ...fixture, bound: true, binding: 'other' } },
|
|
541
|
+
}))
|
|
542
|
+
check('a bound conversation names its own kit', textOf(bound).includes('另一套'), textOf(bound))
|
|
543
|
+
|
|
544
|
+
// A conversation that borrowed the default is not bound: the chip has to show
|
|
545
|
+
// the current default even though the store still holds the kit it was given
|
|
546
|
+
// when it was created.
|
|
547
|
+
const borrowed = expand(hero.component({
|
|
548
|
+
sessionId: 'session-new',
|
|
549
|
+
preview: { blank: true, data: { ...fixture, bound: false, binding: 'other' } },
|
|
550
|
+
}))
|
|
551
|
+
check('a borrowed kit does not count as this conversation\'s pick',
|
|
552
|
+
textOf(borrowed).includes('我的套') && !textOf(borrowed).includes('另一套'), textOf(borrowed))
|
|
553
|
+
check('the picker can ask the host to catch up with the default',
|
|
554
|
+
source.includes('follow: true') && source.includes("String(next.binding ?? '') !== wanted"),
|
|
555
|
+
'no catch-up request in the picker')
|
|
556
|
+
// The catch-up must hang on "this screen is blank", not on "this conversation
|
|
557
|
+
// never picked": a conversation created under an older default carries a real
|
|
558
|
+
// binding, and the pill used to show it as if it had been chosen here.
|
|
559
|
+
check('the catch-up is gated on a blank screen, not on the binding',
|
|
560
|
+
source.includes('if (blank === true)') && !source.includes('next.bound !== true &&'),
|
|
561
|
+
'the catch-up still waits for a missing binding')
|
|
562
|
+
|
|
563
|
+
// Once the conversation has a message the hero is gone, and so is the picker.
|
|
564
|
+
check('the picker leaves with the hero', hero.component({ sessionId: 'session-new', preview: { blank: false, data: fixture } }) === null)
|
|
565
|
+
} catch (error) {
|
|
566
|
+
failures.push(`hero picker render threw: ${error instanceof Error ? error.message : String(error)}`)
|
|
300
567
|
}
|
|
301
568
|
|
|
302
569
|
// ---- the chip stays a single button while closed ---------------------------
|
|
@@ -335,7 +602,21 @@ try {
|
|
|
335
602
|
// 全部 / 仅内置 sit in the same list as the kits, so they are rendered as cards
|
|
336
603
|
// with the same two actions; presets are read-only and carry no save-as button.
|
|
337
604
|
// The default section says out loud that it cannot reach an open conversation.
|
|
338
|
-
check('settings says the default only affects new conversations',
|
|
605
|
+
check('settings says the default only affects new conversations',
|
|
606
|
+
panelText.includes('只影响新建对话') && panelText.includes('当前对话在「装备栏」切换'), panelText.slice(0, 400))
|
|
607
|
+
// The title and its caveat are one thought; the note sits beside the heading
|
|
608
|
+
// instead of becoming a paragraph of its own above the cards.
|
|
609
|
+
check('the default heading carries its caveat on the same line',
|
|
610
|
+
panelText.includes('新对话默认套装只影响新建对话'), panelText.slice(0, 400))
|
|
611
|
+
// One text node, not two spans: the second span's own 0.6 opacity multiplied
|
|
612
|
+
// with the heading's 0.6, so the same line came out in two shades.
|
|
613
|
+
const splitCaveat = nodesIn(panel, (node) => textOf(node) === '只影响新建对话')
|
|
614
|
+
check('the caveat is not a separately styled node', splitCaveat.length === 0,
|
|
615
|
+
`${splitCaveat.length} · 只影响新建对话`)
|
|
616
|
+
// 全部 / 仅内置 are the only two cards that had no number; they price their own
|
|
617
|
+
// tool tables, which is also what the header menu's rows now show.
|
|
618
|
+
check('the base cards state their tool tokens',
|
|
619
|
+
panelText.includes('≈2.4k tokens') && panelText.includes('≈1.6k tokens'), panelText.slice(0, 500))
|
|
339
620
|
const copyButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '复制切换命令')
|
|
340
621
|
const defaultButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '设为默认')
|
|
341
622
|
// 2 defaults + 3 presets + 2 user kits — every card carries both actions.
|
|
@@ -347,6 +628,16 @@ try {
|
|
|
347
628
|
const deleteButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '删除')
|
|
348
629
|
check('only my kits can be deleted', deleteButtons.length === 2, `${deleteButtons.length} · 删除@${panelText.indexOf('删除')} · ${panelText.slice(0, 200)}`)
|
|
349
630
|
|
|
631
|
+
// A page that edits defaults has no business quoting what a switch would cost
|
|
632
|
+
// *this* conversation — that number belongs to the conversation's own menu.
|
|
633
|
+
check('settings does not price a switch', !panelText.includes('切换一次大概多贵') && !panelText.includes('未命中 ¥/百万'), panelText.slice(0, 600))
|
|
634
|
+
check('settings explains the numbers are the last live conversation\'s',
|
|
635
|
+
panelText.includes('设置页不绑定具体会话,上面的数字来自最近活跃的那个对话'), panelText.slice(0, 300))
|
|
636
|
+
check('settings says where a single conversation is switched',
|
|
637
|
+
panelText.includes('当前对话在「装备栏」切换'), panelText.slice(0, 300))
|
|
638
|
+
check('settings names the other two places a kit can be chosen',
|
|
639
|
+
panelText.includes('新建时也可选套装'), panelText.slice(0, 300))
|
|
640
|
+
|
|
350
641
|
// Presets start collapsed, and while collapsed they behave like 我的套装: the
|
|
351
642
|
// one that is the default stays visible so the page still answers "a new
|
|
352
643
|
// conversation starts with this".
|
|
@@ -412,12 +703,14 @@ try {
|
|
|
412
703
|
applyError = error
|
|
413
704
|
}
|
|
414
705
|
check('apply survives a missing slot', applyError === undefined, String(applyError))
|
|
415
|
-
check('the remaining
|
|
416
|
-
keptRegistrations.length ===
|
|
706
|
+
check('the remaining surfaces still register',
|
|
707
|
+
keptRegistrations.length === 2
|
|
708
|
+
&& keptRegistrations.some((entry) => entry.name === 'settings.section')
|
|
709
|
+
&& keptRegistrations.some((entry) => entry.name === 'conversation.input.right'),
|
|
417
710
|
JSON.stringify(keptRegistrations.map((entry) => entry.name)))
|
|
418
711
|
|
|
419
712
|
if (failures.length === 0) {
|
|
420
|
-
console.log('ok: client bundle loads, renders the new header menu (groups, check marks, tooltips, footer), and
|
|
713
|
+
console.log('ok: client bundle loads, renders the new-conversation picker, the header menu (groups, check marks, tooltips, footer), and the settings panel')
|
|
421
714
|
process.exit(0)
|
|
422
715
|
}
|
|
423
716
|
console.log(`FAILED (${failures.length}):`)
|
package/scripts/selftest.mjs
CHANGED
|
@@ -118,6 +118,20 @@ function makeCtx() {
|
|
|
118
118
|
built.commands = commands
|
|
119
119
|
built.routes = routes
|
|
120
120
|
built.sessions = { get: (sessionId) => sessions.get(sessionId) }
|
|
121
|
+
// The token meter publishes the prompt size as a session projection. The fake
|
|
122
|
+
// host answers from `projectionTokens`, so a test can move the number.
|
|
123
|
+
built.projectionTokens = { value: undefined }
|
|
124
|
+
built.sessionProjections = {
|
|
125
|
+
snapshot(session) {
|
|
126
|
+
const value = built.projectionTokens.value
|
|
127
|
+
return {
|
|
128
|
+
asOfSeq: 0,
|
|
129
|
+
values: value === undefined
|
|
130
|
+
? {}
|
|
131
|
+
: { contextPressure: { pressureTokens: value, projectedTokens: value, contextWindow: 128000 } },
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
}
|
|
121
135
|
built.sessionTable = sessions
|
|
122
136
|
built.agentTable = agents
|
|
123
137
|
return built
|
|
@@ -148,6 +162,7 @@ function childFor(dependencies, built, commands, routes) {
|
|
|
148
162
|
// The plugin injects `sessions` optionally; a fake host without it must still
|
|
149
163
|
// work (that is the 0.x path), so this mirrors the real `optional` contract.
|
|
150
164
|
if (dependencies.includes('sessions')) child.sessions = built.sessions
|
|
165
|
+
if (dependencies.includes('sessionProjections')) child.sessionProjections = built.sessionProjections
|
|
151
166
|
return child
|
|
152
167
|
}
|
|
153
168
|
|
|
@@ -982,6 +997,141 @@ process.env.USERPROFILE = savedProfile
|
|
|
982
997
|
process.env.HOME = savedHome
|
|
983
998
|
rmSync(legacyHome, { recursive: true, force: true })
|
|
984
999
|
|
|
1000
|
+
// ---- the client is told how big the prompt is -------------------------------
|
|
1001
|
+
// A switch costs prompt size × the miss/hit price gap, so the host has to hand
|
|
1002
|
+
// over the token meter's own number — not a guess from the session file.
|
|
1003
|
+
const pricedAgent = makeAgent()
|
|
1004
|
+
sessionAgentOf.agent = pricedAgent
|
|
1005
|
+
const unsampled = await getState(pricedAgent.id)
|
|
1006
|
+
check('no sample yet means no context size', unsampled.body.context?.tokens === null, JSON.stringify(unsampled.body.context))
|
|
1007
|
+
ctx.projectionTokens.value = 150000
|
|
1008
|
+
const sampled = await getState(pricedAgent.id)
|
|
1009
|
+
check('the context size comes from contextPressure', sampled.body.context?.tokens === 150000, JSON.stringify(sampled.body.context))
|
|
1010
|
+
ctx.projectionTokens.value = undefined
|
|
1011
|
+
const meterGone = await getState(pricedAgent.id)
|
|
1012
|
+
check('a host without a token meter still answers', meterGone.body.context?.tokens === null, JSON.stringify(meterGone.body.context))
|
|
1013
|
+
|
|
1014
|
+
// ---- a blank conversation can choose its kit before it has an agent ---------
|
|
1015
|
+
// This is the free switch: no agent means no request prefix, so the choice is
|
|
1016
|
+
// stored as a binding and applied the moment the agent appears — before the
|
|
1017
|
+
// first prompt is built.
|
|
1018
|
+
const unknownPick = await post({ action: 'bind', sessionId: 'session-blank', name: 'pick-kit' })
|
|
1019
|
+
check('binding an unknown kit is refused', unknownPick.status === 404, `${unknownPick.status} ${unknownPick.body?.message ?? ''}`)
|
|
1020
|
+
|
|
1021
|
+
await ctx.registered.get('kit_create').execute(
|
|
1022
|
+
{ name: 'pick-kit', title: '新建用', builtins: ['read'], plugins: [] }, exec(agentB))
|
|
1023
|
+
const bound = await post({ action: 'bind', sessionId: 'session-blank', name: 'pick-kit' })
|
|
1024
|
+
check('a blank conversation can be bound to a kit',
|
|
1025
|
+
bound.status === 200 && bound.body?.ok === true && bound.body?.bound === true && bound.body?.binding === 'pick-kit',
|
|
1026
|
+
JSON.stringify({ status: bound.status, bound: bound.body?.bound, binding: bound.body?.binding }))
|
|
1027
|
+
|
|
1028
|
+
const beforeAgent = await getState('session-blank')
|
|
1029
|
+
check('the binding is stored before the agent exists',
|
|
1030
|
+
beforeAgent.body?.bound === true && beforeAgent.body?.binding === 'pick-kit',
|
|
1031
|
+
JSON.stringify({ bound: beforeAgent.body?.bound, binding: beforeAgent.body?.binding }))
|
|
1032
|
+
|
|
1033
|
+
// The first request is assembled after this, so the pick costs nothing.
|
|
1034
|
+
const pickAgent = makeAgent(undefined, { id: 'session-blank' })
|
|
1035
|
+
sessionAgentOf.agent = pickAgent
|
|
1036
|
+
for (const handler of ctx.handlers.get('agent/created') ?? []) handler(pickAgent)
|
|
1037
|
+
const afterAgent = await getState('session-blank')
|
|
1038
|
+
check('the pick applies as soon as the agent appears',
|
|
1039
|
+
afterAgent.body?.binding === 'pick-kit' && afterAgent.body?.selection?.kit === 'pick-kit',
|
|
1040
|
+
JSON.stringify({ binding: afterAgent.body?.binding, selection: afterAgent.body?.selection }))
|
|
1041
|
+
check('the picked kit is what the first request will see',
|
|
1042
|
+
keptOf(pickAgent).includes('read'), keptOf(pickAgent).join(','))
|
|
1043
|
+
// The welcome screen can already have an agent behind it, and the picker still
|
|
1044
|
+
// has to know the pick was a draft — otherwise the release never fires and the
|
|
1045
|
+
// draft is what the next visit shows.
|
|
1046
|
+
const liveDraft = await getState('session-blank')
|
|
1047
|
+
check('a live conversation still reports its draft',
|
|
1048
|
+
liveDraft.body?.live === true && liveDraft.body?.draft === true,
|
|
1049
|
+
JSON.stringify({ live: liveDraft.body?.live, draft: liveDraft.body?.draft }))
|
|
1050
|
+
|
|
1051
|
+
const cleared = await post({ action: 'bind', sessionId: 'session-blank', name: '' })
|
|
1052
|
+
check('binding 全部 clears the pick', cleared.status === 200 && cleared.body?.bound === true && cleared.body?.binding === '',
|
|
1053
|
+
JSON.stringify({ bound: cleared.body?.bound, binding: cleared.body?.binding }))
|
|
1054
|
+
const clearedState = await getState('session-blank')
|
|
1055
|
+
check('the cleared pick reads back as 全部', clearedState.body?.bound === true && clearedState.body?.binding === '',
|
|
1056
|
+
JSON.stringify({ bound: clearedState.body?.bound, binding: clearedState.body?.binding }))
|
|
1057
|
+
|
|
1058
|
+
// ---- a welcome-screen pick is a draft, not a decision ----------------------
|
|
1059
|
+
// Leaving the screen without sending anything must reset it, exactly like the
|
|
1060
|
+
// host resets its own mode; a completed turn promotes the draft for real.
|
|
1061
|
+
const draftBind = await post({ action: 'bind', sessionId: 'session-draft', name: 'pick-kit' })
|
|
1062
|
+
check('a welcome-screen pick is marked as a draft', draftBind.body?.draft === true, JSON.stringify(draftBind.body?.draft))
|
|
1063
|
+
// The marker has to be on disk, not in this process: a restart used to launder
|
|
1064
|
+
// a draft into a decision, so the next visit showed a kit the user never chose.
|
|
1065
|
+
const draftStore = () => JSON.parse(readFileSync(storePath, 'utf8'))
|
|
1066
|
+
check('the draft marker reaches the store',
|
|
1067
|
+
draftStore().drafts?.['session-draft'] === 'pick-kit',
|
|
1068
|
+
JSON.stringify(draftStore().drafts))
|
|
1069
|
+
const draftState = await getState('session-draft')
|
|
1070
|
+
check('the draft reads back on the same visit',
|
|
1071
|
+
draftState.body?.draft === true && draftState.body?.binding === 'pick-kit',
|
|
1072
|
+
JSON.stringify({ draft: draftState.body?.draft, binding: draftState.body?.binding }))
|
|
1073
|
+
|
|
1074
|
+
const released = await post({ action: 'release', sessionId: 'session-draft' })
|
|
1075
|
+
check('leaving the welcome screen drops the draft',
|
|
1076
|
+
released.body?.draft === false && released.body?.bound === false && released.body?.binding === '',
|
|
1077
|
+
JSON.stringify({ draft: released.body?.draft, bound: released.body?.bound, binding: released.body?.binding }))
|
|
1078
|
+
check('the released draft leaves nothing behind in the store',
|
|
1079
|
+
draftStore().drafts?.['session-draft'] === undefined && draftStore().assignments['session-draft'] === undefined,
|
|
1080
|
+
JSON.stringify({ drafts: draftStore().drafts, assignment: draftStore().assignments['session-draft'] }))
|
|
1081
|
+
const afterRelease = await getState('session-draft')
|
|
1082
|
+
check('the next visit starts at the default',
|
|
1083
|
+
afterRelease.body?.draft !== true && afterRelease.body?.bound === false,
|
|
1084
|
+
JSON.stringify({ draft: afterRelease.body?.draft, bound: afterRelease.body?.bound }))
|
|
1085
|
+
|
|
1086
|
+
await post({ action: 'bind', sessionId: 'session-draft', name: 'pick-kit' })
|
|
1087
|
+
for (const handler of ctx.handlers.get('session/event') ?? []) handler({ id: 'session-draft' }, { type: 'turn/end' })
|
|
1088
|
+
const promoted = await getState('session-draft')
|
|
1089
|
+
check('a completed turn promotes the draft into a real binding',
|
|
1090
|
+
promoted.body?.draft !== true && promoted.body?.binding === 'pick-kit',
|
|
1091
|
+
JSON.stringify({ draft: promoted.body?.draft, binding: promoted.body?.binding }))
|
|
1092
|
+
|
|
1093
|
+
await post({ action: 'bind', sessionId: 'session-draft-2', name: 'pick-kit' })
|
|
1094
|
+
for (const handler of ctx.handlers.get('session/disposed') ?? []) handler({ id: 'session-draft-2' })
|
|
1095
|
+
const disposed = await getState('session-draft-2')
|
|
1096
|
+
check('a disposed welcome screen takes its draft with it',
|
|
1097
|
+
disposed.body?.draft !== true, JSON.stringify(disposed.body?.draft))
|
|
1098
|
+
|
|
1099
|
+
// ---- a default is a loan, not a pick ---------------------------------------
|
|
1100
|
+
// Creating a conversation while a default is set used to write that default into
|
|
1101
|
+
// the conversation's binding as if the user had chosen it. After the default
|
|
1102
|
+
// moved, the welcome screen kept showing the old kit under a 「默认」 label.
|
|
1103
|
+
const loanAgent = makeAgent(undefined, { id: 'session-loan' })
|
|
1104
|
+
fireCreated(loanAgent)
|
|
1105
|
+
const loaned = await getState('session-loan')
|
|
1106
|
+
check('a conversation that borrowed the default does not count as bound',
|
|
1107
|
+
loaned.body?.bound === false && loaned.body?.binding === 'coding',
|
|
1108
|
+
JSON.stringify({ bound: loaned.body?.bound, binding: loaned.body?.binding }))
|
|
1109
|
+
check('the loan is marked as such in the store',
|
|
1110
|
+
draftStore().auto?.['session-loan'] === true && draftStore().drafts?.['session-loan'] === undefined,
|
|
1111
|
+
JSON.stringify({ auto: draftStore().auto, drafts: draftStore().drafts }))
|
|
1112
|
+
|
|
1113
|
+
// Changing the default still touches no conversation...
|
|
1114
|
+
await ctx.registered.get('kit_set_default').execute({ name: 'pick-kit' }, exec(loanAgent))
|
|
1115
|
+
const stillLoaned = await getState('session-loan')
|
|
1116
|
+
check('a new default leaves a borrowed conversation on its old kit',
|
|
1117
|
+
stillLoaned.body?.binding === 'coding', JSON.stringify(stillLoaned.body?.binding))
|
|
1118
|
+
|
|
1119
|
+
// ...the welcome screen catches it up instead, and the catch-up is still a loan.
|
|
1120
|
+
const caughtUp = await post({ action: 'bind', sessionId: 'session-loan', name: 'pick-kit', follow: true })
|
|
1121
|
+
check('the follow-up moves a borrowed conversation to the new default',
|
|
1122
|
+
caughtUp.body?.binding === 'pick-kit' && caughtUp.body?.bound === false && caughtUp.body?.draft === false,
|
|
1123
|
+
JSON.stringify({ binding: caughtUp.body?.binding, bound: caughtUp.body?.bound, draft: caughtUp.body?.draft }))
|
|
1124
|
+
check('a follow-up writes no draft',
|
|
1125
|
+
draftStore().drafts?.['session-loan'] === undefined && draftStore().auto?.['session-loan'] === true,
|
|
1126
|
+
JSON.stringify({ drafts: draftStore().drafts, auto: draftStore().auto }))
|
|
1127
|
+
|
|
1128
|
+
// The first turn turns the loan into this conversation's own kit.
|
|
1129
|
+
for (const handler of ctx.handlers.get('session/event') ?? []) handler({ id: 'session-loan' }, { type: 'turn/end' })
|
|
1130
|
+
const settled = await getState('session-loan')
|
|
1131
|
+
check('the first turn pins the borrowed kit',
|
|
1132
|
+
settled.body?.bound === true && settled.body?.binding === 'pick-kit' && draftStore().auto?.['session-loan'] === undefined,
|
|
1133
|
+
JSON.stringify({ bound: settled.body?.bound, binding: settled.body?.binding, auto: draftStore().auto }))
|
|
1134
|
+
|
|
985
1135
|
// ---- report ----------------------------------------------------------------
|
|
986
1136
|
rmSync(scratch, { recursive: true, force: true })
|
|
987
1137
|
if (failures.length === 0) {
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Is there a host service that maps a session id to its live agent?
|
|
3
|
-
*/
|
|
4
|
-
import { readFileSync, readdirSync } from 'node:fs'
|
|
5
|
-
|
|
6
|
-
const BASE = 'C:/Users/JungleYXu/AppData/Local/Programs/DeepSeek Harness/resources/app.asar/dsh/node_modules/@deepseek-ai'
|
|
7
|
-
const packages = readdirSync(BASE)
|
|
8
|
-
const pattern = /agentFor|bySession|sessionAgent|agentOf|resolveAgent/g
|
|
9
|
-
|
|
10
|
-
for (const name of packages) {
|
|
11
|
-
if (!name.startsWith('dsh-')) continue
|
|
12
|
-
let text
|
|
13
|
-
try {
|
|
14
|
-
text = readFileSync(`${BASE}/${name}/lib/index.js`, 'utf8')
|
|
15
|
-
} catch {
|
|
16
|
-
continue
|
|
17
|
-
}
|
|
18
|
-
const hits = [...new Set([...text.matchAll(pattern)].map((m) => m[0]))]
|
|
19
|
-
if (hits.length === 0) continue
|
|
20
|
-
const index = text.search(pattern)
|
|
21
|
-
console.log(`\n### ${name} :: ${hits.join(', ')}`)
|
|
22
|
-
console.log(text.slice(Math.max(0, index - 200), index + 260).replace(/\s+/g, ' '))
|
|
23
|
-
}
|
|
24
|
-
|
package/scripts/agent-probe.mjs
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* How does host code get a session id from an agent object?
|
|
3
|
-
*/
|
|
4
|
-
import { readFileSync } from 'node:fs'
|
|
5
|
-
|
|
6
|
-
const BASE = 'C:/Users/JungleYXu/AppData/Local/Programs/DeepSeek Harness/resources/app.asar/dsh/node_modules/@deepseek-ai'
|
|
7
|
-
const files = [
|
|
8
|
-
'dsh-tools/lib/index.js',
|
|
9
|
-
'dsh-agent/lib/index.js',
|
|
10
|
-
'dsh-session/lib/index.js',
|
|
11
|
-
'dsh-subagent/lib/index.js',
|
|
12
|
-
]
|
|
13
|
-
|
|
14
|
-
for (const rel of files) {
|
|
15
|
-
let text
|
|
16
|
-
try {
|
|
17
|
-
text = readFileSync(`${BASE}/${rel}`, 'utf8')
|
|
18
|
-
} catch (error) {
|
|
19
|
-
console.log(`skip ${rel}: ${error.code}`)
|
|
20
|
-
continue
|
|
21
|
-
}
|
|
22
|
-
const hits = [...text.matchAll(/agent\.session[A-Za-z]*/g)].map((m) => m[0])
|
|
23
|
-
const unique = [...new Set(hits)]
|
|
24
|
-
console.log(`${rel} -> ${unique.join(', ') || '(none)'}`)
|
|
25
|
-
}
|