dsh-skill-hub 0.2.2 → 0.2.5
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/README.md +10 -14
- package/README.zh.md +9 -12
- package/lib/client.js +493 -177
- package/lib/client.js.map +1 -1
- package/lib/index.js +213 -36
- package/lib/types/client/SkillHubSettingsCard.d.ts +4 -6
- package/lib/types/client/icons.d.ts +25 -0
- package/lib/types/client/locales.d.ts +7 -3
- package/lib/types/client/panel/format.d.ts +10 -0
- package/lib/types/client/settings-card.d.ts +18 -0
- package/lib/types/client/settings-form.d.ts +9 -0
- package/lib/types/client/slash-dots.d.ts +57 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/protocol.d.ts +42 -1
- package/lib/types/stats.d.ts +53 -4
- package/lib/types/store.d.ts +7 -2
- package/package.json +68 -26
- package/src/client/SkillHubSettingsCard.tsx +35 -10
- package/src/client/icons.tsx +61 -0
- package/src/client/index.tsx +13 -1
- package/src/client/locales.ts +14 -6
- package/src/client/panel/SkillDetailView.tsx +6 -2
- package/src/client/panel/SkillHubPanel.tsx +2 -2
- package/src/client/panel/SkillRow.tsx +8 -3
- package/src/client/panel/format.ts +11 -0
- package/src/client/panel/panel.module.css +1 -0
- package/src/client/settings-card.tsx +49 -1
- package/src/client/settings-form.ts +21 -0
- package/src/client/slash-dots.test.ts +145 -0
- package/src/client/slash-dots.tsx +190 -0
- package/src/index.ts +34 -3
- package/src/protocol.ts +47 -1
- package/src/routes.test.ts +3 -1
- package/src/routes.ts +2 -2
- package/src/stats.test.ts +273 -2
- package/src/stats.ts +166 -23
- package/src/store.test.ts +32 -0
- package/src/store.ts +58 -3
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { JSX, KeyboardEvent } from 'react'
|
|
7
7
|
import type { CatalogSkill } from '../../protocol.ts'
|
|
8
|
-
import { IconTrashOutline16 } from '
|
|
8
|
+
import { IconTrashOutline16 } from '../icons.tsx'
|
|
9
9
|
import { tt } from '../helpers.ts'
|
|
10
10
|
import { dotStyle, relativeTimeText } from './format.ts'
|
|
11
11
|
import type { SkillHubState } from './useSkillHub.ts'
|
|
@@ -16,6 +16,12 @@ export function SkillRow(props: { skill: CatalogSkill; hub: SkillHubState }): JS
|
|
|
16
16
|
const stat = hub.uses.get(skill.name)
|
|
17
17
|
const count = stat?.count ?? 0
|
|
18
18
|
const lastUsed = stat?.lastUsed
|
|
19
|
+
/** 单一状态圆点:模型可调 → 蓝;否则用户可调 → 绿。与聊天 / 菜单同规则。 */
|
|
20
|
+
const dot = skill.invocation.modelInvocable
|
|
21
|
+
? <span className={css.dot + ' ' + css.dotModel} style={dotStyle(hub.hubConfig?.dotModelColor)} title={tt('legend.model')} />
|
|
22
|
+
: skill.invocation.userInvocable
|
|
23
|
+
? <span className={css.dot + ' ' + css.dotUser} style={dotStyle(hub.hubConfig?.dotUserColor)} title={tt('legend.user')} />
|
|
24
|
+
: null
|
|
19
25
|
/** 键盘打开详情:Enter 或空格。仅当焦点在行本身(而非行内按钮)时生效。 */
|
|
20
26
|
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
21
27
|
if (event.target !== event.currentTarget) return
|
|
@@ -37,8 +43,7 @@ export function SkillRow(props: { skill: CatalogSkill; hub: SkillHubState }): JS
|
|
|
37
43
|
<div className={css.rowName}>
|
|
38
44
|
<span className={css.rowNameText}>{skill.name}</span>
|
|
39
45
|
{count > 0 && hub.hubConfig?.showUseCount !== false ? <span className={css.useCount}>{count}</span> : null}
|
|
40
|
-
{
|
|
41
|
-
{skill.invocation.userInvocable ? <span className={css.dot + ' ' + css.dotUser} style={dotStyle(hub.hubConfig?.dotUserColor)} title={tt('legend.user')} /> : null}
|
|
46
|
+
{dot}
|
|
42
47
|
{lastUsed !== undefined && hub.hubConfig?.showUseTime !== false ? <span className={css.useTime}>{relativeTimeText(lastUsed)}</span> : null}
|
|
43
48
|
</div>
|
|
44
49
|
<div className={css.rowDesc}>{skill.description}</div>
|
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
* Shared display helpers for the panel views (skill rows, detail view, and
|
|
3
3
|
* dialogs): dot color styling, relative/absolute time text, and commit-SHA
|
|
4
4
|
* shortening. Kept in one place so the views render identically.
|
|
5
|
+
*
|
|
6
|
+
* The invocation-status dot defaults live here (not in the settings card) so
|
|
7
|
+
* both the panel legend and the chat `/` skill menu draw from one source and
|
|
8
|
+
* so this primitives-free module stays unit-testable.
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
11
|
import type { CSSProperties } from 'react'
|
|
8
12
|
import { formatRelativeTime } from '../grouping.ts'
|
|
9
13
|
import { tt } from '../helpers.ts'
|
|
10
14
|
|
|
15
|
+
/** Model-invocable dot color default. Single source for the TS side; the
|
|
16
|
+
* panel's CSS mirrors it via --hub-model (panel.module.css). */
|
|
17
|
+
export const DEFAULT_DOT_MODEL_COLOR = '#2f81f7'
|
|
18
|
+
/** User-invocable dot color default. Single source for the TS side; the
|
|
19
|
+
* panel's CSS mirrors it via --hub-user (panel.module.css). */
|
|
20
|
+
export const DEFAULT_DOT_USER_COLOR = '#3fb950'
|
|
21
|
+
|
|
11
22
|
/** Dot inline style from the user-chosen color (undefined keeps the CSS default). */
|
|
12
23
|
export function dotStyle(color: string | undefined): CSSProperties | undefined {
|
|
13
24
|
if (color === undefined) return undefined
|
|
@@ -120,6 +120,7 @@
|
|
|
120
120
|
|
|
121
121
|
/* Reduced header: title + quiet count on one line, controls on the next. */
|
|
122
122
|
.headerCount { font-size: 12px; opacity: .5; margin-left: 2px; white-space: nowrap; }
|
|
123
|
+
.pluginVersion { font-size: 12px; font-weight: 500; opacity: .45; margin-left: 6px; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
123
124
|
.subbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
124
125
|
.legendToggle { flex: none; width: 22px; height: 22px; border-radius: 50%; border: none; background: rgba(128,128,128,.14); color: inherit; font-size: 12px; line-height: 1; cursor: pointer; opacity: .55; transition: opacity .15s ease, background .15s ease; }
|
|
125
126
|
.legendToggle:hover { opacity: 1; }
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { useState, type ReactElement } from 'react'
|
|
10
|
-
import { IconChevronDownOutline14 } from '
|
|
10
|
+
import { IconChevronDownOutline14 } from './icons.tsx'
|
|
11
11
|
import type { HubKey } from './locales.ts'
|
|
12
12
|
import type { CardShell } from './settings-form.ts'
|
|
13
13
|
import css from './settings-card.module.css'
|
|
@@ -236,3 +236,51 @@ export function ColorField(props: ColorFieldProps): ReactElement {
|
|
|
236
236
|
</div>
|
|
237
237
|
)
|
|
238
238
|
}
|
|
239
|
+
|
|
240
|
+
/** One staged numeric field: a numeric draft text input with inherit/reset semantics. */
|
|
241
|
+
export interface NumberFieldProps {
|
|
242
|
+
id: string
|
|
243
|
+
label: string
|
|
244
|
+
hint: string
|
|
245
|
+
/** Placeholder shown while the field inherits its default. */
|
|
246
|
+
inheritLabel: string
|
|
247
|
+
overriddenLabel: string
|
|
248
|
+
resetLabel: string
|
|
249
|
+
disabled: boolean
|
|
250
|
+
/** Effective number when overridden; empty string means inherit. */
|
|
251
|
+
text: string
|
|
252
|
+
overridden: boolean
|
|
253
|
+
invalid?: boolean
|
|
254
|
+
onEdit: (text: string) => void
|
|
255
|
+
onReset: () => void
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function NumberField(props: NumberFieldProps): ReactElement {
|
|
259
|
+
return (
|
|
260
|
+
<div className={css.field}>
|
|
261
|
+
<div className={css.head}>
|
|
262
|
+
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
|
263
|
+
{props.overridden ? (
|
|
264
|
+
<span className={css.badges}>
|
|
265
|
+
<span className={css.badge}>{props.overriddenLabel}</span>
|
|
266
|
+
<button type='button' className={css.reset} disabled={props.disabled} onClick={props.onReset}>
|
|
267
|
+
{props.resetLabel}
|
|
268
|
+
</button>
|
|
269
|
+
</span>
|
|
270
|
+
) : null}
|
|
271
|
+
</div>
|
|
272
|
+
<input
|
|
273
|
+
id={props.id}
|
|
274
|
+
type='text'
|
|
275
|
+
inputMode='numeric'
|
|
276
|
+
className={css.input}
|
|
277
|
+
value={props.text}
|
|
278
|
+
disabled={props.disabled}
|
|
279
|
+
placeholder={props.inheritLabel}
|
|
280
|
+
aria-invalid={props.invalid === true}
|
|
281
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
282
|
+
/>
|
|
283
|
+
<p className={css.hint}>{props.hint}</p>
|
|
284
|
+
</div>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
@@ -49,6 +49,27 @@ export function colorField(field: string): FieldSpec {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* A numeric field with range clamping. Integer by default: a fractional draft
|
|
54
|
+
* is truncated so "5.5 分钟" cannot sneak past an integer-only schema.
|
|
55
|
+
*/
|
|
56
|
+
export function numberField(field: string, options: { min?: number; max?: number; integer?: boolean } = {}): FieldSpec {
|
|
57
|
+
const min = options.min ?? Number.NEGATIVE_INFINITY
|
|
58
|
+
const max = options.max ?? Number.POSITIVE_INFINITY
|
|
59
|
+
const integer = options.integer ?? true
|
|
60
|
+
return {
|
|
61
|
+
field,
|
|
62
|
+
format: (value) => (typeof value === 'number' && Number.isFinite(value) ? String(value) : ''),
|
|
63
|
+
parse: (text) => {
|
|
64
|
+
const trimmed = text.trim()
|
|
65
|
+
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return undefined
|
|
66
|
+
const parsed = integer ? Math.trunc(Number(trimmed)) : Number(trimmed)
|
|
67
|
+
if (!Number.isFinite(parsed) || parsed < min || parsed > max) return undefined
|
|
68
|
+
return { kind: 'set', value: parsed }
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
/** Card-level state the chrome renders. */
|
|
53
74
|
export interface CardShell {
|
|
54
75
|
available: boolean
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash-menu skill dots: source lookup + candidates wrapping. The wrapper's
|
|
3
|
+
* contract is pure and injectable — fake api (catalog) and fake settings scope
|
|
4
|
+
* (getSnapshot), no DOM — so it tests cleanly in the node vitest environment.
|
|
5
|
+
* The module caches the catalog map; resetModelCache keeps tests independent.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
9
|
+
import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
|
|
10
|
+
import type { InputTriggerCandidate, InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
11
|
+
import type { HubSettingsValue } from '../protocol.ts'
|
|
12
|
+
import type { SkillHubApi } from './api.ts'
|
|
13
|
+
import { findSkillSource, resetModelCache, wrapSkillSource } from './slash-dots.tsx'
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
resetModelCache()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/** A `/` skill source stub with the core ui-skill shape. */
|
|
20
|
+
function skillSource(candidates = async (): Promise<InputTriggerCandidate[]> => []): InputTriggerSource {
|
|
21
|
+
return {
|
|
22
|
+
trigger: '/',
|
|
23
|
+
name: 'skill',
|
|
24
|
+
order: 2,
|
|
25
|
+
candidates,
|
|
26
|
+
onPick: () => undefined,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Fake registry exposing `live.sources` the way the running service does. */
|
|
31
|
+
function registry(...sources: InputTriggerSource[]): InputTriggerServiceContract {
|
|
32
|
+
return { live: { sources }, registerSource: () => () => {}, sessionOf: () => { throw new Error('not used in tests') } } as unknown as InputTriggerServiceContract
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Fake settings scope snapshotting a fixed HubSettingsValue. */
|
|
36
|
+
function scopeWith(value: HubSettingsValue): SettingsScope<HubSettingsValue> {
|
|
37
|
+
return {
|
|
38
|
+
getSnapshot: () => ({ status: 'ready', value, base: undefined, user: undefined, revision: 1, writable: true, mode: 'host' }),
|
|
39
|
+
subscribe: () => () => {},
|
|
40
|
+
set: async () => {},
|
|
41
|
+
unset: async () => {},
|
|
42
|
+
} as SettingsScope<HubSettingsValue>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Fake hub api whose catalog lists the given skills. */
|
|
46
|
+
function apiWith(skills: Array<{ name: string; modelInvocable: boolean }>): SkillHubApi {
|
|
47
|
+
return {
|
|
48
|
+
catalog: async () => ({
|
|
49
|
+
ok: true,
|
|
50
|
+
complete: true,
|
|
51
|
+
skills: skills.map((s) => ({
|
|
52
|
+
name: s.name,
|
|
53
|
+
description: '',
|
|
54
|
+
invocation: { modelInvocable: s.modelInvocable, userInvocable: true },
|
|
55
|
+
provider: 'filesystem',
|
|
56
|
+
source: 'user-dsh',
|
|
57
|
+
writable: true,
|
|
58
|
+
})),
|
|
59
|
+
disabled: [],
|
|
60
|
+
diagnostics: [],
|
|
61
|
+
}),
|
|
62
|
+
} as unknown as SkillHubApi
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('findSkillSource', () => {
|
|
66
|
+
it('finds the core /skill source among other sources', () => {
|
|
67
|
+
const source = skillSource()
|
|
68
|
+
const service = registry(
|
|
69
|
+
{ trigger: '/', name: 'command', candidates: async () => [], onPick: () => undefined },
|
|
70
|
+
source,
|
|
71
|
+
)
|
|
72
|
+
expect(findSkillSource(service)).toBe(source)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns undefined when the skill source is absent', () => {
|
|
76
|
+
const service = registry({ trigger: '/', name: 'command', candidates: async () => [], onPick: () => undefined })
|
|
77
|
+
expect(findSkillSource(service)).toBeUndefined()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('never throws on a reshaped registry', () => {
|
|
81
|
+
expect(findSkillSource({} as InputTriggerServiceContract)).toBeUndefined()
|
|
82
|
+
expect(findSkillSource({ live: {} } as unknown as InputTriggerServiceContract)).toBeUndefined()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('wrapSkillSource', () => {
|
|
87
|
+
it('adds a model-colored dot to model-callable skills and a user-colored dot to user-only ones', async () => {
|
|
88
|
+
const source = skillSource(async () => [
|
|
89
|
+
{ name: 'code-review', description: 'review code' },
|
|
90
|
+
{ name: 'personal-note', description: 'only me' },
|
|
91
|
+
{ name: 'known-default', description: 'fallback unknown' },
|
|
92
|
+
])
|
|
93
|
+
const api = apiWith([
|
|
94
|
+
{ name: 'code-review', modelInvocable: true },
|
|
95
|
+
{ name: 'personal-note', modelInvocable: false },
|
|
96
|
+
])
|
|
97
|
+
const scope = scopeWith({ enabled: true, announceToAgent: true, showUseCount: true, showUseTime: true, showGroupSummary: true, dotModelColor: '#112233', dotUserColor: '#445566' })
|
|
98
|
+
|
|
99
|
+
const restore = wrapSkillSource(source, api, scope)
|
|
100
|
+
try {
|
|
101
|
+
const rows = await source.candidates({ sessionId: 's1' as never }, { query: '', position: 'leading', signal: new AbortController().signal })
|
|
102
|
+
|
|
103
|
+
expect(rows).toHaveLength(3)
|
|
104
|
+
// Model-callable → model color.
|
|
105
|
+
expect((rows[0].icon as unknown as { props: { style: { background: string } } }).props.style.background).toBe('#112233')
|
|
106
|
+
// User-only → user color.
|
|
107
|
+
expect((rows[1].icon as unknown as { props: { style: { background: string } } }).props.style.background).toBe('#445566')
|
|
108
|
+
// Unknown name → model default (still a dot, safer than no dot).
|
|
109
|
+
expect(rows[2].icon).toBeDefined()
|
|
110
|
+
// Original fields pass through untouched.
|
|
111
|
+
expect(rows[0].name).toBe('code-review')
|
|
112
|
+
expect(rows[0].description).toBe('review code')
|
|
113
|
+
} finally {
|
|
114
|
+
restore()
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('falls back to the default colors when settings omit them and catalog fails', async () => {
|
|
119
|
+
const source = skillSource(async () => [{ name: 'lonely', description: '' }])
|
|
120
|
+
const api = { catalog: async () => { throw new Error('route down') } } as unknown as SkillHubApi
|
|
121
|
+
const scope = scopeWith({ enabled: true, announceToAgent: true, showUseCount: true, showUseTime: true, showGroupSummary: true })
|
|
122
|
+
|
|
123
|
+
const restore = wrapSkillSource(source, api, scope)
|
|
124
|
+
try {
|
|
125
|
+
const rows = await source.candidates({ sessionId: 's1' as never }, { query: '', position: 'leading', signal: new AbortController().signal })
|
|
126
|
+
// Catalog failed → unknown name → model default dot still renders.
|
|
127
|
+
expect(rows[0].icon).toBeDefined()
|
|
128
|
+
expect((rows[0].icon as unknown as { props: { style: { background: string } } }).props.style.background).toBe('#2f81f7')
|
|
129
|
+
} finally {
|
|
130
|
+
restore()
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('restores the original candidates on dispose', async () => {
|
|
135
|
+
const originalCandidates = async (): Promise<InputTriggerCandidate[]> => [{ name: 'plain', description: '' }]
|
|
136
|
+
const source = skillSource(originalCandidates)
|
|
137
|
+
const api = apiWith([])
|
|
138
|
+
const scope = scopeWith({ enabled: true, announceToAgent: true, showUseCount: true, showUseTime: true, showGroupSummary: true })
|
|
139
|
+
|
|
140
|
+
const restore = wrapSkillSource(source, api, scope)
|
|
141
|
+
expect(source.candidates).not.toBe(originalCandidates)
|
|
142
|
+
restore()
|
|
143
|
+
expect(source.candidates).toBe(originalCandidates)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash-menu skill dots: puts the invocation-status dot (model-callable blue /
|
|
3
|
+
* user-only green) in front of every skill candidate in the chat `/` menu.
|
|
4
|
+
*
|
|
5
|
+
* Mechanism (mirrors how dsh-at-file fills the menu icon slot): the candidate
|
|
6
|
+
* menu's rows already render an optional `icon` slot (`MenuView` renders
|
|
7
|
+
* `item.icon` in a 16×16 leading span when it's defined), but the core `/skill`
|
|
8
|
+
* source (`dsh-client-ui-skill`) returns candidates without `icon`. This module
|
|
9
|
+
* wraps that source's `candidates` and stamps each row with a colored dot,
|
|
10
|
+
* reusing the same settings (dotModelColor / dotUserColor) and the same
|
|
11
|
+
* `modelInvocable` classification the panel legend uses — so the chat menu and
|
|
12
|
+
* the Settings → 技能 panel stay in sync, and editing the color updates both.
|
|
13
|
+
*
|
|
14
|
+
* The skill source is registered by the core plugin under the `name` "skill"
|
|
15
|
+
* on the `/` trigger; re-registering the same name would throw, so this wraps
|
|
16
|
+
* the already-registered source object instead (found through the runtime
|
|
17
|
+
* source registry, which the frozen contract exposes only as `registerSource`
|
|
18
|
+
* / `sessionOf` — the lookup below is defensive: it never throws).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
|
|
22
|
+
// Type-only: pulls the Context merge for ctx.inputTriggers.
|
|
23
|
+
import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
24
|
+
import type { InputTriggerCandidate, InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
25
|
+
import { createElement } from 'react'
|
|
26
|
+
import type { HubSettingsValue } from '../protocol.ts'
|
|
27
|
+
import type { SkillHubApi } from './api.ts'
|
|
28
|
+
import { DEFAULT_DOT_MODEL_COLOR, DEFAULT_DOT_USER_COLOR } from './panel/format.ts'
|
|
29
|
+
|
|
30
|
+
/** The core plugin's skill source identity on the '/' trigger. */
|
|
31
|
+
const SKILL_SOURCE = { trigger: '/', name: 'skill' } as const
|
|
32
|
+
|
|
33
|
+
/** How long one catalog-derived modelInvocable map stays hot before refresh. */
|
|
34
|
+
const MODEL_TTL_MS = 60_000
|
|
35
|
+
|
|
36
|
+
/** Runtime view of the source registry (the contract keeps `live` private). */
|
|
37
|
+
type SourceRegistry = {
|
|
38
|
+
live?: { sources?: InputTriggerSource[] }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Find the core `/skill` source through the runtime registry, or undefined.
|
|
43
|
+
* The lookup never throws: a missing/reshaped registry just means no dots. Exported
|
|
44
|
+
* for unit tests; the apply path only calls it indirectly through setupSkillSlashDots.
|
|
45
|
+
* @param service - the ctx.inputTriggers service face.
|
|
46
|
+
* @returns the registered skill source, or undefined.
|
|
47
|
+
*/
|
|
48
|
+
export function findSkillSource(service: InputTriggerServiceContract): InputTriggerSource | undefined {
|
|
49
|
+
const live = (service as unknown as SourceRegistry).live
|
|
50
|
+
if (live?.sources === undefined) return undefined
|
|
51
|
+
return live.sources.find((source) => source.trigger === SKILL_SOURCE.trigger && source.name === SKILL_SOURCE.name)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One catalog-derived name → modelInvocable snapshot with a load timestamp. */
|
|
55
|
+
let modelCache: { at: number; map: Map<string, boolean> } | { at: number; failed: true } | undefined
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Clear the modelInvocable cache. Called on connection/reset so a fresh
|
|
59
|
+
* catalog wins after reconnect; exported for deterministic unit tests.
|
|
60
|
+
*/
|
|
61
|
+
export function resetModelCache(): void {
|
|
62
|
+
modelCache = undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve name → modelInvocable from the hub's catalog, cached for
|
|
67
|
+
* MODEL_TTL_MS. A failed load caches the failure briefly so a downed route
|
|
68
|
+
* doesn't hammer the host on every keystroke; the returned map is empty then
|
|
69
|
+
* (callers fall back to the model dot for unknown names).
|
|
70
|
+
* @param api - the hub browser API.
|
|
71
|
+
* @returns name → whether the model may call the skill.
|
|
72
|
+
*/
|
|
73
|
+
async function modelInvocableMap(api: SkillHubApi): Promise<Map<string, boolean>> {
|
|
74
|
+
const now = Date.now()
|
|
75
|
+
const cached = modelCache
|
|
76
|
+
if (cached !== undefined && now - cached.at < MODEL_TTL_MS) {
|
|
77
|
+
return 'failed' in cached ? new Map() : cached.map
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const catalog = await api.catalog()
|
|
81
|
+
const map = new Map<string, boolean>(catalog.skills.map((skill) => [skill.name, skill.invocation.modelInvocable]))
|
|
82
|
+
modelCache = { at: now, map }
|
|
83
|
+
return map
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error('[dsh-skill-hub] slash dot color lookup failed:', error)
|
|
86
|
+
modelCache = { at: now, failed: true }
|
|
87
|
+
return new Map()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One menu-row dot element. Inline span so it needs no CSS module; the
|
|
93
|
+
* candidate menu centers it inside its 16×16 leading icon slot.
|
|
94
|
+
* @param color - the dot's background color.
|
|
95
|
+
* @returns a React node (memory-only, never crosses the Host boundary).
|
|
96
|
+
*/
|
|
97
|
+
function dotIcon(color: string): InputTriggerCandidate['icon'] {
|
|
98
|
+
return createElement('span', {
|
|
99
|
+
'aria-hidden': true,
|
|
100
|
+
style: {
|
|
101
|
+
display: 'inline-block',
|
|
102
|
+
width: 6,
|
|
103
|
+
height: 6,
|
|
104
|
+
borderRadius: 3,
|
|
105
|
+
background: color,
|
|
106
|
+
flex: 'none',
|
|
107
|
+
},
|
|
108
|
+
}) as unknown as InputTriggerCandidate['icon']
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Wrap the core skill source so every menu row carries the invocation dot.
|
|
113
|
+
* Exported for unit tests; production wiring goes through setupSkillSlashDots.
|
|
114
|
+
* @param source - the registered `/skill` source.
|
|
115
|
+
* @param api - hub browser API for the modelInvocable lookup.
|
|
116
|
+
* @param scope - hub settings scope for the dot colors.
|
|
117
|
+
* @returns a disposer restoring the original candidates.
|
|
118
|
+
*/
|
|
119
|
+
export function wrapSkillSource(source: InputTriggerSource, api: SkillHubApi, scope: SettingsScope<HubSettingsValue>): () => void {
|
|
120
|
+
const original = source.candidates
|
|
121
|
+
source.candidates = async (session, req) => {
|
|
122
|
+
const items = await original(session, req)
|
|
123
|
+
if (req.signal.aborted) return items
|
|
124
|
+
const modelByName = await modelInvocableMap(api)
|
|
125
|
+
if (req.signal.aborted) return items
|
|
126
|
+
const snapshot = scope.getSnapshot()
|
|
127
|
+
const modelColor = snapshot.value?.dotModelColor ?? DEFAULT_DOT_MODEL_COLOR
|
|
128
|
+
const userColor = snapshot.value?.dotUserColor ?? DEFAULT_DOT_USER_COLOR
|
|
129
|
+
return items.map((item) => ({
|
|
130
|
+
...item,
|
|
131
|
+
icon: dotIcon((modelByName.get(item.name) ?? true) ? modelColor : userColor),
|
|
132
|
+
}))
|
|
133
|
+
}
|
|
134
|
+
return () => {
|
|
135
|
+
source.candidates = original
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Mount the slash-menu dots on the registered `/skill` source. Idempotent and
|
|
141
|
+
* defensive: if the core source isn't registered yet (or the registry shape
|
|
142
|
+
* changes), it retries briefly and then gives up silently — the chat keeps
|
|
143
|
+
* working, it simply shows no dots. The returned disposer restores the
|
|
144
|
+
* original candidates and clears the model cache.
|
|
145
|
+
* @param ctx - the client root context (inputTriggers + events).
|
|
146
|
+
* @param api - hub browser API.
|
|
147
|
+
* @param scope - hub settings scope for dot colors.
|
|
148
|
+
* @returns a cleanup function for `ctx.effect`.
|
|
149
|
+
*/
|
|
150
|
+
export function setupSkillSlashDots(
|
|
151
|
+
ctx: ClientContext,
|
|
152
|
+
api: SkillHubApi,
|
|
153
|
+
scope: SettingsScope<HubSettingsValue>,
|
|
154
|
+
): () => void {
|
|
155
|
+
const inputTriggers = ctx.get('inputTriggers')
|
|
156
|
+
if (inputTriggers === undefined) return () => {}
|
|
157
|
+
|
|
158
|
+
let disposed = false
|
|
159
|
+
let restore: (() => void) | undefined
|
|
160
|
+
let attempts = 0
|
|
161
|
+
|
|
162
|
+
// The core ui-skill source registers early in the bundle, but not strictly
|
|
163
|
+
// before this apply runs — poll briefly so order never matters.
|
|
164
|
+
const attempt = (): void => {
|
|
165
|
+
if (disposed) return
|
|
166
|
+
const source = findSkillSource(inputTriggers)
|
|
167
|
+
if (source === undefined) {
|
|
168
|
+
if (attempts < 10) {
|
|
169
|
+
attempts += 1
|
|
170
|
+
setTimeout(attempt, 100)
|
|
171
|
+
}
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
restore = wrapSkillSource(source, api, scope)
|
|
175
|
+
}
|
|
176
|
+
attempt()
|
|
177
|
+
|
|
178
|
+
const clearCache = (): void => {
|
|
179
|
+
resetModelCache()
|
|
180
|
+
}
|
|
181
|
+
// ctx.on returns the listener disposer; cordis has no ctx.off.
|
|
182
|
+
const offReset = ctx.on('connection/reset', clearCache)
|
|
183
|
+
|
|
184
|
+
return () => {
|
|
185
|
+
disposed = true
|
|
186
|
+
offReset()
|
|
187
|
+
restore?.()
|
|
188
|
+
restore = undefined
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,10 @@ export interface Config {
|
|
|
40
40
|
showUseTime?: boolean
|
|
41
41
|
/** Show group-header usage summaries (count + last used). Default true. */
|
|
42
42
|
showGroupSummary?: boolean
|
|
43
|
+
/** 统计滚动窗口天数:只统计最近 N 天的使用;0 = 全部历史。默认 0。 */
|
|
44
|
+
statsWindowDays?: number
|
|
45
|
+
/** 自动统计扫描间隔(分钟,最小 1)。默认 5。 */
|
|
46
|
+
statsScanMinutes?: number
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
export const Config: z<Config> = z.object({
|
|
@@ -48,6 +52,8 @@ export const Config: z<Config> = z.object({
|
|
|
48
52
|
showUseCount: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseCount),
|
|
49
53
|
showUseTime: z.boolean().default(HUB_CONFIG_DEFAULTS.showUseTime),
|
|
50
54
|
showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
|
|
55
|
+
statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
|
|
56
|
+
statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes),
|
|
51
57
|
})
|
|
52
58
|
|
|
53
59
|
/**
|
|
@@ -68,6 +74,8 @@ export const HubSettingsSchema: z<HubSettingsValue> = z.object({
|
|
|
68
74
|
showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
|
|
69
75
|
dotModelColor: z.string().pattern(HEX_COLOR_RE),
|
|
70
76
|
dotUserColor: z.string().pattern(HEX_COLOR_RE),
|
|
77
|
+
statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
|
|
78
|
+
statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes),
|
|
71
79
|
})
|
|
72
80
|
|
|
73
81
|
/** Order of the announcement section within the tool-guidance band. */
|
|
@@ -126,7 +134,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
126
134
|
// surfaces once the namespace commits.
|
|
127
135
|
const updateConfig = async (patch: Partial<HubConfig>): Promise<HubConfig> => {
|
|
128
136
|
const user: Record<string, unknown> = { ...saved() }
|
|
129
|
-
for (const [key, value] of Object.entries(patch) as Array<[keyof HubConfig, boolean | string | undefined]>) {
|
|
137
|
+
for (const [key, value] of Object.entries(patch) as Array<[keyof HubConfig, boolean | string | number | undefined]>) {
|
|
130
138
|
if (value === undefined) delete user[key]
|
|
131
139
|
else user[key] = value
|
|
132
140
|
}
|
|
@@ -222,7 +230,30 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
222
230
|
// stats wiring — no sessionQuery service ever mounted means none of this
|
|
223
231
|
// runs).
|
|
224
232
|
ctx.inject(['sessionQuery'], (sctx) => {
|
|
225
|
-
|
|
226
|
-
|
|
233
|
+
// 恢复 sidecar 里的增量扫描检查点后再建 reader(异步、不阻塞注入回调):
|
|
234
|
+
// 重启后无需重新解压全部历史日志;检查点只在全量对账后落盘(约每天一次,
|
|
235
|
+
// 或统计窗口配置变化后的下一次扫描)。扫描间隔与滚动窗口都从设置命名空间
|
|
236
|
+
// 实时读取——卡片里改完即生效,无需重启。
|
|
237
|
+
void (async () => {
|
|
238
|
+
const saved = await store.getSkillStatsState().catch(() => undefined)
|
|
239
|
+
const scanMinutes = (): number => {
|
|
240
|
+
const value = current().statsScanMinutes
|
|
241
|
+
return typeof value === 'number' && value >= 1 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsScanMinutes
|
|
242
|
+
}
|
|
243
|
+
const windowDays = (): number => {
|
|
244
|
+
const value = current().statsWindowDays
|
|
245
|
+
return typeof value === 'number' && value >= 0 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsWindowDays
|
|
246
|
+
}
|
|
247
|
+
stats = createSkillStatsReader(sctx.sessionQuery, () => scanMinutes() * 60_000, {
|
|
248
|
+
checkpoint: saved,
|
|
249
|
+
windowDays,
|
|
250
|
+
onCheckpoint: (next) => {
|
|
251
|
+
void store.saveSkillStatsState(next).catch((error) => {
|
|
252
|
+
ctx.logger.warn('[dsh-skill-hub] persisting skill-stats checkpoint failed', error)
|
|
253
|
+
})
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
sync()
|
|
257
|
+
})()
|
|
227
258
|
})
|
|
228
259
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -88,6 +88,8 @@ export interface DiagnosticEntry {
|
|
|
88
88
|
/** GET /api/skill-hub/catalog */
|
|
89
89
|
export interface CatalogResponse {
|
|
90
90
|
ok: true
|
|
91
|
+
/** 已安装插件自身的版本号(package.json version),面板标题旁显示。 */
|
|
92
|
+
pluginVersion: string
|
|
91
93
|
/** Whether discovery completed within a stable catalog revision. */
|
|
92
94
|
complete: boolean
|
|
93
95
|
/** Sorted winning summaries of every enabled skill (all roots + providers). */
|
|
@@ -196,6 +198,25 @@ export interface StatsResponse {
|
|
|
196
198
|
stats: SkillStat[]
|
|
197
199
|
}
|
|
198
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Persisted incremental-scan checkpoint for the usage statistics (sidecar
|
|
203
|
+
* `skillStats` field). Sessions created before `frozenBefore` are treated as
|
|
204
|
+
* finalized: their per-session counts live in `frozenSessions` (only sessions
|
|
205
|
+
* with at least one invocation are kept) and they are not re-read on
|
|
206
|
+
* incremental scans. A daily full reconciliation rebuilds the cache and
|
|
207
|
+
* advances the watermark, so a resumed old session is eventually re-counted.
|
|
208
|
+
*/
|
|
209
|
+
export interface SkillStatsCheckpoint {
|
|
210
|
+
/** The rolling-window configuration this checkpoint was built for (0 = all history). */
|
|
211
|
+
windowDays: number
|
|
212
|
+
/** Watermark: every session with header.createdAt < this value is frozen. */
|
|
213
|
+
frozenBefore: number
|
|
214
|
+
/** Per-session counts of finalized sessions, keyed by session id. */
|
|
215
|
+
frozenSessions: Record<string, { createdAt: number; counts: Record<string, { count: number; lastUsed: number }> }>
|
|
216
|
+
/** Epoch ms of the last full reconciliation (drives the daily cadence). */
|
|
217
|
+
lastFullReconcile: number
|
|
218
|
+
}
|
|
219
|
+
|
|
199
220
|
/** JSON error body shared by every route. */
|
|
200
221
|
export interface ErrorResponse {
|
|
201
222
|
error: string
|
|
@@ -217,6 +238,10 @@ export interface HubConfig {
|
|
|
217
238
|
showUseTime?: boolean
|
|
218
239
|
/** Show group-header usage summaries (count + last used). Default true. */
|
|
219
240
|
showGroupSummary?: boolean
|
|
241
|
+
/** 统计滚动窗口天数:只统计最近 N 天的使用;0 = 全部历史。默认 0。 */
|
|
242
|
+
statsWindowDays?: number
|
|
243
|
+
/** 自动统计扫描间隔(分钟,最小 1)。默认 5。 */
|
|
244
|
+
statsScanMinutes?: number
|
|
220
245
|
}
|
|
221
246
|
|
|
222
247
|
/**
|
|
@@ -235,6 +260,10 @@ export type HubSettingsValue = {
|
|
|
235
260
|
dotModelColor?: string
|
|
236
261
|
/** User-invocable dot color (#rrggbb); absent means the panel default. */
|
|
237
262
|
dotUserColor?: string
|
|
263
|
+
/** 统计滚动窗口天数(0 = 全部历史)。 */
|
|
264
|
+
statsWindowDays?: number
|
|
265
|
+
/** 自动统计扫描间隔(分钟)。 */
|
|
266
|
+
statsScanMinutes?: number
|
|
238
267
|
}
|
|
239
268
|
|
|
240
269
|
/**
|
|
@@ -248,28 +277,41 @@ export const HUB_CONFIG_DEFAULTS = {
|
|
|
248
277
|
showUseCount: true,
|
|
249
278
|
showUseTime: true,
|
|
250
279
|
showGroupSummary: true,
|
|
280
|
+
statsWindowDays: 14,
|
|
281
|
+
statsScanMinutes: 5,
|
|
251
282
|
} as const
|
|
252
283
|
|
|
253
284
|
/**
|
|
254
285
|
* Resolve the effective hub config: saved sidecar overrides win over the
|
|
255
286
|
* cordis composition entry (the web card owns runtime config), missing
|
|
256
287
|
* booleans fall back to HUB_CONFIG_DEFAULTS, and dot colors pass through
|
|
257
|
-
* (saved first, then base) only when set.
|
|
288
|
+
* (saved first, then base) only when set. Numeric stats knobs are clamped to
|
|
289
|
+
* their sane ranges (window ≥ 0, scan interval ≥ 1 minute).
|
|
258
290
|
*/
|
|
259
291
|
export function resolveHubConfig(saved: Partial<HubConfig>, base: Partial<HubConfig> = {}): HubConfig {
|
|
260
292
|
const dotModelColor = saved.dotModelColor !== undefined ? saved.dotModelColor : base.dotModelColor
|
|
261
293
|
const dotUserColor = saved.dotUserColor !== undefined ? saved.dotUserColor : base.dotUserColor
|
|
294
|
+
const windowDays = clampNumber(saved.statsWindowDays ?? base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays
|
|
295
|
+
const scanMinutes = clampNumber(saved.statsScanMinutes ?? base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes
|
|
262
296
|
return {
|
|
263
297
|
enabled: saved.enabled ?? base.enabled ?? HUB_CONFIG_DEFAULTS.enabled,
|
|
264
298
|
announceToAgent: saved.announceToAgent ?? base.announceToAgent ?? HUB_CONFIG_DEFAULTS.announceToAgent,
|
|
265
299
|
showUseCount: saved.showUseCount ?? base.showUseCount ?? HUB_CONFIG_DEFAULTS.showUseCount,
|
|
266
300
|
showUseTime: saved.showUseTime ?? base.showUseTime ?? HUB_CONFIG_DEFAULTS.showUseTime,
|
|
267
301
|
showGroupSummary: saved.showGroupSummary ?? base.showGroupSummary ?? HUB_CONFIG_DEFAULTS.showGroupSummary,
|
|
302
|
+
statsWindowDays: windowDays,
|
|
303
|
+
statsScanMinutes: scanMinutes,
|
|
268
304
|
...(dotModelColor !== undefined ? { dotModelColor } : {}),
|
|
269
305
|
...(dotUserColor !== undefined ? { dotUserColor } : {}),
|
|
270
306
|
}
|
|
271
307
|
}
|
|
272
308
|
|
|
309
|
+
/** Clamp a numeric override into a valid value; undefined/invalid stays undefined. */
|
|
310
|
+
function clampNumber(value: unknown, min: number): number | undefined {
|
|
311
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < min) return undefined
|
|
312
|
+
return Math.floor(value)
|
|
313
|
+
}
|
|
314
|
+
|
|
273
315
|
/** HEX color validation shared by host routes and the settings card. */
|
|
274
316
|
export const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i
|
|
275
317
|
|
|
@@ -298,6 +340,10 @@ export interface ConfigRequest {
|
|
|
298
340
|
dotModelColor?: string | null
|
|
299
341
|
/** Set the dot color; null clears the saved override so it re-inherits the default. */
|
|
300
342
|
dotUserColor?: string | null
|
|
343
|
+
/** 统计滚动窗口天数(0 = 全部历史);null 清除覆盖回默认。 */
|
|
344
|
+
statsWindowDays?: number | null
|
|
345
|
+
/** 自动统计扫描间隔(分钟);null 清除覆盖回默认。 */
|
|
346
|
+
statsScanMinutes?: number | null
|
|
301
347
|
}
|
|
302
348
|
|
|
303
349
|
/** One market source: a tracked upstream repo plus its pinned version. */
|