dsh-brief-sidebar 0.9.2 → 0.9.4
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 +1025 -441
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +303 -11
- package/lib/index.mjs.map +1 -1
- package/lib/types/client/locales.d.ts +13 -0
- package/lib/types/client/summary/ReferenceSection.d.ts +24 -0
- package/lib/types/client/summary/SearchRefSection.d.ts +21 -0
- package/lib/types/client/summary/SkillUsageSection.d.ts +6 -0
- package/lib/types/client/summary/search-refs.d.ts +38 -0
- package/lib/types/projection/keys.d.ts +42 -0
- package/lib/types/projection/register.d.ts +6 -0
- package/lib/types/projection/search-ref-fold.d.ts +116 -0
- package/package.json +1 -1
- package/src/client/SummaryTab.tsx +12 -18
- package/src/client/locales.ts +117 -0
- package/src/client/summary/ReferenceSection.tsx +211 -0
- package/src/client/summary/SearchRefSection.tsx +241 -0
- package/src/client/summary/SkillUsageSection.tsx +8 -2
- package/src/client/summary/search-refs.ts +116 -0
- package/src/index.ts +4 -1
- package/src/projection/keys.ts +43 -0
- package/src/projection/register.ts +82 -2
- package/src/projection/search-ref-fold.ts +258 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The references section: 技能与记忆并列成两个可左右切换的页签。
|
|
3
|
+
*
|
|
4
|
+
* Why a tab strip instead of three stacked sections: the two lists answer the
|
|
5
|
+
* same question — "what entered this session's context" — from two different
|
|
6
|
+
* providers (this plugin's own skill fold, and prime-memory's recall/slots).
|
|
7
|
+
* Stacked, they pushed the board and the produced files off screen on a narrow
|
|
8
|
+
* sidebar; side by side, each gets the full width when the reader actually
|
|
9
|
+
* wants it.
|
|
10
|
+
*
|
|
11
|
+
* Two decisions worth knowing when editing this file:
|
|
12
|
+
*
|
|
13
|
+
* 1. **The inactive pane stays mounted and is hidden with `display:none`.**
|
|
14
|
+
* The recall pane carries real interaction state (selected row, pending
|
|
15
|
+
* rating, the expanded history group); unmounting it would silently reset
|
|
16
|
+
* what the human just did every time they glanced at the skills tab. The
|
|
17
|
+
* cost is that a hidden pane keeps re-rendering on projection frames —
|
|
18
|
+
* bounded, because the whole tab still renders nothing while `visible` is
|
|
19
|
+
* false (see `SummaryTab`).
|
|
20
|
+
* 2. **The parent repeats its children's gates.** A section that hides itself
|
|
21
|
+
* leaves the tab strip pointing at an empty pane, so the availability
|
|
22
|
+
* question is answered here with the SAME readers and resolvers the children
|
|
23
|
+
* use. `tests/reference-section.spec.tsx` pins both sides against the same
|
|
24
|
+
* fixtures — if a child gate ever drifts from this one, that test goes red
|
|
25
|
+
* rather than the UI growing a dead tab.
|
|
26
|
+
*
|
|
27
|
+
* Colours come from `--dsw-alias-*` tokens only (skin contract).
|
|
28
|
+
*/
|
|
29
|
+
import { createElement } from 'react'
|
|
30
|
+
import { useCallback, useState } from 'react'
|
|
31
|
+
import type { CSSProperties, KeyboardEvent, ReactNode } from 'react'
|
|
32
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
33
|
+
import { SKILL_USAGE_KEY } from '../../projection/keys'
|
|
34
|
+
import { useProjectionValue } from '../use-projection'
|
|
35
|
+
import { readSkillUsage } from './skill-usage'
|
|
36
|
+
import { SEARCH_REFS_KEY, readSearchRefs } from './search-refs'
|
|
37
|
+
import { MEMORY_RECALL_KEY, readRecall } from './memory-recall'
|
|
38
|
+
import { MEMORY_SLOTS_KEY, readSlots } from './memory-slots'
|
|
39
|
+
import { resolveShowMemoryRecall, resolveShowMemorySlots } from '../scope-face'
|
|
40
|
+
import { useConfigFlag } from './use-config-flag'
|
|
41
|
+
import { SkillUsageSection } from './SkillUsageSection'
|
|
42
|
+
import { SearchRefSection } from './SearchRefSection'
|
|
43
|
+
import { MemoryRecallSection } from './MemoryRecallSection'
|
|
44
|
+
import { MemorySlotsSection } from './MemorySlotsSection'
|
|
45
|
+
import type { FeedbackFetch } from './recall-feedback'
|
|
46
|
+
|
|
47
|
+
export interface ReferenceSectionProps {
|
|
48
|
+
/** The DSH locale lookup, passed down from `apply`. */
|
|
49
|
+
t: (key: string) => string
|
|
50
|
+
/** The client root context (the tab body's only way to reach services). */
|
|
51
|
+
ctx?: Context
|
|
52
|
+
/** The session this tab is scoped to. */
|
|
53
|
+
scope?: { readonly sessionId?: string }
|
|
54
|
+
/** The rating transport, handed straight to the recall pane. */
|
|
55
|
+
fetchImpl?: FeedbackFetch
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Which pane is showing. Three values, no fourth tab by design. */
|
|
59
|
+
export type ReferenceTab = 'skills' | 'memory' | 'search'
|
|
60
|
+
|
|
61
|
+
const SECTION_STYLE: CSSProperties = {
|
|
62
|
+
flexShrink: 0,
|
|
63
|
+
borderBottom: '1px solid var(--dsw-alias-border-secondary)',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const HEADER_STYLE: CSSProperties = {
|
|
67
|
+
padding: '10px 12px 0',
|
|
68
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
69
|
+
fontSize: 12,
|
|
70
|
+
fontWeight: 600,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The tab strip: flush-left, same gutter as section headers. */
|
|
74
|
+
const TABS_STYLE: CSSProperties = {
|
|
75
|
+
display: 'flex',
|
|
76
|
+
gap: 4,
|
|
77
|
+
padding: '6px 12px 0',
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One tab. Selected gets a hairline of secondary label; never a colour literal. */
|
|
81
|
+
const TAB_STYLE: CSSProperties = {
|
|
82
|
+
background: 'none',
|
|
83
|
+
border: 'none',
|
|
84
|
+
borderBottom: '1px solid transparent',
|
|
85
|
+
font: 'inherit',
|
|
86
|
+
fontSize: 12,
|
|
87
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
88
|
+
cursor: 'pointer',
|
|
89
|
+
padding: '4px 8px 6px',
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const TAB_ACTIVE_STYLE: CSSProperties = {
|
|
93
|
+
...TAB_STYLE,
|
|
94
|
+
borderBottom: '1px solid var(--dsw-alias-label-secondary)',
|
|
95
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
96
|
+
fontWeight: 600,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ORDER: readonly ReferenceTab[] = ['skills', 'memory', 'search']
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The references section. All hooks run before any early return, so the tab
|
|
103
|
+
* strip can disappear (no capability at all) without ever changing hook order.
|
|
104
|
+
*
|
|
105
|
+
* @param props - translation, client context, session scope, rating transport.
|
|
106
|
+
*/
|
|
107
|
+
export function ReferenceSection(props: ReferenceSectionProps): ReactNode {
|
|
108
|
+
const { t, ctx, scope, fetchImpl } = props
|
|
109
|
+
const [tab, setTab] = useState<ReferenceTab>('skills')
|
|
110
|
+
|
|
111
|
+
// The same three reads the children make, used only to decide whether the
|
|
112
|
+
// strip has anything to point at.
|
|
113
|
+
const skillsLive = readSkillUsage(useProjectionValue(ctx, scope?.sessionId, SKILL_USAGE_KEY)) !== undefined
|
|
114
|
+
const searchLive = readSearchRefs(useProjectionValue(ctx, scope?.sessionId, SEARCH_REFS_KEY)) !== undefined
|
|
115
|
+
const recallFrame = readRecall(useProjectionValue(ctx, scope?.sessionId, MEMORY_RECALL_KEY))
|
|
116
|
+
const slotsFrame = readSlots(useProjectionValue(ctx, scope?.sessionId, MEMORY_SLOTS_KEY))
|
|
117
|
+
const recallOn = useConfigFlag(ctx, resolveShowMemoryRecall) !== false
|
|
118
|
+
const slotsOn = useConfigFlag(ctx, resolveShowMemorySlots) === true
|
|
119
|
+
const memoryLive = (recallFrame !== undefined && recallOn) || (slotsFrame !== undefined && slotsOn)
|
|
120
|
+
const live: Record<ReferenceTab, boolean> = { skills: skillsLive, memory: memoryLive, search: searchLive }
|
|
121
|
+
// 记住的选择可能因能力变化而消失(例如关掉记忆开关):退到第一个可用页签。
|
|
122
|
+
const active: ReferenceTab = live[tab] ? tab : ORDER.find(id => live[id]) ?? 'skills'
|
|
123
|
+
|
|
124
|
+
const onTabsKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>): void => {
|
|
125
|
+
// 左右方向键在“可用页签”里循环:能力缺席的页签不该被键盘走到。
|
|
126
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
|
127
|
+
event.preventDefault()
|
|
128
|
+
const shown = ORDER.filter(id => live[id])
|
|
129
|
+
if (shown.length < 2) return
|
|
130
|
+
const at = shown.indexOf(active)
|
|
131
|
+
const step = event.key === 'ArrowRight' ? 1 : shown.length - 1
|
|
132
|
+
setTab(shown[(at + step) % shown.length])
|
|
133
|
+
}, [live, active])
|
|
134
|
+
|
|
135
|
+
// Nothing to reference — no provider in the composition, or both switched off.
|
|
136
|
+
if (!skillsLive && !memoryLive && !searchLive) return null
|
|
137
|
+
|
|
138
|
+
const tabButton = (id: ReferenceTab, label: string): ReactNode => createElement(
|
|
139
|
+
'button',
|
|
140
|
+
{
|
|
141
|
+
key: id,
|
|
142
|
+
type: 'button',
|
|
143
|
+
role: 'tab',
|
|
144
|
+
id: `dsh-brief-reference-tab-${id}`,
|
|
145
|
+
'aria-selected': active === id,
|
|
146
|
+
'aria-controls': `dsh-brief-reference-pane-${id}`,
|
|
147
|
+
style: active === id ? TAB_ACTIVE_STYLE : TAB_STYLE,
|
|
148
|
+
'data-dsh-brief-sidebar': `reference-tab-${id}`,
|
|
149
|
+
onClick: () => { setTab(id) },
|
|
150
|
+
},
|
|
151
|
+
label,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return createElement(
|
|
155
|
+
'div',
|
|
156
|
+
{ style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'references-section' },
|
|
157
|
+
createElement('div', { style: HEADER_STYLE }, t('section.references')),
|
|
158
|
+
createElement(
|
|
159
|
+
'div',
|
|
160
|
+
{
|
|
161
|
+
role: 'tablist',
|
|
162
|
+
'aria-label': t('section.references'),
|
|
163
|
+
style: TABS_STYLE,
|
|
164
|
+
onKeyDown: onTabsKeyDown,
|
|
165
|
+
'data-dsh-brief-sidebar': 'reference-tabs',
|
|
166
|
+
},
|
|
167
|
+
skillsLive && tabButton('skills', t('references.tabSkills')),
|
|
168
|
+
// 每个页签各自条件化:只有一侧能力时不该留个点了就空的页签。
|
|
169
|
+
memoryLive && tabButton('memory', t('references.tabMemory')),
|
|
170
|
+
searchLive && tabButton('search', t('references.tabSearch')),
|
|
171
|
+
),
|
|
172
|
+
skillsLive && createElement(
|
|
173
|
+
'div',
|
|
174
|
+
{
|
|
175
|
+
role: 'tabpanel',
|
|
176
|
+
id: 'dsh-brief-reference-pane-skills',
|
|
177
|
+
'aria-labelledby': 'dsh-brief-reference-tab-skills',
|
|
178
|
+
hidden: active !== 'skills',
|
|
179
|
+
style: active === 'skills' ? undefined : { display: 'none' },
|
|
180
|
+
'data-dsh-brief-sidebar': 'reference-pane-skills',
|
|
181
|
+
},
|
|
182
|
+
createElement(SkillUsageSection, { t, ctx, scope, hideHeader: true }),
|
|
183
|
+
),
|
|
184
|
+
memoryLive && createElement(
|
|
185
|
+
'div',
|
|
186
|
+
{
|
|
187
|
+
role: 'tabpanel',
|
|
188
|
+
id: 'dsh-brief-reference-pane-memory',
|
|
189
|
+
'aria-labelledby': 'dsh-brief-reference-tab-memory',
|
|
190
|
+
hidden: active !== 'memory',
|
|
191
|
+
style: active === 'memory' ? undefined : { display: 'none' },
|
|
192
|
+
'data-dsh-brief-sidebar': 'reference-pane-memory',
|
|
193
|
+
},
|
|
194
|
+
// 两条记忆链各有自己的标题:召回与常驻槽位是不同事实,合成一节会糊掉。
|
|
195
|
+
createElement(MemoryRecallSection, { t, ctx, scope, fetchImpl }),
|
|
196
|
+
createElement(MemorySlotsSection, { t, ctx, scope }),
|
|
197
|
+
),
|
|
198
|
+
searchLive && createElement(
|
|
199
|
+
'div',
|
|
200
|
+
{
|
|
201
|
+
role: 'tabpanel',
|
|
202
|
+
id: 'dsh-brief-reference-pane-search',
|
|
203
|
+
'aria-labelledby': 'dsh-brief-reference-tab-search',
|
|
204
|
+
hidden: active !== 'search',
|
|
205
|
+
style: active === 'search' ? undefined : { display: 'none' },
|
|
206
|
+
'data-dsh-brief-sidebar': 'reference-pane-search',
|
|
207
|
+
},
|
|
208
|
+
createElement(SearchRefSection, { t, ctx, scope, hideHeader: true }),
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The search-reference section: the `dshSummarySearchRefs` projection drawn as
|
|
3
|
+
* one tab of the references section.
|
|
4
|
+
*
|
|
5
|
+
* It answers "what did this session pull in from outside": the queries it ran
|
|
6
|
+
* through `advanced_search`, and the sources those searches returned. Two
|
|
7
|
+
* groups, because they are two different facts — a query says what was asked,
|
|
8
|
+
* a source says what came back, and an older `dsh-free-search` without
|
|
9
|
+
* `presentationMeta` produces queries with no sources at all. That degraded
|
|
10
|
+
* case is stated rather than hidden, because an empty source list would
|
|
11
|
+
* otherwise read as "the search found nothing".
|
|
12
|
+
*
|
|
13
|
+
* Optional on both sides: no registry, or a host half that never registered the
|
|
14
|
+
* unit, means the client reads capability absence and the section hides.
|
|
15
|
+
*
|
|
16
|
+
* Two contracts inherited from the recall section:
|
|
17
|
+
* - **no native `title` attribute** — the whole text rides the row or is left
|
|
18
|
+
* out; an unskinable tooltip would overlap the plugin's own surfaces;
|
|
19
|
+
* - **colours from `--dsw-alias-*` tokens only** (skin contract).
|
|
20
|
+
*
|
|
21
|
+
* Source links are untrusted external data, so only `http`/`https` become
|
|
22
|
+
* anchors (see `safeHref`) and they open with `rel="noreferrer noopener"`.
|
|
23
|
+
*/
|
|
24
|
+
import { createElement } from 'react'
|
|
25
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
26
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
27
|
+
import { SEARCH_REFS_KEY } from '../../projection/keys'
|
|
28
|
+
import { useProjectionValue } from '../use-projection'
|
|
29
|
+
import { GroupHeader } from './FoldToggle'
|
|
30
|
+
import { expandCount, expandTotal } from '../locales'
|
|
31
|
+
import { isLatestSource, readSearchRefs, safeHref } from './search-refs'
|
|
32
|
+
|
|
33
|
+
export interface SearchRefSectionProps {
|
|
34
|
+
/** The DSH locale lookup, passed down from `apply`. */
|
|
35
|
+
t: (key: string) => string
|
|
36
|
+
/** The client root context (the tab body's only way to reach services). */
|
|
37
|
+
ctx?: Context
|
|
38
|
+
/** The session this tab is scoped to. */
|
|
39
|
+
scope?: { readonly sessionId?: string }
|
|
40
|
+
/** Drop the section header (the references tab already names it). */
|
|
41
|
+
hideHeader?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SECTION_STYLE: CSSProperties = {
|
|
45
|
+
flexShrink: 0,
|
|
46
|
+
borderBottom: '1px solid var(--dsw-alias-border-secondary)',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const HEADER_STYLE: CSSProperties = {
|
|
50
|
+
padding: '10px 12px 0',
|
|
51
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
52
|
+
fontSize: 12,
|
|
53
|
+
fontWeight: 600,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const LIST_STYLE: CSSProperties = {
|
|
57
|
+
listStyle: 'none',
|
|
58
|
+
margin: 0,
|
|
59
|
+
padding: '6px 0',
|
|
60
|
+
display: 'flex',
|
|
61
|
+
flexDirection: 'column',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ROW_STYLE: CSSProperties = {
|
|
65
|
+
display: 'flex',
|
|
66
|
+
alignItems: 'flex-start',
|
|
67
|
+
gap: 8,
|
|
68
|
+
padding: '6px 12px',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const QUERY_STYLE: CSSProperties = {
|
|
72
|
+
minWidth: 0,
|
|
73
|
+
flex: '1 1 auto',
|
|
74
|
+
overflowWrap: 'anywhere',
|
|
75
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The source title, one line: the host and URL carry the identification. */
|
|
79
|
+
const TITLE_STYLE: CSSProperties = {
|
|
80
|
+
minWidth: 0,
|
|
81
|
+
flex: '1 1 auto',
|
|
82
|
+
display: '-webkit-box',
|
|
83
|
+
WebkitLineClamp: 2,
|
|
84
|
+
WebkitBoxOrient: 'vertical',
|
|
85
|
+
overflow: 'hidden',
|
|
86
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const SNIPPET_STYLE: CSSProperties = {
|
|
90
|
+
margin: 0,
|
|
91
|
+
minWidth: 0,
|
|
92
|
+
overflowWrap: 'anywhere',
|
|
93
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
94
|
+
fontSize: 11,
|
|
95
|
+
lineHeight: '16px',
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const META_STYLE: CSSProperties = {
|
|
99
|
+
flexShrink: 0,
|
|
100
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
101
|
+
fontSize: 11,
|
|
102
|
+
lineHeight: '18px',
|
|
103
|
+
whiteSpace: 'nowrap',
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The "本轮" pill: same form as the skill tag, tertiary emphasis. */
|
|
107
|
+
const TAG_STYLE: CSSProperties = {
|
|
108
|
+
flexShrink: 0,
|
|
109
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
110
|
+
border: '1px solid var(--dsw-alias-label-tertiary)',
|
|
111
|
+
borderRadius: 999,
|
|
112
|
+
padding: '0 6px',
|
|
113
|
+
fontSize: 11,
|
|
114
|
+
lineHeight: '16px',
|
|
115
|
+
whiteSpace: 'nowrap',
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const FOOTER_STYLE: CSSProperties = {
|
|
119
|
+
padding: '0 12px 10px',
|
|
120
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
121
|
+
fontSize: 11,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const EMPTY_STYLE: CSSProperties = {
|
|
125
|
+
margin: '0 auto',
|
|
126
|
+
padding: 24,
|
|
127
|
+
maxWidth: 460,
|
|
128
|
+
textAlign: 'center',
|
|
129
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
130
|
+
lineHeight: 1.7,
|
|
131
|
+
fontSize: 12,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The search-reference section. The subscription is unconditional (a plain
|
|
136
|
+
* listener, not IO); the view decides whether anything is drawn.
|
|
137
|
+
*
|
|
138
|
+
* @param props - translation, client context, session scope, header switch.
|
|
139
|
+
*/
|
|
140
|
+
export function SearchRefSection(props: SearchRefSectionProps): ReactNode {
|
|
141
|
+
const { t, ctx, scope, hideHeader = false } = props
|
|
142
|
+
|
|
143
|
+
const view = readSearchRefs(useProjectionValue(ctx, scope?.sessionId, SEARCH_REFS_KEY))
|
|
144
|
+
|
|
145
|
+
// Capability absent — the optional unit never registered. Hide, don't nag.
|
|
146
|
+
if (view === undefined) return null
|
|
147
|
+
|
|
148
|
+
if (view.queries.length === 0 && view.sources.length === 0) {
|
|
149
|
+
return createElement(
|
|
150
|
+
'div',
|
|
151
|
+
{ style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'search-refs-section' },
|
|
152
|
+
!hideHeader && createElement('div', { style: HEADER_STYLE }, t('section.searchRefs')),
|
|
153
|
+
createElement(
|
|
154
|
+
'div',
|
|
155
|
+
{ style: EMPTY_STYLE },
|
|
156
|
+
createElement('div', { style: { marginBottom: 6, color: 'var(--dsw-alias-label-secondary)' } }, t('searchRefs.empty')),
|
|
157
|
+
createElement('div', null, t('searchRefs.emptyHint')),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const body: ReactNode[] = []
|
|
163
|
+
|
|
164
|
+
if (view.queries.length > 0) {
|
|
165
|
+
body.push(createElement(GroupHeader, {
|
|
166
|
+
key: 'qh',
|
|
167
|
+
text: expandCount(t('searchRefs.queryGroup'), view.queries.length),
|
|
168
|
+
marker: 'search-refs-query-group',
|
|
169
|
+
}))
|
|
170
|
+
body.push(createElement(
|
|
171
|
+
'ul',
|
|
172
|
+
{ key: 'ql', style: LIST_STYLE },
|
|
173
|
+
view.queries.map(row => createElement(
|
|
174
|
+
'li',
|
|
175
|
+
{ key: `${row.turn}:${row.query}`, style: ROW_STYLE, 'data-dsh-brief-sidebar': 'search-refs-query' },
|
|
176
|
+
createElement('span', { style: QUERY_STYLE }, row.query),
|
|
177
|
+
createElement('span', { style: META_STYLE }, expandCount(t('searchRefs.hits'), row.hits)),
|
|
178
|
+
)),
|
|
179
|
+
))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (view.sources.length > 0) {
|
|
183
|
+
body.push(createElement(GroupHeader, {
|
|
184
|
+
key: 'sh',
|
|
185
|
+
text: expandCount(t('searchRefs.sourceGroup'), view.sources.length),
|
|
186
|
+
marker: 'search-refs-source-group',
|
|
187
|
+
}))
|
|
188
|
+
body.push(createElement(
|
|
189
|
+
'ul',
|
|
190
|
+
{ key: 'sl', style: LIST_STYLE },
|
|
191
|
+
view.sources.map(item => {
|
|
192
|
+
const href = safeHref(item.url)
|
|
193
|
+
const label = item.title ?? item.url
|
|
194
|
+
const link = href === undefined
|
|
195
|
+
// A non-web scheme (or a malformed URL) from a provider stays text:
|
|
196
|
+
// it must not become a clickable thing in the user's sidebar.
|
|
197
|
+
? createElement('span', { style: TITLE_STYLE }, label)
|
|
198
|
+
: createElement(
|
|
199
|
+
'a',
|
|
200
|
+
{ style: TITLE_STYLE, href, target: '_blank', rel: 'noreferrer noopener' },
|
|
201
|
+
label,
|
|
202
|
+
)
|
|
203
|
+
return createElement(
|
|
204
|
+
'li',
|
|
205
|
+
{ key: item.url, style: ROW_STYLE, 'data-dsh-brief-sidebar': 'search-refs-source' },
|
|
206
|
+
createElement(
|
|
207
|
+
'div',
|
|
208
|
+
{ style: { minWidth: 0, flex: '1 1 auto', display: 'flex', flexDirection: 'column', gap: 2 } },
|
|
209
|
+
link,
|
|
210
|
+
createElement('span', { style: META_STYLE }, item.host),
|
|
211
|
+
item.snippet !== undefined && createElement('p', { style: SNIPPET_STYLE }, item.snippet),
|
|
212
|
+
),
|
|
213
|
+
isLatestSource(view, item.url) && createElement('span', { style: TAG_STYLE }, t('searchRefs.latestTag')),
|
|
214
|
+
)
|
|
215
|
+
}),
|
|
216
|
+
))
|
|
217
|
+
} else {
|
|
218
|
+
// 有查询无来源:老版 dsh-free-search 不发 meta。说清楚,别让人以为搜了没结果。
|
|
219
|
+
body.push(createElement(
|
|
220
|
+
'div',
|
|
221
|
+
{ key: 'ns', style: FOOTER_STYLE, 'data-dsh-brief-sidebar': 'search-refs-no-sources' },
|
|
222
|
+
t('searchRefs.noSourcesHint'),
|
|
223
|
+
))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 列表有上限,页脚说真数:只有真数超过列表长度时才出现,免得两个数字打架。
|
|
227
|
+
if (view.queryTotal > view.queries.length || view.sourceTotal > view.sources.length) {
|
|
228
|
+
body.push(createElement(
|
|
229
|
+
'div',
|
|
230
|
+
{ key: 'cap', style: FOOTER_STYLE, 'data-dsh-brief-sidebar': 'search-refs-capped' },
|
|
231
|
+
expandTotal(t('searchRefs.capped'), Math.max(view.queryTotal, view.sourceTotal)),
|
|
232
|
+
))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return createElement(
|
|
236
|
+
'div',
|
|
237
|
+
{ style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'search-refs-section' },
|
|
238
|
+
!hideHeader && createElement('div', { style: HEADER_STYLE }, t('section.searchRefs')),
|
|
239
|
+
...body,
|
|
240
|
+
)
|
|
241
|
+
}
|
|
@@ -43,6 +43,12 @@ export interface SkillUsageSectionProps {
|
|
|
43
43
|
ctx?: Context
|
|
44
44
|
/** The session this tab is scoped to. */
|
|
45
45
|
scope?: { readonly sessionId?: string }
|
|
46
|
+
/**
|
|
47
|
+
* Drop the section header. The references tab already says “技能”, so
|
|
48
|
+
* repeating it as a heading inside the pane is noise; standalone renders
|
|
49
|
+
* (and every existing test) keep the default `false`.
|
|
50
|
+
*/
|
|
51
|
+
hideHeader?: boolean
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
const SECTION_STYLE: CSSProperties = {
|
|
@@ -121,7 +127,7 @@ const EMPTY_STYLE: CSSProperties = {
|
|
|
121
127
|
* @param props - translation, client context, session scope.
|
|
122
128
|
*/
|
|
123
129
|
export function SkillUsageSection(props: SkillUsageSectionProps): ReactNode {
|
|
124
|
-
const { t, ctx, scope } = props
|
|
130
|
+
const { t, ctx, scope, hideHeader = false } = props
|
|
125
131
|
const [availableOpen, setAvailableOpen] = useState(false)
|
|
126
132
|
|
|
127
133
|
const view = readSkillUsage(useProjectionValue(ctx, scope?.sessionId, SKILL_USAGE_KEY))
|
|
@@ -137,7 +143,7 @@ export function SkillUsageSection(props: SkillUsageSectionProps): ReactNode {
|
|
|
137
143
|
return createElement(
|
|
138
144
|
'div',
|
|
139
145
|
{ style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'skill-usage-section' },
|
|
140
|
-
createElement('div', { style: HEADER_STYLE }, t('section.skillUsage')),
|
|
146
|
+
!hideHeader && createElement('div', { style: HEADER_STYLE }, t('section.skillUsage')),
|
|
141
147
|
view.session.length === 0 && createElement(
|
|
142
148
|
'div',
|
|
143
149
|
{ style: EMPTY_STYLE },
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `dshSummarySearchRefs` projection — reading it.
|
|
3
|
+
*
|
|
4
|
+
* The unit is registered by THIS plugin's host half (key `dshSummarySearchRefs`):
|
|
5
|
+
* the queries this session ran through `advanced_search`, and the sources those
|
|
6
|
+
* searches brought back. It answers "what did the session pull in from outside"
|
|
7
|
+
* — where the skill tab says which instruction packs were loaded and the memory
|
|
8
|
+
* tab says which of the user's own knowledge was injected.
|
|
9
|
+
*
|
|
10
|
+
* Reading discipline, same as every other brief section:
|
|
11
|
+
* - `undefined` = the capability is ABSENT (no projection registry, or a frame
|
|
12
|
+
* drifted past repair) → the tab hides;
|
|
13
|
+
* - empty lists = the honest empty state (the session has not searched yet);
|
|
14
|
+
* - a malformed row is dropped, never thrown.
|
|
15
|
+
*
|
|
16
|
+
* The module is deliberately PURE — no React, no context, no IO.
|
|
17
|
+
*/
|
|
18
|
+
import { SEARCH_REFS_KEY } from '../../projection/keys'
|
|
19
|
+
import type { SearchRefItemView, SearchRefView, SearchQueryView } from '../../projection/keys'
|
|
20
|
+
import { urlKey } from '../../projection/search-ref-fold'
|
|
21
|
+
|
|
22
|
+
/** The key this plugin's client half subscribes to (re-exported for callers). */
|
|
23
|
+
export { SEARCH_REFS_KEY }
|
|
24
|
+
|
|
25
|
+
/** Whether a value is a non-negative safe integer (the view's counters). */
|
|
26
|
+
function isCounter(value: unknown): value is number {
|
|
27
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Narrow one source row; `undefined` = malformed, dropped. */
|
|
31
|
+
function readItem(value: unknown): SearchRefItemView | undefined {
|
|
32
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
33
|
+
const raw = value as Record<string, unknown>
|
|
34
|
+
if (typeof raw['url'] !== 'string' || raw['url'].length === 0) return undefined
|
|
35
|
+
if (typeof raw['host'] !== 'string') return undefined
|
|
36
|
+
if (typeof raw['query'] !== 'string') return undefined
|
|
37
|
+
if (!isCounter(raw['count']) || !isCounter(raw['lastTurn'])) return undefined
|
|
38
|
+
const item: { -readonly [K in keyof SearchRefItemView]: SearchRefItemView[K] } = {
|
|
39
|
+
url: raw['url'],
|
|
40
|
+
host: raw['host'],
|
|
41
|
+
query: raw['query'],
|
|
42
|
+
count: raw['count'],
|
|
43
|
+
lastTurn: raw['lastTurn'],
|
|
44
|
+
}
|
|
45
|
+
if (typeof raw['title'] === 'string' && raw['title'].length > 0) item.title = raw['title']
|
|
46
|
+
if (typeof raw['snippet'] === 'string' && raw['snippet'].length > 0) item.snippet = raw['snippet']
|
|
47
|
+
return item
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Narrow one query row; `undefined` = malformed, dropped. */
|
|
51
|
+
function readQuery(value: unknown): SearchQueryView | undefined {
|
|
52
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
53
|
+
const raw = value as Record<string, unknown>
|
|
54
|
+
if (typeof raw['query'] !== 'string' || raw['query'].length === 0) return undefined
|
|
55
|
+
if (!isCounter(raw['turn']) || !isCounter(raw['hits'])) return undefined
|
|
56
|
+
return { query: raw['query'], turn: raw['turn'], hits: raw['hits'] }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The latest-turn key set, or `null` when absent OR drifted (only a tag rides it). */
|
|
60
|
+
function readLatest(value: unknown): SearchRefView['latest'] {
|
|
61
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null
|
|
62
|
+
const raw = value as Record<string, unknown>
|
|
63
|
+
if (!isCounter(raw['turn']) || !Array.isArray(raw['urls'])) return null
|
|
64
|
+
return { turn: raw['turn'], urls: raw['urls'].filter((u): u is string => typeof u === 'string' && u.length > 0) }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Narrow a raw projection value into a search-reference view.
|
|
69
|
+
*
|
|
70
|
+
* @param value - the raw `unknown` snapshot of the `dshSummarySearchRefs` face.
|
|
71
|
+
* @returns the validated view, or `undefined` when the capability is absent.
|
|
72
|
+
*/
|
|
73
|
+
export function readSearchRefs(value: unknown): SearchRefView | undefined {
|
|
74
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
75
|
+
const raw = value as Record<string, unknown>
|
|
76
|
+
if (!Array.isArray(raw['queries']) || !Array.isArray(raw['sources'])) return undefined
|
|
77
|
+
if (!isCounter(raw['queryTotal']) || !isCounter(raw['sourceTotal'])) return undefined
|
|
78
|
+
const queries: SearchQueryView[] = []
|
|
79
|
+
for (const entry of raw['queries']) {
|
|
80
|
+
const narrowed = readQuery(entry)
|
|
81
|
+
if (narrowed !== undefined) queries.push(narrowed)
|
|
82
|
+
}
|
|
83
|
+
const sources: SearchRefItemView[] = []
|
|
84
|
+
for (const entry of raw['sources']) {
|
|
85
|
+
const narrowed = readItem(entry)
|
|
86
|
+
if (narrowed !== undefined) sources.push(narrowed)
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
latest: readLatest(raw['latest']),
|
|
90
|
+
queries,
|
|
91
|
+
sources,
|
|
92
|
+
queryTotal: raw['queryTotal'],
|
|
93
|
+
sourceTotal: raw['sourceTotal'],
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether one source row was surfaced by the latest turn's searches. */
|
|
98
|
+
export function isLatestSource(view: SearchRefView, url: string): boolean {
|
|
99
|
+
return view.latest?.urls.includes(urlKey(url)) === true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The href to render for a source URL, or `undefined` when it must not be a link.
|
|
104
|
+
*
|
|
105
|
+
* Search results are untrusted external data: a `javascript:` or `data:` URL in
|
|
106
|
+
* a provider response would otherwise become a clickable footgun in the user's
|
|
107
|
+
* own sidebar. Only web schemes get an anchor; everything else renders as text.
|
|
108
|
+
*/
|
|
109
|
+
export function safeHref(url: string): string | undefined {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = new URL(url)
|
|
112
|
+
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : undefined
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
*/
|
|
38
38
|
import type { Context } from '@deepseek-ai/cordis'
|
|
39
39
|
import z from '@deepseek-ai/schemastery'
|
|
40
|
-
import { registerDeliverablesProjection, registerSkillUsageProjection } from './projection/register'
|
|
40
|
+
import { registerDeliverablesProjection, registerSearchRefProjection, registerSkillUsageProjection } from './projection/register'
|
|
41
41
|
|
|
42
42
|
/** Profile row identity. */
|
|
43
43
|
export const name = 'dsh-brief-sidebar'
|
|
@@ -68,4 +68,7 @@ export const Config = z.object({
|
|
|
68
68
|
export function apply(ctx: Context): void {
|
|
69
69
|
registerDeliverablesProjection(ctx)
|
|
70
70
|
registerSkillUsageProjection(ctx)
|
|
71
|
+
// 搜索引用:本会话用 `advanced_search` 查了什么、取回了哪些来源。与技能一样
|
|
72
|
+
// 是可选贡献:没有 sessionProjections 服务的组合不注册也不影响本行加载。
|
|
73
|
+
registerSearchRefProjection(ctx)
|
|
71
74
|
}
|
package/src/projection/keys.ts
CHANGED
|
@@ -22,6 +22,13 @@ export const DELIVERABLES_KEY = 'dshSummaryDeliverables'
|
|
|
22
22
|
*/
|
|
23
23
|
export const SKILL_USAGE_KEY = 'dshSummarySkillUsage'
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The search-reference key (queries and returned sources of this session's
|
|
27
|
+
* `advanced_search` calls). Same rule as above: the name is ours, so no other
|
|
28
|
+
* host-side unit can answer for it by accident.
|
|
29
|
+
*/
|
|
30
|
+
export const SEARCH_REFS_KEY = 'dshSummarySearchRefs'
|
|
31
|
+
|
|
25
32
|
/** Client view of the produced-file fold (JSON-safe). */
|
|
26
33
|
export interface DeliverablesView {
|
|
27
34
|
/** Paths produced in the latest turn, first-seen order (capped). */
|
|
@@ -63,3 +70,39 @@ export interface SkillUsageView {
|
|
|
63
70
|
/** How many entries that frame published — may exceed `available.length`. */
|
|
64
71
|
readonly availableCount: number
|
|
65
72
|
}
|
|
73
|
+
|
|
74
|
+
/** One external source a search brought back (wire view). */
|
|
75
|
+
export interface SearchRefItemView {
|
|
76
|
+
/** The URL as published (host case and trailing slash normalised). */
|
|
77
|
+
readonly url: string
|
|
78
|
+
readonly host: string
|
|
79
|
+
readonly title?: string
|
|
80
|
+
/** Provider snippet, capped by the fold. */
|
|
81
|
+
readonly snippet?: string
|
|
82
|
+
/** The query that most recently surfaced it. */
|
|
83
|
+
readonly query: string
|
|
84
|
+
/** Settled searches that surfaced this source. */
|
|
85
|
+
readonly count: number
|
|
86
|
+
readonly lastTurn: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One query the session ran, with how many sources it returned. */
|
|
90
|
+
export interface SearchQueryView {
|
|
91
|
+
readonly query: string
|
|
92
|
+
readonly turn: number
|
|
93
|
+
readonly hits: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Client view of the search-reference fold (JSON-safe). */
|
|
97
|
+
export interface SearchRefView {
|
|
98
|
+
/** Normalised URL keys surfaced in the latest turn; `null` before the first turn. */
|
|
99
|
+
readonly latest: { readonly turn: number; readonly urls: readonly string[] } | null
|
|
100
|
+
/** Distinct queries, most recent first (capped). */
|
|
101
|
+
readonly queries: readonly SearchQueryView[]
|
|
102
|
+
/** Distinct sources, most recent first (capped). */
|
|
103
|
+
readonly sources: readonly SearchRefItemView[]
|
|
104
|
+
/** Distinct queries session-wide — may exceed `queries.length`. */
|
|
105
|
+
readonly queryTotal: number
|
|
106
|
+
/** Distinct sources session-wide — may exceed `sources.length`. */
|
|
107
|
+
readonly sourceTotal: number
|
|
108
|
+
}
|