@v587d/capital-generation 2.10.1 → 2.10.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.
@@ -5,13 +5,26 @@ const {
5
5
  SettingsSecretField,
6
6
  SettingsFormModel,
7
7
  SegmentedControl,
8
+ Pill,
8
9
  Switch,
10
+ Tag,
9
11
  Modal,
10
- writeClipboard,
11
12
  IconRightUpOutlineRegular,
12
13
  } = require('@deepseek-ai/dsh-client-ui-primitives')
13
14
  const { SELECTED_SKILL_CATALOG, SELECTED_SKILL_TAG_LABELS } = require('../selected-skills/catalog.js')
14
- const { SELECTED_SKILL_ROLES, SELECTED_SKILL_ROUTING } = require('../selected-skills/routing.js')
15
+ const {
16
+ SELECTED_SKILL_FILTER_ALL,
17
+ filterSelectedSkills,
18
+ selectedSkillFacetCounts,
19
+ selectedSkillPresetTotals,
20
+ selectedSkillRoles,
21
+ selectedSkillTraitOrder,
22
+ } = require('../selected-skills/filter.js')
23
+ const {
24
+ SELECTED_SKILL_PRESETS,
25
+ SELECTED_SKILL_PRESET_ORDER,
26
+ SELECTED_SKILL_ROUTING,
27
+ } = require('../selected-skills/routing.js')
15
28
 
16
29
  /**
17
30
  * Two different strings: `ENTRY_ID` is the settings namespace (= profile entry
@@ -51,6 +64,9 @@ const DOC_URLS = {
51
64
  /** Render order = array order; one list drives status, refs, drafts and event refresh. */
52
65
  const CREDENTIAL_FIELDS = ['fuyao', 'ftshare', 'anysearch', 'wind', 'paddleocr']
53
66
  const SELECTED_SKILL_FIELDS = SELECTED_SKILL_CATALOG.map(({ key }) => key)
