dsh-brief-sidebar 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dsh.plugin.json +1 -1
- package/lib/client.js +333 -70
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +78 -28
- package/lib/index.mjs.map +1 -1
- package/lib/types/client/locales.d.ts +13 -0
- package/lib/types/client/summary/DeliverablesSection.d.ts +3 -2
- package/lib/types/client/summary/FoldToggle.d.ts +27 -0
- package/lib/types/client/summary/skill-usage.d.ts +12 -0
- package/lib/types/projection/keys.d.ts +11 -4
- package/lib/types/projection/skill-usage-fold.d.ts +45 -19
- package/package.json +1 -1
- package/src/client/locales.ts +89 -9
- package/src/client/summary/DeliverablesSection.tsx +21 -47
- package/src/client/summary/FoldToggle.tsx +114 -0
- package/src/client/summary/MemoryRecallSection.tsx +79 -2
- package/src/client/summary/SkillUsageSection.tsx +51 -20
- package/src/client/summary/skill-usage.ts +40 -1
- package/src/projection/keys.ts +11 -4
- package/src/projection/register.ts +12 -2
- package/src/projection/skill-usage-fold.ts +108 -41
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two group chrome pieces the summary sections share: a counted group
|
|
3
|
+
* title, and the toggle that folds a group away.
|
|
4
|
+
*
|
|
5
|
+
* Extracted because the deliverables section (本回合 / 更早改动) and the recall
|
|
6
|
+
* section (活跃召回 / 其余历史召回) now fold a history group the same way. Two
|
|
7
|
+
* private copies of the same button would drift the first time either section
|
|
8
|
+
* touches padding — and the whole point of the split is that a reader can tell
|
|
9
|
+
* "what is live now" from "what the session also contains" without learning a
|
|
10
|
+
* different layout per section.
|
|
11
|
+
*
|
|
12
|
+
* Colours come from `--dsw-alias-*` tokens only (skin contract, enforced by
|
|
13
|
+
* `tests/purity.spec.ts`).
|
|
14
|
+
*/
|
|
15
|
+
import { createElement } from 'react'
|
|
16
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
17
|
+
|
|
18
|
+
/** Section-level title under the section header: quieter, same left gutter. */
|
|
19
|
+
const GROUP_HEADER_STYLE: CSSProperties = {
|
|
20
|
+
padding: '8px 12px 0',
|
|
21
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
22
|
+
fontSize: 11,
|
|
23
|
+
fontWeight: 600,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The fold switch: full-width hit area, tertiary text, chevron at the end. */
|
|
27
|
+
const TOGGLE_STYLE: CSSProperties = {
|
|
28
|
+
background: 'none',
|
|
29
|
+
border: 'none',
|
|
30
|
+
font: 'inherit',
|
|
31
|
+
fontSize: 12,
|
|
32
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
33
|
+
cursor: 'pointer',
|
|
34
|
+
display: 'flex',
|
|
35
|
+
alignItems: 'center',
|
|
36
|
+
gap: 4,
|
|
37
|
+
padding: '6px 12px',
|
|
38
|
+
textAlign: 'left',
|
|
39
|
+
width: 'calc(100% - 24px)',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The note beside a folded group (e.g. how many the capped list left out). */
|
|
43
|
+
const CAPTION_STYLE: CSSProperties = {
|
|
44
|
+
padding: '0 12px 6px',
|
|
45
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
46
|
+
fontSize: 11,
|
|
47
|
+
lineHeight: '16px',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CHEVRON_STYLE = (open: boolean): CSSProperties => ({
|
|
51
|
+
flexShrink: 0,
|
|
52
|
+
transition: 'transform 0.16s',
|
|
53
|
+
transform: open ? 'rotate(180deg)' : 'none',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A counted group title. Renders nothing when the group is empty — an empty
|
|
58
|
+
* "活跃召回 · 0" line would ask the reader to explain a heading rather than read
|
|
59
|
+
* a fact.
|
|
60
|
+
* @param props - already-interpolated text, plus an optional DOM marker.
|
|
61
|
+
*/
|
|
62
|
+
export function GroupHeader(props: { text: string; marker?: string }): ReactNode {
|
|
63
|
+
if (props.text.length === 0) return null
|
|
64
|
+
return createElement(
|
|
65
|
+
'div',
|
|
66
|
+
{
|
|
67
|
+
style: GROUP_HEADER_STYLE,
|
|
68
|
+
'data-dsh-brief-sidebar': props.marker ?? 'group-header',
|
|
69
|
+
},
|
|
70
|
+
props.text,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The fold switch. `marker` is what tests (and the browser probe) click, so it
|
|
76
|
+
* belongs to the component rather than to either caller's markup.
|
|
77
|
+
* @param props - label, current state, DOM marker, click handler.
|
|
78
|
+
*/
|
|
79
|
+
export function FoldToggle(props: {
|
|
80
|
+
label: string
|
|
81
|
+
open: boolean
|
|
82
|
+
marker: string
|
|
83
|
+
onToggle(): void
|
|
84
|
+
}): ReactNode {
|
|
85
|
+
const { label, open, marker } = props
|
|
86
|
+
return createElement(
|
|
87
|
+
'button',
|
|
88
|
+
{
|
|
89
|
+
type: 'button',
|
|
90
|
+
'aria-expanded': open,
|
|
91
|
+
style: TOGGLE_STYLE,
|
|
92
|
+
'data-dsh-brief-sidebar': marker,
|
|
93
|
+
onClick: () => { props.onToggle() },
|
|
94
|
+
},
|
|
95
|
+
label,
|
|
96
|
+
createElement(
|
|
97
|
+
'svg',
|
|
98
|
+
{ width: 10, height: 10, viewBox: '0 0 16 16', 'aria-hidden': true, style: CHEVRON_STYLE(open) },
|
|
99
|
+
createElement('path', {
|
|
100
|
+
d: 'M4 6l4 4 4-4',
|
|
101
|
+
fill: 'none',
|
|
102
|
+
stroke: 'currentColor',
|
|
103
|
+
strokeWidth: 1.5,
|
|
104
|
+
strokeLinecap: 'round',
|
|
105
|
+
strokeLinejoin: 'round',
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A quiet caption under a group, used for the "the list is capped" note. */
|
|
112
|
+
export function GroupCaption(props: { text: string; marker: string }): ReactNode {
|
|
113
|
+
return createElement('div', { style: CAPTION_STYLE, 'data-dsh-brief-sidebar': props.marker }, props.text)
|
|
114
|
+
}
|
|
@@ -53,6 +53,7 @@ import { resolveShowMemoryRecall } from '../scope-face'
|
|
|
53
53
|
import { useConfigFlag } from './use-config-flag'
|
|
54
54
|
import { useProjectionValue } from '../use-projection'
|
|
55
55
|
import { expandCount } from '../locales'
|
|
56
|
+
import { FoldToggle, GroupHeader } from './FoldToggle'
|
|
56
57
|
|
|
57
58
|
export interface MemoryRecallSectionProps {
|
|
58
59
|
/** The DSH locale lookup, passed down from `apply`. */
|
|
@@ -309,6 +310,7 @@ function RecallRow(props: {
|
|
|
309
310
|
item: MemoryRecallItem
|
|
310
311
|
selected: boolean
|
|
311
312
|
stale: boolean
|
|
313
|
+
wasSuperseded: boolean
|
|
312
314
|
pending: boolean
|
|
313
315
|
armed: boolean
|
|
314
316
|
mark: number | undefined
|
|
@@ -320,7 +322,7 @@ function RecallRow(props: {
|
|
|
320
322
|
onEnter(el: HTMLElement): void
|
|
321
323
|
onLeave(): void
|
|
322
324
|
}): ReactNode {
|
|
323
|
-
const { item, selected, stale, mark, error, t } = props
|
|
325
|
+
const { item, selected, stale, wasSuperseded, mark, error, t } = props
|
|
324
326
|
const scenario = item.scenario === undefined ? undefined : item.scenario.join(' · ')
|
|
325
327
|
const markText = mark === undefined
|
|
326
328
|
? undefined
|
|
@@ -361,6 +363,13 @@ function RecallRow(props: {
|
|
|
361
363
|
scenario,
|
|
362
364
|
),
|
|
363
365
|
staleText !== undefined && createElement('span', { style: MARK_STYLE }, staleText),
|
|
366
|
+
// “正文已被摘要取代”是行自己的事实,得写在行上:否则展开历史组后,
|
|
367
|
+
// 读者只能靠位置猜哪一行不在上下文里。
|
|
368
|
+
wasSuperseded && createElement(
|
|
369
|
+
'span',
|
|
370
|
+
{ style: TYPE_STYLE, 'data-dsh-brief-sidebar': 'recall-superseded-tag' },
|
|
371
|
+
t('recall.supersededTag'),
|
|
372
|
+
),
|
|
364
373
|
markText !== undefined && createElement('span', { style: MARK_STYLE }, markText),
|
|
365
374
|
error !== undefined && createElement(
|
|
366
375
|
'span',
|
|
@@ -408,6 +417,8 @@ export function MemoryRecallSection(props: MemoryRecallSectionProps): ReactNode
|
|
|
408
417
|
// Flipped once by a 404: the composition's prime-memory cannot take marks,
|
|
409
418
|
// so the section goes back to being the plain read-only list it was.
|
|
410
419
|
const [writeAbsent, setWriteAbsent] = useState(false)
|
|
420
|
+
// 历史召回组默认折叠:它们已不在上下文里,但仍属于“本会话见过什么”的一部分。
|
|
421
|
+
const [historyOpen, setHistoryOpen] = useState(false)
|
|
411
422
|
|
|
412
423
|
const show = useConfigFlag(ctx, resolveShowMemoryRecall)
|
|
413
424
|
const view: MemoryRecallView | undefined = readRecall(
|
|
@@ -493,8 +504,11 @@ export function MemoryRecallSection(props: MemoryRecallSectionProps): ReactNode
|
|
|
493
504
|
// A `superseded` row is different — its text is no longer in context — so it is
|
|
494
505
|
// hidden, and the footer reports the count so nothing disappears silently.
|
|
495
506
|
const isStale = (item: MemoryRecallItem): boolean => item.retired === true || staleIds.includes(item.id)
|
|
507
|
+
// 两组按“正文还在不在上下文”分:活跃 = 未取代(或被退场的例外,裁决凭证必须留下);
|
|
508
|
+
// 其余历史 = 被摘要取代且未被裁决退场的,它们收进开关后面而不是只报个计数。
|
|
496
509
|
const rows = view.items.filter(item => item.superseded !== true || item.retired === true)
|
|
497
|
-
const
|
|
510
|
+
const historyRows = view.items.filter(item => item.superseded === true && item.retired !== true)
|
|
511
|
+
const hidden = historyRows.length
|
|
498
512
|
|
|
499
513
|
if (rows.length === 0) {
|
|
500
514
|
return createElement(
|
|
@@ -510,6 +524,34 @@ export function MemoryRecallSection(props: MemoryRecallSectionProps): ReactNode
|
|
|
510
524
|
},
|
|
511
525
|
hidden > 0 ? expandCount(t('recall.supersededHidden'), hidden) : t('recall.empty'),
|
|
512
526
|
),
|
|
527
|
+
// 空态下历史组仍要能展开:否则“全都被取代”的会话永远读不到自己召回过什么。
|
|
528
|
+
historyRows.length > 0 && createElement(FoldToggle, {
|
|
529
|
+
label: expandCount(t('recall.historyGroup'), historyRows.length),
|
|
530
|
+
open: historyOpen,
|
|
531
|
+
marker: 'recall-history-toggle',
|
|
532
|
+
onToggle: () => { setHistoryOpen(current => !current) },
|
|
533
|
+
}),
|
|
534
|
+
historyOpen && createElement(
|
|
535
|
+
'ul',
|
|
536
|
+
{ style: LIST_STYLE },
|
|
537
|
+
historyRows.map(item => createElement(RecallRow, {
|
|
538
|
+
key: item.id,
|
|
539
|
+
item,
|
|
540
|
+
selected: false,
|
|
541
|
+
stale: isStale(item),
|
|
542
|
+
wasSuperseded: true,
|
|
543
|
+
pending: false,
|
|
544
|
+
armed: false,
|
|
545
|
+
mark: undefined,
|
|
546
|
+
error: undefined,
|
|
547
|
+
rating: false,
|
|
548
|
+
t,
|
|
549
|
+
onSelect: () => {},
|
|
550
|
+
onScore: () => {},
|
|
551
|
+
onEnter: () => {},
|
|
552
|
+
onLeave: () => {},
|
|
553
|
+
})),
|
|
554
|
+
),
|
|
513
555
|
)
|
|
514
556
|
}
|
|
515
557
|
|
|
@@ -518,6 +560,11 @@ export function MemoryRecallSection(props: MemoryRecallSectionProps): ReactNode
|
|
|
518
560
|
{ style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'memory-recall-section' },
|
|
519
561
|
createElement('div', { style: HEADER_STYLE }, t('section.memoryRecall')),
|
|
520
562
|
rating && createElement('div', { style: HINT_STYLE }, `${t('recall.rateHint')}${t('recall.strikeHint')}`),
|
|
563
|
+
// 活跃组计数只说“仍在上下文”的那些,注入总数归页脚,两个数各说各的。
|
|
564
|
+
rows.length > 0 && createElement(GroupHeader, {
|
|
565
|
+
text: expandCount(t('recall.activeGroup'), rows.length),
|
|
566
|
+
marker: 'recall-active-group',
|
|
567
|
+
}),
|
|
521
568
|
createElement(
|
|
522
569
|
'ul',
|
|
523
570
|
{ style: LIST_STYLE },
|
|
@@ -526,6 +573,7 @@ export function MemoryRecallSection(props: MemoryRecallSectionProps): ReactNode
|
|
|
526
573
|
item,
|
|
527
574
|
selected: selectedId === item.id,
|
|
528
575
|
stale: isStale(item),
|
|
576
|
+
wasSuperseded: item.superseded === true,
|
|
529
577
|
pending: pendingId === item.id,
|
|
530
578
|
armed: armed && selectedId === item.id,
|
|
531
579
|
mark: marks[item.id] ?? item.rating,
|
|
@@ -538,6 +586,35 @@ export function MemoryRecallSection(props: MemoryRecallSectionProps): ReactNode
|
|
|
538
586
|
onLeave,
|
|
539
587
|
})),
|
|
540
588
|
),
|
|
589
|
+
historyRows.length > 0 && createElement(FoldToggle, {
|
|
590
|
+
label: expandCount(t('recall.historyGroup'), historyRows.length),
|
|
591
|
+
open: historyOpen,
|
|
592
|
+
marker: 'recall-history-toggle',
|
|
593
|
+
onToggle: () => { setHistoryOpen(current => !current) },
|
|
594
|
+
}),
|
|
595
|
+
historyOpen && createElement(
|
|
596
|
+
'ul',
|
|
597
|
+
{ style: LIST_STYLE },
|
|
598
|
+
historyRows.map(item => createElement(RecallRow, {
|
|
599
|
+
key: item.id,
|
|
600
|
+
item,
|
|
601
|
+
// 历史行不开评分:退场与相关度都是对“还在上下文里的那条”的裁决,
|
|
602
|
+
// 正文已被取代的行再标一次也不会回到上下文,只会让人以为还能救。
|
|
603
|
+
selected: false,
|
|
604
|
+
stale: isStale(item),
|
|
605
|
+
wasSuperseded: true,
|
|
606
|
+
pending: false,
|
|
607
|
+
armed: false,
|
|
608
|
+
mark: undefined,
|
|
609
|
+
error: undefined,
|
|
610
|
+
rating: false,
|
|
611
|
+
t,
|
|
612
|
+
onSelect: () => {},
|
|
613
|
+
onScore: () => {},
|
|
614
|
+
onEnter: () => {},
|
|
615
|
+
onLeave: () => {},
|
|
616
|
+
})),
|
|
617
|
+
),
|
|
541
618
|
createElement(
|
|
542
619
|
'div',
|
|
543
620
|
{ style: FOOTER_STYLE },
|
|
@@ -3,15 +3,23 @@
|
|
|
3
3
|
* part of the summary tab.
|
|
4
4
|
*
|
|
5
5
|
* Where `MemoryRecallSection` answers "what memories did the model see", this
|
|
6
|
-
* answers "which instruction packs
|
|
7
|
-
*
|
|
6
|
+
* answers "which instruction packs entered the context, and which ones were on
|
|
7
|
+
* the menu". Two groups, deliberately not merged:
|
|
8
|
+
*
|
|
9
|
+
* - **已引用** — the skill's instructions reached the conversation, either
|
|
10
|
+
* because the model called the `skill` tool or because the user invoked it
|
|
11
|
+
* directly (the host then injects `<skill_content>` and tells the model not
|
|
12
|
+
* to call the tool — reading only the tool would miss over half of those
|
|
13
|
+
* sessions, measured on 250 real ones).
|
|
14
|
+
* - **本会话可用** — the session's current catalog. Available is NOT used: the
|
|
15
|
+
* model may never touch any of these, so it is collapsed behind a counted
|
|
16
|
+
* toggle and never mixed into the reference rows.
|
|
8
17
|
*
|
|
9
18
|
* Like the deliverables section this is an OPTIONAL capability on both sides:
|
|
10
19
|
* no projection registry (or a host half that never registered the unit) means
|
|
11
20
|
* the client reads capability absence and the section hides. No settings switch
|
|
12
21
|
* gates it either — the list is a read-only fact about the session, not a
|
|
13
|
-
* preference
|
|
14
|
-
* nothing to leave on.
|
|
22
|
+
* preference.
|
|
15
23
|
*
|
|
16
24
|
* Two contracts this row honours, both learned on the recall section:
|
|
17
25
|
* - **no native `title` attribute** — it paints an unskinable tooltip that
|
|
@@ -19,11 +27,14 @@
|
|
|
19
27
|
* - **colours from `--dsw-alias-*` tokens only** (skin contract).
|
|
20
28
|
*/
|
|
21
29
|
import { createElement } from 'react'
|
|
30
|
+
import { useState } from 'react'
|
|
22
31
|
import type { CSSProperties, ReactNode } from 'react'
|
|
23
32
|
import type { Context } from '@deepseek-ai/cordis'
|
|
24
33
|
import { SKILL_USAGE_KEY } from '../../projection/keys'
|
|
25
34
|
import { useProjectionValue } from '../use-projection'
|
|
26
|
-
import { expandPair, readSkillUsage } from './skill-usage'
|
|
35
|
+
import { availableExcept, expandPair, readSkillUsage } from './skill-usage'
|
|
36
|
+
import { GroupCaption, GroupHeader, FoldToggle } from './FoldToggle'
|
|
37
|
+
import { expandCount } from '../locales'
|
|
27
38
|
|
|
28
39
|
export interface SkillUsageSectionProps {
|
|
29
40
|
/** The DSH locale lookup, passed down from `apply`. */
|
|
@@ -111,32 +122,32 @@ const EMPTY_STYLE: CSSProperties = {
|
|
|
111
122
|
*/
|
|
112
123
|
export function SkillUsageSection(props: SkillUsageSectionProps): ReactNode {
|
|
113
124
|
const { t, ctx, scope } = props
|
|
125
|
+
const [availableOpen, setAvailableOpen] = useState(false)
|
|
114
126
|
|
|
115
127
|
const view = readSkillUsage(useProjectionValue(ctx, scope?.sessionId, SKILL_USAGE_KEY))
|
|
116
128
|
|
|
117
129
|
// Capability absent — the optional unit never registered. Hide, don't nag.
|
|
118
130
|
if (view === undefined) return null
|
|
119
131
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
{ style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'skill-usage-section' },
|
|
124
|
-
createElement('div', { style: HEADER_STYLE }, t('section.skillUsage')),
|
|
125
|
-
createElement(
|
|
126
|
-
'div',
|
|
127
|
-
{ style: EMPTY_STYLE },
|
|
128
|
-
createElement('div', { style: { marginBottom: 6, color: 'var(--dsw-alias-label-secondary)' } }, t('skillUsage.empty')),
|
|
129
|
-
createElement('div', null, t('skillUsage.emptyHint')),
|
|
130
|
-
),
|
|
131
|
-
)
|
|
132
|
-
}
|
|
133
|
-
|
|
132
|
+
const referenced = view.session.map(entry => entry.name)
|
|
133
|
+
// 可用组只列未被引用的那些:同一个技能不该占两行。
|
|
134
|
+
const rest = availableExcept(view.available, referenced)
|
|
134
135
|
const latestNames = view.latest?.names ?? []
|
|
135
136
|
|
|
136
137
|
return createElement(
|
|
137
138
|
'div',
|
|
138
139
|
{ style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'skill-usage-section' },
|
|
139
140
|
createElement('div', { style: HEADER_STYLE }, t('section.skillUsage')),
|
|
141
|
+
view.session.length === 0 && createElement(
|
|
142
|
+
'div',
|
|
143
|
+
{ style: EMPTY_STYLE },
|
|
144
|
+
createElement('div', { style: { marginBottom: 6, color: 'var(--dsw-alias-label-secondary)' } }, t('skillUsage.empty')),
|
|
145
|
+
createElement('div', null, t('skillUsage.emptyHint')),
|
|
146
|
+
),
|
|
147
|
+
view.session.length > 0 && createElement(GroupHeader, {
|
|
148
|
+
text: expandCount(t('skillUsage.invokedGroup'), view.session.length),
|
|
149
|
+
marker: 'skill-usage-invoked-group',
|
|
150
|
+
}),
|
|
140
151
|
createElement(
|
|
141
152
|
'ul',
|
|
142
153
|
{ style: LIST_STYLE },
|
|
@@ -148,7 +159,27 @@ export function SkillUsageSection(props: SkillUsageSectionProps): ReactNode {
|
|
|
148
159
|
latestNames.includes(entry.name) && createElement('span', { style: TAG_STYLE }, t('skillUsage.latestTag')),
|
|
149
160
|
)),
|
|
150
161
|
),
|
|
162
|
+
rest.length > 0 && createElement(FoldToggle, {
|
|
163
|
+
label: expandCount(t('skillUsage.availableGroup'), rest.length),
|
|
164
|
+
open: availableOpen,
|
|
165
|
+
marker: 'skill-usage-available-toggle',
|
|
166
|
+
onToggle: () => { setAvailableOpen(current => !current) },
|
|
167
|
+
}),
|
|
168
|
+
// 清单有上限:开关行说收了多少个名字,附注说这一帧真发了多少。
|
|
169
|
+
rest.length > 0 && view.availableCount > view.available.length && createElement(GroupCaption, {
|
|
170
|
+
text: expandCount(t('skillUsage.availableCapped'), view.availableCount),
|
|
171
|
+
marker: 'skill-usage-available-capped',
|
|
172
|
+
}),
|
|
173
|
+
availableOpen && createElement(
|
|
174
|
+
'ul',
|
|
175
|
+
{ style: LIST_STYLE },
|
|
176
|
+
rest.map(name => createElement(
|
|
177
|
+
'li',
|
|
178
|
+
{ key: name, style: ROW_STYLE, 'data-dsh-brief-sidebar': 'skill-usage-available-row' },
|
|
179
|
+
createElement('span', { style: NAME_STYLE }, name),
|
|
180
|
+
)),
|
|
181
|
+
),
|
|
151
182
|
// 列表有上限,页脚说真数:加载次数与去重后的技能数各归各。
|
|
152
|
-
createElement('div', { style: FOOTER_STYLE }, expandPair(t('skillUsage.count'), view.callTotal, view.sessionTotal)),
|
|
183
|
+
view.session.length > 0 && createElement('div', { style: FOOTER_STYLE }, expandPair(t('skillUsage.count'), view.callTotal, view.sessionTotal)),
|
|
153
184
|
)
|
|
154
185
|
}
|
|
@@ -49,6 +49,19 @@ function readLatest(value: unknown): SkillUsageView['latest'] {
|
|
|
49
49
|
return { turn: raw['turn'], names: raw['names'].filter((n): n is string => typeof n === 'string' && n.length > 0) }
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The available-skill list of a frame, or `[]` when the frame carries none.
|
|
54
|
+
*
|
|
55
|
+
* Absent-by-age is the important case: a host half older than this reader
|
|
56
|
+
* publishes no `available` field at all, and that must NOT void the whole view
|
|
57
|
+
* — the session's references are still true. Only a drifted (non-array) value
|
|
58
|
+
* reads as "nothing known", never as "nothing available".
|
|
59
|
+
*/
|
|
60
|
+
function readAvailable(value: unknown): readonly string[] {
|
|
61
|
+
if (!Array.isArray(value)) return []
|
|
62
|
+
return value.filter((n): n is string => typeof n === 'string' && n.length > 0)
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
/**
|
|
53
66
|
* Narrow a raw projection value into a skill-usage view.
|
|
54
67
|
*
|
|
@@ -67,7 +80,33 @@ export function readSkillUsage(value: unknown): SkillUsageView | undefined {
|
|
|
67
80
|
const narrowed = readEntry(entry)
|
|
68
81
|
if (narrowed !== undefined) session.push(narrowed)
|
|
69
82
|
}
|
|
70
|
-
|
|
83
|
+
const available = readAvailable(raw['available'])
|
|
84
|
+
// The count rides with the list; without a list there is no count to trust.
|
|
85
|
+
const availableCount = available.length === 0 ? 0 : (isCounter(raw['availableCount']) ? raw['availableCount'] : available.length)
|
|
86
|
+
return {
|
|
87
|
+
latest: readLatest(raw['latest']),
|
|
88
|
+
session,
|
|
89
|
+
sessionTotal: raw['sessionTotal'],
|
|
90
|
+
callTotal: raw['callTotal'],
|
|
91
|
+
available,
|
|
92
|
+
availableCount,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The available list minus the names already shown as referenced.
|
|
98
|
+
*
|
|
99
|
+
* One skill must not occupy a row in both groups: the reference row is the
|
|
100
|
+
* stronger fact, and a duplicate under "available" would read as "the model
|
|
101
|
+
* had a choice it didn't make".
|
|
102
|
+
*
|
|
103
|
+
* @param available - the session's current catalog names, in catalog order.
|
|
104
|
+
* @param referenced - names the fold counted as loaded or invoked.
|
|
105
|
+
* @returns the remaining catalog names, order preserved.
|
|
106
|
+
*/
|
|
107
|
+
export function availableExcept(available: readonly string[], referenced: readonly string[]): string[] {
|
|
108
|
+
const seen = new Set(referenced)
|
|
109
|
+
return available.filter(name => !seen.has(name))
|
|
71
110
|
}
|
|
72
111
|
|
|
73
112
|
/**
|
package/src/projection/keys.ts
CHANGED
|
@@ -47,12 +47,19 @@ export interface SkillUsageEntryView {
|
|
|
47
47
|
|
|
48
48
|
/** Client view of the skill-usage fold (JSON-safe). */
|
|
49
49
|
export interface SkillUsageView {
|
|
50
|
-
/** Skills
|
|
50
|
+
/** Skills referenced in the latest turn; `null` before the first `turn/start`. */
|
|
51
51
|
readonly latest: { readonly turn: number; readonly names: readonly string[] } | null
|
|
52
|
-
/** Distinct
|
|
52
|
+
/** Distinct referenced skills, most recently used first (capped). */
|
|
53
53
|
readonly session: readonly SkillUsageEntryView[]
|
|
54
|
-
/** Distinct
|
|
54
|
+
/** Distinct referenced skills session-wide — may exceed `session.length`. */
|
|
55
55
|
readonly sessionTotal: number
|
|
56
|
-
/** Successful
|
|
56
|
+
/** Successful references session-wide, both channels included. */
|
|
57
57
|
readonly callTotal: number
|
|
58
|
+
/**
|
|
59
|
+
* The skills this session was OFFERED, per its latest catalog frame (capped).
|
|
60
|
+
* Available is not referenced: the model may never use any of these.
|
|
61
|
+
*/
|
|
62
|
+
readonly available: readonly string[]
|
|
63
|
+
/** How many entries that frame published — may exceed `available.length`. */
|
|
64
|
+
readonly availableCount: number
|
|
58
65
|
}
|
|
@@ -110,6 +110,11 @@ const skillStateSchema = z.object({
|
|
|
110
110
|
items: z.array(z.object({ name: z.string(), count: z.number(), lastTurn: z.number() })),
|
|
111
111
|
sessionTotal: z.number(),
|
|
112
112
|
callTotal: z.number(),
|
|
113
|
+
// Defaults, not requirements: a state persisted by the pre-0.9.2 shape has no
|
|
114
|
+
// catalog fields, and replaying it must not fail (the session's references
|
|
115
|
+
// are still true; only the "available" knowledge starts empty).
|
|
116
|
+
catalog: z.array(z.string()).default([]),
|
|
117
|
+
catalogCount: z.number().default(0),
|
|
113
118
|
})
|
|
114
119
|
|
|
115
120
|
/** Zod schema of the skill-usage wire view. */
|
|
@@ -118,6 +123,8 @@ const skillViewSchema = z.object({
|
|
|
118
123
|
session: z.array(z.object({ name: z.string(), count: z.number(), lastTurn: z.number() })),
|
|
119
124
|
sessionTotal: z.number(),
|
|
120
125
|
callTotal: z.number(),
|
|
126
|
+
available: z.array(z.string()),
|
|
127
|
+
availableCount: z.number(),
|
|
121
128
|
})
|
|
122
129
|
|
|
123
130
|
/** Same reference-stability memo as above, for the skill-usage unit. */
|
|
@@ -134,6 +141,8 @@ function skillView(state: SkillUsageState): SkillUsageView {
|
|
|
134
141
|
session: state.items.slice(0, SKILL_CAPS.view),
|
|
135
142
|
sessionTotal: state.sessionTotal,
|
|
136
143
|
callTotal: state.callTotal,
|
|
144
|
+
available: state.catalog.slice(0, SKILL_CAPS.available),
|
|
145
|
+
availableCount: state.catalogCount,
|
|
137
146
|
}
|
|
138
147
|
skillViewMemo.set(state, next)
|
|
139
148
|
return next
|
|
@@ -155,8 +164,9 @@ export function registerSkillUsageProjection(ctx: Context): void {
|
|
|
155
164
|
init: () => initSkillUsageState(),
|
|
156
165
|
apply: applySkillUsageEvent,
|
|
157
166
|
wire: { viewSchema: skillViewSchema, view: skillView },
|
|
158
|
-
//
|
|
159
|
-
|
|
167
|
+
// 1 = the shape gained the catalog fields (`catalog`/`catalogCount`); the
|
|
168
|
+
// state schema defaults them so a version-0 state still replays.
|
|
169
|
+
stateVersion: 1,
|
|
160
170
|
})
|
|
161
171
|
})
|
|
162
172
|
}
|