67
+ // Stable orders: facet counts change with the other dimension, but chips must not jump under the cursor.
68
+ const SELECTED_SKILL_TRAITS = selectedSkillTraitOrder()
69
+ const SELECTED_SKILL_PRESET_TOTALS = selectedSkillPresetTotals()
54
70
  const DEFAULT_REFS = {
55
71
  fuyao: FUYAO_DEFAULT_REF,
56
72
  ftshare: FTSHARE_DEFAULT_REF,
@@ -81,16 +97,17 @@ const zh = {
81
97
  paddleocrLabel: 'PaddleOCR 文档解析 Token',
82
98
  paddleocrHint: '配置 Token 后自动解析 PDF、图片。保存后新自动模式会话生效。',
83
99
  selectedSkillsTitle: '精选 Skills',
84
- selectedSkillsModeLabel: '自动选择方法',
85
- selectedSkillsModeHint: '开启后按角色开放该角色全部方法,由模型按触发条件选择;下方勾选只在手动模式生效。',
86
- selectedSkillsModeShort: '自动',
87
- selectedSkillsFilterLabel: '按能力筛选',
88
- selectedSkillsEmpty: '没有匹配这个标签的 Skill。',
89
- selectedSkillsRoles: '角色:',
90
- selectedSkillsWhen: '何时使用:',
91
- selectedSkillsCopyName: '复制 /技能名',
92
- selectedSkillsCopied: '已复制',
93
- selectedSkillsDetail: '详情',
100
+ selectedSkillsModeLabel: '智能装载 Skills',
101
+ selectedSkillsModeHint: '开启后,由大模型根据用户提问,智能装载合适的精选「Skills」。',
102
+ selectedSkillsFilterLabel: '筛选精选 Skills',
103
+ selectedSkillsTraitGroup: 'Skill 特点',
104
+ selectedSkillsPresetGroup: '适用预设',
105
+ selectedSkillsAll: '全部',
106
+ selectedSkillsEmpty: '没有符合当前筛选条件的 Skill。',
107
+ selectedSkillsWhen: '何时使用',
108
+ selectedSkillsKeywords: '关键词',
109
+ selectedSkillsOpenDetail: '查看 Skill 原文',
110
+ selectedSkillsPresetUnavailable: '该预设不加载精选 Skills',
94
111
  selectedSkillsDetailClose: '关闭',
95
112
  selectedSkillsDocLoading: '原文读取中…',
96
113
  selectedSkillsDocFailed: '原文读不到:这份快照没随包落地,或暂时读不出来。',
@@ -127,16 +144,17 @@ const en = {
127
144
  paddleocrLabel: 'PaddleOCR document token',
128
145
  paddleocrHint: 'Configure the token to parse PDFs and images automatically. Takes effect in new Auto mode sessions.',
129
146
  selectedSkillsTitle: 'Selected Skills',
130
- selectedSkillsModeLabel: 'Pick methods automatically',
131
- selectedSkillsModeHint: 'When on, every method owned by a role is exposed and the model picks by trigger; the switches below apply to Manual mode only.',
132
- selectedSkillsModeShort: 'Auto',
133
- selectedSkillsFilterLabel: 'Filter by capability',
134
- selectedSkillsEmpty: 'No skills match this tag.',
135
- selectedSkillsRoles: 'Roles: ',
136
- selectedSkillsWhen: 'Use when: ',
137
- selectedSkillsCopyName: 'Copy /name',
138
- selectedSkillsCopied: 'Copied',
139
- selectedSkillsDetail: 'Details',
147
+ selectedSkillsModeLabel: 'Auto-load Skills',
148
+ selectedSkillsModeHint: 'When on, the model picks the relevant selected Skills for each question.',
149
+ selectedSkillsFilterLabel: 'Filter selected Skills',
150
+ selectedSkillsTraitGroup: 'Skill traits',
151
+ selectedSkillsPresetGroup: 'Preset',
152
+ selectedSkillsAll: 'All',
153
+ selectedSkillsEmpty: 'No skills match the current filters.',
154
+ selectedSkillsWhen: 'Use when',
155
+ selectedSkillsKeywords: 'Keywords',
156
+ selectedSkillsOpenDetail: 'Open skill source',
157
+ selectedSkillsPresetUnavailable: 'This preset does not load selected Skills.',
140
158
  selectedSkillsDetailClose: 'Close',
141
159
  selectedSkillsDocLoading: 'Loading the source…',
142
160
  selectedSkillsDocFailed: 'The source could not be read: this snapshot is not in the installed package, or it cannot be opened right now.',
@@ -172,7 +190,6 @@ function installStyles() {
172
190
  style.dataset.capitalConfig = 'true'
173
191
  style.textContent = `
174
192
  .capital-config-card { display: flex; flex-direction: column; gap: 12px; }
175
- .capital-config-title { margin: 0; font-size: 14px; font-weight: 500; line-height: 20px; }
176
193
  /* Declare display only when not hidden, or the hidden attribute loses to author display. */
177
194
  .capital-config-panel { flex-direction: column; gap: 12px; }
178
195
  .capital-config-panel:not([hidden]) { display: flex; }
@@ -188,25 +205,39 @@ function installStyles() {
188
205
  .capital-config-secret-row label { flex: 0 1 auto; }
189
206
  .capital-config-secret-row label ~ span { order: 1; margin-left: auto; }
190
207
  .capital-config-field { display: flex; flex-direction: column; gap: 6px; padding: 12px 0; border-top: 0.5px solid var(--dsw-alias-border-l2); }
191
- /* Filter column stays sticky while the 35-row list scrolls; padding-top matches the 24px rhythm. */
192
- .capital-config-selected-layout { display: flex; align-items: flex-start; gap: 16px; padding-top: 12px; }
193
- .capital-config-selected-filter { position: sticky; top: 12px; flex: 0 0 132px; display: flex; flex-direction: column; align-items: stretch; gap: 3px; }
194
- .capital-config-selected-filter-button { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; min-height: 26px; padding: 3px 8px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12px; line-height: 18px; text-align: left; cursor: pointer; }
195
- .capital-config-selected-filter-button:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
196
- .capital-config-selected-filter-button[aria-pressed="true"] { border-color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent); color: var(--dsw-alias-label-primary); }
197
- .capital-config-selected-filter-count { flex: none; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
198
- .capital-config-selected-filter-button[aria-pressed="true"] .capital-config-selected-filter-count { color: inherit; }
199
- .capital-config-selected-list { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
200
- .capital-config-skill-tags { display: flex; flex-wrap: wrap; gap: 4px; }
201
- .capital-config-skill-tag { padding: 1px 5px; border-radius: 4px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; }
202
- .capital-config-skill-heading { min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
203
- .capital-config-skill-actions { flex: none; display: inline-flex; align-items: center; gap: 6px; }
204
- .capital-config-skill-action { flex: none; min-height: 22px; padding: 2px 8px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent); color: var(--dsw-alias-brand-primary); font: inherit; font-size: 11px; line-height: 16px; white-space: nowrap; cursor: pointer; }
205
- .capital-config-skill-action:hover { border-color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent); }
206
- .capital-config-skill-action:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 1px; }
207
- .capital-config-skill-repo { display: inline-flex; align-items: center; gap: 3px; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; text-decoration: none; }
208
- .capital-config-skill-repo:hover { color: var(--dsw-alias-brand-primary); text-decoration: underline; }
208
+ .capital-config-selected-mode { padding: 12px 14px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-2); }
209
+ .capital-config-switch-row { align-items: center; gap: 12px; display: flex; }
210
+ .capital-config-switch-text { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 4px; }
211
+ .capital-config-switch-label { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 500; line-height: 20px; }
212
+ .capital-config-hint { margin: 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
213
+ .capital-config-skills-browser { display: flex; flex-direction: column; gap: 16px; }
214
+ .capital-config-filters { display: flex; flex-direction: column; gap: 10px; padding: 12px 14px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-2); }
215
+ .capital-config-filter-row { display: flex; align-items: flex-start; gap: 12px; }
216
+ .capital-config-filter-label { flex: 0 0 auto; min-width: 64px; padding-top: 3px; color: var(--dsw-alias-label-tertiary); font-size: 11.5px; font-weight: 500; line-height: 18px; }
217
+ .capital-config-filter-options { display: flex; flex: 1 1 auto; flex-wrap: wrap; gap: 4px; min-width: 0; }
218
+ .capital-config-filter-pill { height: 22px; padding: 0 7px; gap: 5px; font-size: 11.5px; line-height: 16px; }
219
+ .capital-config-filter-pill:disabled { opacity: 0.45; cursor: not-allowed; }
220
+ .capital-config-filter-pill:disabled:hover { background: var(--dsw-alias-bg-layer-2); }
221
+ .capital-config-filter-count { color: var(--dsw-alias-label-tertiary); font-size: 10px; line-height: 14px; font-variant-numeric: tabular-nums; }
222
+ .capital-config-filter-pill[aria-pressed="true"] .capital-config-filter-count { color: inherit; opacity: 0.72; }
223
+ .capital-config-selected-list { display: flex; flex-direction: column; border-top: 0.5px solid var(--dsw-alias-border-l1); }
224
+ .capital-config-skill-item { border-bottom: 0.5px solid var(--dsw-alias-border-l1); }
225
+ .capital-config-skill-row { display: flex; align-items: flex-start; gap: 16px; padding: 14px 2px; }
226
+ .capital-config-skill-main { min-width: 0; flex: 1 1 auto; display: flex; flex-direction: column; gap: 6px; }
227
+ .capital-config-skill-heading { min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
228
+ .capital-config-skill-name { padding: 0; border: 0; border-radius: 4px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; font-weight: 500; line-height: 20px; text-align: left; cursor: pointer; }
229
+ .capital-config-skill-name:hover { color: var(--dsw-alias-brand-primary); }
230
+ .capital-config-skill-name:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 2px; }
231
+ .capital-config-skill-tags, .capital-config-skill-presets { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; }
232
+ .capital-config-skill-tag { font-weight: 400; }
233
+ .capital-config-skill-preset { font-weight: 500; }
209
234
  .capital-config-skill-description { margin: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
235
+ .capital-config-skill-meta { display: grid; grid-template-columns: max-content minmax(0, 1fr); column-gap: 10px; row-gap: 2px; margin: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
236
+ .capital-config-skill-meta dt { color: var(--dsw-alias-label-tertiary); font-weight: 500; white-space: nowrap; }
237
+ .capital-config-skill-meta dd { min-width: 0; margin: 0; }
238
+ .capital-config-skill-repo { display: inline-flex; align-items: center; gap: 3px; width: max-content; max-width: 100%; margin-top: 2px; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; text-decoration: none; }
239
+ .capital-config-skill-repo:hover { color: var(--dsw-alias-brand-primary); text-decoration: underline; }
240
+ .capital-config-selected-empty { margin: 0; padding: 28px 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; text-align: center; }
210
241
  /* Official Modal defaults to 380px; the div prefix outranks the hashed .dialog class. */
211
242
  div.capital-config-skill-modal { width: min(820px, 100%); max-height: 100%; }
212
243
  /* Scroll the body so the dialog's overflow:hidden cannot clip it. */
@@ -221,14 +252,6 @@ function installStyles() {
221
252
  .capital-config-doc-link { display: inline-flex; align-items: center; gap: 3px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 20px; white-space: nowrap; text-decoration: none; }
222
253
  .capital-config-doc-link:hover { color: var(--dsw-alias-brand-primary); text-decoration: underline; }
223
254
  .capital-config-doc-link:focus-visible { border-radius: 3px; outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 2px; }
224
- .capital-config-selected-mode { margin-bottom: 12px; }
225
- .capital-config-switch-row { align-items: center; gap: 12px; display: flex; }
226
- .capital-config-switch-text { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 4px; }
227
- .capital-config-switch-label { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 500; line-height: 20px; }
228
- .capital-config-hint { margin: 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
229
- .capital-config-skill-roles { display: flex; flex-wrap: wrap; gap: 4px; }
230
- .capital-config-skill-role { padding: 1px 5px; border-radius: 4px; border: 0.5px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 16px; }
231
- .capital-config-skill-when { margin: 0; color: var(--dsw-alias-label-tertiary); font-size: 11.5px; line-height: 17px; }
232
255
  .capital-config-write-failed { margin: 0; color: var(--dsw-alias-state-error-primary); font-size: 12px; line-height: 18px; }
233
256
  /* Own inline-flex group so the host's wrapping never splits the two links. */
234
257
  .capital-config-plugin-links { flex: none; align-items: center; gap: 2px; display: inline-flex; }
@@ -537,27 +560,27 @@ function loadSkillDoc(name) {
537
560
  return pending
538
561
  }
539
562
 
540
- const COPIED_FLASH_MS = 1600
563
+ function presetLabel(role, locale) {
564
+ return SELECTED_SKILL_PRESETS[role]?.[locale] ?? role
565
+ }
541
566
 
542
- function SelectedSkillRow({ entry, state, copyable, disabled, onChange, t }) {
567
+ /** Routing text stays one sentence; split the keyword tail so both lines share a type scale. */
568
+ function splitUseWhen(value, locale) {
569
+ const marker = locale === 'en' ? 'Triggers on ' : ';关键词:'
570
+ const index = value.lastIndexOf(marker)
571
+ if (index <= 0) return { when: value, keywords: '' }
572
+ return {
573
+ when: value.slice(0, index).trim(),
574
+ keywords: value.slice(index + marker.length).trim().replace(locale === 'en' ? /\.$/ : /。$/, ''),
575
+ }
576
+ }
577
+
578
+ function SelectedSkillRow({ entry, state, disabled, onChange, t }) {
543
579
  const locale = skillLocale()
544
580
  const label = entry.label[locale]
545
581
  const repositoryUrl = `https://github.com/${entry.owner}/${entry.repository}`
546
- const copyTimer = useRef(null)
547
- const [copied, setCopied] = useState(false)
548
582
  const [detailOpen, setDetailOpen] = useState(false)
549
583
  const [doc, setDoc] = useState(null)
550
- useEffect(() => () => {
551
- if (copyTimer.current !== null) clearTimeout(copyTimer.current)
552
- }, [])
553
- const copyName = async () => {
554
- const accepted = await writeClipboard(`/${entry.name}`)
555
- // Do not flip the label if the clipboard rejected it.
556
- if (!accepted) return
557
- setCopied(true)
558
- if (copyTimer.current !== null) clearTimeout(copyTimer.current)
559
- copyTimer.current = setTimeout(() => setCopied(false), COPIED_FLASH_MS)
560
- }
561
584
  const prefetchDoc = () => {
562
585
  if (doc?.status === 'ready' || doc?.status === 'loading') return
563
586
  if (skillDocs.has(entry.name)) {
@@ -578,48 +601,56 @@ function SelectedSkillRow({ entry, state, copyable, disabled, onChange, t }) {
578
601
  : doc?.status === 'failed'
579
602
  ? h('p', { className: 'capital-config-skill-doc-state' }, t(doc.code === 'route_missing' ? 'selectedSkillsDocStale' : 'selectedSkillsDocFailed'))
580
603
  : h('p', { className: 'capital-config-skill-doc-state' }, t('selectedSkillsDocLoading'))
581
- return h('div', { className: 'capital-config-field' },
582
- h('div', { className: 'capital-config-switch-row' },
583
- h('div', { className: 'capital-config-switch-text' },
604
+ const { when, keywords } = splitUseWhen(SELECTED_SKILL_ROUTING[entry.name]?.useWhen?.[locale] ?? '', locale)
605
+ const roles = entry.roles ?? []
606
+ return h('div', { className: 'capital-config-skill-item' },
607
+ h('div', { className: 'capital-config-skill-row' },
608
+ h('div', { className: 'capital-config-skill-main' },
584
609
  h('div', { className: 'capital-config-skill-heading' },
585
- h('span', { className: 'capital-config-title' }, label),
586
- h('div', { className: 'capital-config-skill-actions' },
587
- // Manual: only enabled rows. Auto: the role owns every method, so copying any name is meaningful.
588
- copyable === true ? h('button', {
589
- type: 'button',
590
- className: 'capital-config-skill-action',
591
- title: `${t('selectedSkillsCopyName')}: ${entry.name}`,
592
- 'aria-label': `${t('selectedSkillsCopyName')}: ${entry.name}`,
593
- onClick: copyName,
594
- }, copied ? t('selectedSkillsCopied') : t('selectedSkillsCopyName')) : null,
595
- h('button', {
596
- type: 'button',
597
- className: 'capital-config-skill-action',
598
- 'aria-label': `${t('selectedSkillsDetail')}: ${entry.name}`,
599
- 'aria-haspopup': 'dialog',
600
- // Prefetch on hover / focus so the modal opens with content; click is the action.
601
- onPointerEnter: prefetchDoc,
602
- onFocus: prefetchDoc,
603
- onClick: () => {
604
- prefetchDoc()
605
- setDetailOpen(true)
606
- },
607
- }, t('selectedSkillsDetail')),
608
- h(Modal, {
609
- open: detailOpen,
610
- onClose: () => setDetailOpen(false),
611
- title: label,
612
- description: entry.name,
613
- closeLabel: t('selectedSkillsDetailClose'),
614
- className: 'capital-config-skill-modal',
615
- contentClassName: 'capital-config-skill-modal-content',
616
- }, detailContent),
617
- ),
610
+ h('button', {
611
+ type: 'button',
612
+ className: 'capital-config-skill-name',
613
+ title: t('selectedSkillsOpenDetail'),
614
+ 'aria-haspopup': 'dialog',
615
+ // Prefetch on hover / focus so the modal opens with content; click is the action.
616
+ onPointerEnter: prefetchDoc,
617
+ onFocus: prefetchDoc,
618
+ onClick: () => {
619
+ prefetchDoc()
620
+ setDetailOpen(true)
621
+ },
622
+ }, label),
623
+ h('div', { className: 'capital-config-skill-tags', role: 'group', 'aria-label': t('selectedSkillsTraitGroup') },
624
+ entry.tags.map((tag) => h(Tag, {
625
+ key: tag,
626
+ tone: 'neutral',
627
+ className: 'capital-config-skill-tag',
628
+ }, SELECTED_SKILL_TAG_LABELS[locale][tag] ?? tag))),
629
+ roles.length > 0 ? h('div', { className: 'capital-config-skill-presets', role: 'group', 'aria-label': t('selectedSkillsPresetGroup') },
630
+ roles.map((role) => h(Tag, {
631
+ key: role,
632
+ tone: 'info',
633
+ className: 'capital-config-skill-preset',
634
+ },
635
+ h('span', { className: 'capital-config-sr-only' }, `${t('selectedSkillsPresetGroup')}:`),
636
+ presetLabel(role, locale)))) : null,
637
+ h(Modal, {
638
+ open: detailOpen,
639
+ onClose: () => setDetailOpen(false),
640
+ title: label,
641
+ description: entry.name,
642
+ closeLabel: t('selectedSkillsDetailClose'),
643
+ className: 'capital-config-skill-modal',
644
+ contentClassName: 'capital-config-skill-modal-content',
645
+ }, detailContent),
618
646
  ),
619
647
  h('p', { className: 'capital-config-skill-description' }, entry.description[locale]),
620
- h('p', { className: 'capital-config-skill-when' }, `${t('selectedSkillsWhen')}${SELECTED_SKILL_ROUTING[entry.name]?.useWhen?.[locale] ?? ''}`),
621
- entry.roles?.length ? h('div', { className: 'capital-config-skill-roles' }, entry.roles.map((role) =>
622
- h('span', { key: role, className: 'capital-config-skill-role' }, SELECTED_SKILL_ROLES[role]?.[locale] ?? role))) : null,
648
+ h('dl', { className: 'capital-config-skill-meta' },
649
+ h('dt', { key: 'when-label' }, t('selectedSkillsWhen')),
650
+ h('dd', { key: 'when' }, when),
651
+ keywords ? h('dt', { key: 'keywords-label' }, t('selectedSkillsKeywords')) : null,
652
+ keywords ? h('dd', { key: 'keywords' }, keywords) : null,
653
+ ),
623
654
  h('a', {
624
655
  className: 'capital-config-skill-repo',
625
656
  href: repositoryUrl,
@@ -627,8 +658,6 @@ function SelectedSkillRow({ entry, state, copyable, disabled, onChange, t }) {
627
658
  rel: 'noopener noreferrer',
628
659
  title: t('selectedSkillsOpenRepo'),
629
660
  }, `${entry.owner}/${entry.repository}`, h(IconRightUpOutlineRegular, { size: 12 })),
630
- h('div', { className: 'capital-config-skill-tags' }, entry.tags.map((tag) =>
631
- h('span', { key: tag, className: 'capital-config-skill-tag' }, SELECTED_SKILL_TAG_LABELS[locale][tag] ?? tag))),
632
661
  ),
633
662
  h(Switch, {
634
663
  label,
@@ -654,7 +683,8 @@ function CapitalCard(props) {
654
683
  const autoMode = state.selectedSkillsMode?.auto === true
655
684
  const locale = skillLocale()
656
685
  const [tab, setTab] = useState('sources')
657
- const [tagFilter, setTagFilter] = useState('all')
686
+ const [traitFilter, setTraitFilter] = useState(SELECTED_SKILL_FILTER_ALL)
687
+ const [presetFilter, setPresetFilter] = useState(SELECTED_SKILL_FILTER_ALL)
658
688
  // Fetch on mount: tab labels carry numbers; hooks must precede the unavailable return.
659
689
  useEffect(() => { props.ensureBundleRows() }, [])
660
690
  // Pinned order is snapshotted on tab entry so rows never move under the cursor.
@@ -662,17 +692,11 @@ function CapitalCard(props) {
662
692
  .filter((entry) => state.selectedSkills?.[entry.key]?.on === true)
663
693
  .map((entry) => entry.key))
664
694
  const [pinnedSkills, setPinnedSkills] = useState(enabledKeys)
665
- useEffect(() => { if (state.available) setPinnedSkills(enabledKeys()) }, [state.available])
666
- const tagCounts = new Map()
667
- for (const entry of SELECTED_SKILL_CATALOG) {
668
- for (const tag of entry.tags) tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1)
669
- }
670
- tagCounts.set('all', SELECTED_SKILL_CATALOG.length)
671
- const tags = ['all', ...Array.from(tagCounts.keys())
672
- .filter((tag) => tag !== 'all')
673
- .sort((left, right) => tagCounts.get(right) - tagCounts.get(left))]
674
- const visibleSkills = SELECTED_SKILL_CATALOG
675
- .filter((entry) => tagFilter === 'all' || entry.tags.includes(tagFilter))
695
+ useEffect(() => {
696
+ if (state.available && !autoMode) setPinnedSkills(enabledKeys())
697
+ }, [state.available, autoMode])
698
+ const facetCounts = selectedSkillFacetCounts({ trait: traitFilter, preset: presetFilter })
699
+ const visibleSkills = filterSelectedSkills({ trait: traitFilter, preset: presetFilter })
676
700
  .sort((left, right) => (pinnedSkills.has(left.key) ? 0 : 1) - (pinnedSkills.has(right.key) ? 0 : 1))
677
701
  const form = h(SettingsForm, {
678
702
  labels: {
@@ -708,7 +732,7 @@ function CapitalCard(props) {
708
732
  ),
709
733
  state.localFetch?.failed ? h('p', { role: 'status', className: 'capital-config-write-failed' }, t('saveFailed')) : null,
710
734
  )
711
- const modeSection = h('div', { className: 'capital-config-field capital-config-selected-mode' },
735
+ const modeSection = h('div', { className: 'capital-config-selected-mode' },
712
736
  h('div', { className: 'capital-config-switch-row' },
713
737
  h('div', { className: 'capital-config-switch-text' },
714
738
  h('span', { className: 'capital-config-switch-label' }, t('selectedSkillsModeLabel')),
@@ -723,37 +747,60 @@ function CapitalCard(props) {
723
747
  ),
724
748
  state.selectedSkillsMode?.failed ? h('p', { role: 'status', className: 'capital-config-write-failed' }, t('saveFailed')) : null,
725
749
  )
726
- const tagLabels = SELECTED_SKILL_TAG_LABELS[locale]
727
- const skillsFilter = h('div', { className: 'capital-config-selected-filter', role: 'group', 'aria-label': t('selectedSkillsFilterLabel') },
728
- ...tags.map((tag) => h('button', {
729
- key: tag,
750
+ const traitLabels = SELECTED_SKILL_TAG_LABELS[locale]
751
+ const filterRow = (label, pills) => h('div', { className: 'capital-config-filter-row' },
752
+ h('span', { className: 'capital-config-filter-label' }, label),
753
+ h('div', { className: 'capital-config-filter-options', role: 'group', 'aria-label': label }, ...pills),
754
+ )
755
+ const traitPills = SELECTED_SKILL_TRAITS.map((trait) => h(Pill, {
756
+ key: trait,
757
+ type: 'button',
758
+ className: 'capital-config-filter-pill',
759
+ active: traitFilter === trait,
760
+ 'aria-pressed': traitFilter === trait ? 'true' : 'false',
761
+ onClick: () => setTraitFilter(trait),
762
+ },
763
+ trait === SELECTED_SKILL_FILTER_ALL ? t('selectedSkillsAll') : (traitLabels[trait] ?? trait),
764
+ h('span', { className: 'capital-config-filter-count' }, String(facetCounts.traits.get(trait) ?? 0))))
765
+ const presetPills = [SELECTED_SKILL_FILTER_ALL, ...SELECTED_SKILL_PRESET_ORDER].map((preset) => {
766
+ const total = preset === SELECTED_SKILL_FILTER_ALL
767
+ ? SELECTED_SKILL_CATALOG.length
768
+ : SELECTED_SKILL_PRESET_TOTALS.get(preset) ?? 0
769
+ const unavailable = preset !== SELECTED_SKILL_FILTER_ALL && total === 0
770
+ return h(Pill, {
771
+ key: preset,
730
772
  type: 'button',
731
- className: 'capital-config-selected-filter-button',
732
- 'aria-pressed': tagFilter === tag ? 'true' : 'false',
733
- onClick: () => setTagFilter(tag),
734
- }, tagLabels[tag] ?? tag, h('span', { className: 'capital-config-selected-filter-count' }, String(tagCounts.get(tag))))),
773
+ className: 'capital-config-filter-pill',
774
+ active: presetFilter === preset,
775
+ 'aria-pressed': presetFilter === preset ? 'true' : 'false',
776
+ disabled: unavailable,
777
+ title: unavailable ? t('selectedSkillsPresetUnavailable') : undefined,
778
+ onClick: () => setPresetFilter(preset),
779
+ },
780
+ preset === SELECTED_SKILL_FILTER_ALL ? t('selectedSkillsAll') : presetLabel(preset, locale),
781
+ h('span', { className: 'capital-config-filter-count' }, String(facetCounts.presets.get(preset) ?? 0)))
782
+ })
783
+ const skillsFilter = h('div', { className: 'capital-config-filters', role: 'group', 'aria-label': t('selectedSkillsFilterLabel') },
784
+ filterRow(t('selectedSkillsTraitGroup'), traitPills),
785
+ filterRow(t('selectedSkillsPresetGroup'), presetPills),
735
786
  )
736
787
  // No cap: the user builds the set; the role prompt caps only how many it lists.
737
- const enabledSkills = SELECTED_SKILL_CATALOG.filter(
738
- (entry) => state.selectedSkills?.[entry.key]?.on === true,
739
- ).length
740
788
  const skillRows = visibleSkills.length > 0
741
789
  ? visibleSkills.map((entry) => h(SelectedSkillRow, {
742
790
  key: entry.key,
743
- entry: { ...entry, roles: SELECTED_SKILL_ROUTING[entry.name]?.roles ?? [] },
791
+ entry: { ...entry, roles: selectedSkillRoles(entry) },
744
792
  state: state.selectedSkills?.[entry.key],
745
- copyable: autoMode || state.selectedSkills?.[entry.key]?.on === true,
746
- disabled: disabled || state.saving || autoMode,
793
+ disabled: disabled || state.saving,
747
794
  onChange: (value) => props.toggleSelectedSkill(entry.key, value),
748
795
  t,
749
796
  }))
750
- : [h('p', { key: 'empty', className: 'capital-config-hint' }, t('selectedSkillsEmpty'))]
797
+ : [h('p', { key: 'empty', className: 'capital-config-selected-empty' }, t('selectedSkillsEmpty'))]
751
798
  // No cap banner: the icon is the only explanation. Unknown count prints no number.
752
799
  const withCount = (label, count) => (Number.isInteger(count) ? `${label} ${count}` : label)
753
800
  const options = [
754
801
  // A dirty draft while Save is off-screen must be announced on the tab.
755
802
  { value: 'sources', label: state.dirty ? t('titleUnsaved') : t('title') },
756
- { value: 'skills', label: `${t('selectedSkillsTitle')} ${autoMode ? t('selectedSkillsModeShort') : `${enabledSkills}/${SELECTED_SKILL_CATALOG.length}`}` },
803
+ { value: 'skills', label: t('selectedSkillsTitle') },
757
804
  { value: 'components', label: withCount(t('componentsTitle'), state.components?.count) },
758
805
  ]
759
806
  const panel = (value, children) => h('section', {
@@ -780,13 +827,12 @@ function CapitalCard(props) {
780
827
  },
781
828
  }),
782
829
  panel('sources', [form, localFetchSection]),
783
- panel('skills', [
784
- modeSection,
785
- h('div', { className: 'capital-config-selected-layout' },
830
+ panel('skills', autoMode
831
+ ? [modeSection]
832
+ : [modeSection, h('div', { className: 'capital-config-skills-browser' },
786
833
  skillsFilter,
787
834
  h('div', { className: 'capital-config-selected-list' }, ...skillRows),
788
- ),
789
- ]),
835
+ )]),
790
836
  // The panel is the host's rows list; keep the aria-controls target non-empty via sr-only.
791
837
  panel('components', [h('span', { className: 'capital-config-sr-only' }, t('componentsHint'))]),
792
838
  )
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v587d/config",
3
- "version": "2.10.1",
3
+ "version": "2.10.2",
4
4
  "description": "自动模式的取数凭据与偏好配置行:卡片坐在 @v587d/capital-generation 插件主页的「配置」段,点这一行本身没有表单。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,7 +3,7 @@
3
3
  * Known limit: DNS resolves per request but the IP is not pinned to the connection,
4
4
  * so a rebinding window remains (same trade-off as dsh-search-first).
5
5
  */
6
- export declare const LOCAL_FETCH_CLIENT_VERSION = "2.10.1";
6
+ export declare const LOCAL_FETCH_CLIENT_VERSION = "2.10.2";
7
7
  export declare const LOCAL_FETCH_MAX_URL_LENGTH = 2048;
8
8
  export type LocalFetchErrorCode = 'ABORTED' | 'TIMEOUT' | 'INVALID_URL' | 'BLOCKED_URL' | 'DNS' | 'REDIRECT' | 'HTTP' | 'TOO_LARGE' | 'UNSUPPORTED_CONTENT_TYPE';
9
9
  export declare class LocalFetchError extends Error {
@@ -4,7 +4,7 @@
4
4
  * so a rebinding window remains (same trade-off as dsh-search-first).
5
5
  */
6
6
  import { htmlToMarkdown, stripInvisibleText } from './html-markdown.js';
7
- export const LOCAL_FETCH_CLIENT_VERSION = '2.10.1';
7
+ export const LOCAL_FETCH_CLIENT_VERSION = '2.10.2';
8
8
  export const LOCAL_FETCH_MAX_URL_LENGTH = 2048;
9
9
  export class LocalFetchError extends Error {
10
10
  code;
@@ -35,7 +35,7 @@ export interface WindCallResult {
35
35
  code?: WindErrorCode;
36
36
  }
37
37
  /** Must match `package.json` version; a test pins the two together. */
38
- export declare const WIND_CLIENT_VERSION = "2.10.1";
38
+ export declare const WIND_CLIENT_VERSION = "2.10.2";
39
39
  export interface WindClient {
40
40
  callTool(toolName: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<WindCallResult>;
41
41
  }
@@ -19,7 +19,7 @@ export const WIND_SERVER_ENDPOINTS = {
19
19
  analytics_data: 'https://mcp.wind.com.cn/vserver_analytics_data/mcp/',
20
20
  };
21
21
  /** Must match `package.json` version; a test pins the two together. */
22
- export const WIND_CLIENT_VERSION = '2.10.1';
22
+ export const WIND_CLIENT_VERSION = '2.10.2';
23
23
  function errorText(error) {
24
24
  return error instanceof Error ? error.message : String(error);
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v587d/capital-generation",
3
- "version": "2.10.1",
3
+ "version": "2.10.2",
4
4
  "description": "Capital Generation:面向中国散户的 AI 投资助手插件(自动模式 Agent Preset)",
5
5
  "icon": "assets/cg-icon.png",
6
6
  "author": "v587d",
@@ -108,6 +108,7 @@
108
108
  "watchlist",
109
109
  "preset",
110
110
  "selected-skills/catalog.js",
111
+ "selected-skills/filter.js",
111
112
  "selected-skills/routing.js",
112
113
  "selected-skills/ROUTING.md",
113
114
  "selected-skills/README.md",
@@ -29,8 +29,9 @@ for the user to review the source and decide whether it fits the task.
29
29
  Tags describe what a skill helps with, not whether it is trustworthy or recommended.
30
30
  `status`, `lastCommit`, `reviewBy` and `riskNote` are review metadata for this repository:
31
31
  they drive the quarterly review cadence and the scan record, and they are deliberately not
32
- rendered in the settings card, which shows the skill name, summary, capability tags and the
33
- upstream repository link. A tag is not a guarantee of financial validity.
32
+ rendered in the settings card, which shows the skill name, summary, capability tags, the
33
+ preset that can load it, its trigger/keyword line and the upstream repository link. A tag
34
+ is not a guarantee of financial validity.
34
35
 
35
36
  ## Operating posture
36
37
 
@@ -0,0 +1,70 @@
1
+ import { SELECTED_SKILL_CATALOG } from './catalog.js'
2
+ import {
3
+ SELECTED_SKILL_PRESET_ORDER,
4
+ SELECTED_SKILL_ROLE_ALIASES,
5
+ SELECTED_SKILL_ROUTING,
6
+ } from './routing.js'
7
+
8
+ /** Both filter dimensions use this value for "no constraint". */
9
+ export const SELECTED_SKILL_FILTER_ALL = 'all'
10
+
11
+ /** Roles a skill can be loaded by; aliases resolve before matching. */
12
+ export function selectedSkillRoles(entry) {
13
+ return (SELECTED_SKILL_ROUTING[entry.name]?.roles ?? [])
14
+ .map((role) => SELECTED_SKILL_ROLE_ALIASES[role] ?? role)
15
+ }
16
+
17
+ /** Trait order is the full-catalog count, so chips never reorder while the user filters. */
18
+ export function selectedSkillTraitOrder(catalog = SELECTED_SKILL_CATALOG) {
19
+ const counts = new Map()
20
+ for (const entry of catalog) {
21
+ for (const tag of entry.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1)
22
+ }
23
+ const traits = [...counts.keys()].sort((left, right) => counts.get(right) - counts.get(left))
24
+ return [SELECTED_SKILL_FILTER_ALL, ...traits]
25
+ }
26
+
27
+ /** Total skills each preset can load; zero-count presets stay visible but disabled. */
28
+ export function selectedSkillPresetTotals(catalog = SELECTED_SKILL_CATALOG) {
29
+ const totals = new Map(SELECTED_SKILL_PRESET_ORDER.map((preset) => [preset, 0]))
30
+ for (const entry of catalog) {
31
+ for (const role of new Set(selectedSkillRoles(entry))) {
32
+ if (totals.has(role)) totals.set(role, totals.get(role) + 1)
33
+ }
34
+ }
35
+ return totals
36
+ }
37
+
38
+ const matchesTrait = (entry, trait) => trait === SELECTED_SKILL_FILTER_ALL || entry.tags.includes(trait)
39
+ const matchesPreset = (entry, preset) => preset === SELECTED_SKILL_FILTER_ALL || selectedSkillRoles(entry).includes(preset)
40
+
41
+ /** Both dimensions intersect; either one set to `all` leaves the other as the only constraint. */
42
+ export function filterSelectedSkills({
43
+ trait = SELECTED_SKILL_FILTER_ALL,
44
+ preset = SELECTED_SKILL_FILTER_ALL,
45
+ catalog = SELECTED_SKILL_CATALOG,
46
+ } = {}) {
47
+ return catalog.filter((entry) => matchesTrait(entry, trait) && matchesPreset(entry, preset))
48
+ }
49
+
50
+ /**
51
+ * Counts answer "how many rows will this chip show next", so each dimension is
52
+ * counted under the other dimension's current selection.
53
+ */
54
+ export function selectedSkillFacetCounts({
55
+ trait = SELECTED_SKILL_FILTER_ALL,
56
+ preset = SELECTED_SKILL_FILTER_ALL,
57
+ catalog = SELECTED_SKILL_CATALOG,
58
+ } = {}) {
59
+ const traits = new Map([[SELECTED_SKILL_FILTER_ALL, 0]])
60
+ for (const entry of catalog.filter((item) => matchesPreset(item, preset))) {
61
+ traits.set(SELECTED_SKILL_FILTER_ALL, traits.get(SELECTED_SKILL_FILTER_ALL) + 1)
62
+ for (const tag of entry.tags) traits.set(tag, (traits.get(tag) ?? 0) + 1)
63
+ }
64
+ const presets = new Map([[SELECTED_SKILL_FILTER_ALL, 0]])
65
+ for (const entry of catalog.filter((item) => matchesTrait(item, trait))) {
66
+ presets.set(SELECTED_SKILL_FILTER_ALL, presets.get(SELECTED_SKILL_FILTER_ALL) + 1)
67
+ for (const role of new Set(selectedSkillRoles(entry))) presets.set(role, (presets.get(role) ?? 0) + 1)
68
+ }
69
+ return { traits, presets }
70
+ }