dsh-skill-picker 0.3.4 → 0.5.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/README.md +20 -2
- package/lib/client.js +177 -136
- package/lib/client.js.map +2 -2
- package/lib/index.js +147 -10
- package/lib/index.js.map +4 -4
- package/package.json +68 -69
- package/src/client/index.jsx +210 -147
- package/src/index.js +17 -0
- package/src/patch-ui-skill.js +185 -0
package/src/client/index.jsx
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* @module dsh-skill-picker/client
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
18
|
+
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
|
19
19
|
import fuzzysort from 'fuzzysort'
|
|
20
20
|
import { pinyin } from 'pinyin-pro'
|
|
21
21
|
|
|
@@ -25,6 +25,30 @@ export const inject = ['slots', 'connection', 'sessions', 'inputTriggers']
|
|
|
25
25
|
/** localStorage key for the picker's per-browser usage history. */
|
|
26
26
|
const USAGE_KEY = 'dsh-skill-picker:usage'
|
|
27
27
|
|
|
28
|
+
/** localStorage key for the user's manually pinned skills (ordered array of names). */
|
|
29
|
+
const PINNED_KEY = 'dsh-skill-picker:pinned'
|
|
30
|
+
|
|
31
|
+
/** Read the pinned list {string[]}; never throws. */
|
|
32
|
+
function loadPinned() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = localStorage.getItem(PINNED_KEY)
|
|
35
|
+
if (raw === null) return []
|
|
36
|
+
const parsed = JSON.parse(raw)
|
|
37
|
+
return Array.isArray(parsed) ? parsed.filter((name) => typeof name === 'string') : []
|
|
38
|
+
} catch {
|
|
39
|
+
return []
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Persist the pinned list; never throws. */
|
|
44
|
+
function savePinned(pinned) {
|
|
45
|
+
try {
|
|
46
|
+
localStorage.setItem(PINNED_KEY, JSON.stringify(pinned))
|
|
47
|
+
} catch {
|
|
48
|
+
/* storage unavailable — pinning just won't persist */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
28
52
|
/** Read the usage history {name: {count, lastUsed}}; never throws. */
|
|
29
53
|
function loadUsage() {
|
|
30
54
|
try {
|
|
@@ -65,6 +89,30 @@ function rankByUsage(skills, usage) {
|
|
|
65
89
|
})
|
|
66
90
|
}
|
|
67
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Grouped ordering for the ⚡ panel: pinned (manual, pinned order) first,
|
|
94
|
+
* then recently/frequently used (usage order), then the untouched rest (by
|
|
95
|
+
* name). Groups with no members are dropped. A fresh structure is returned;
|
|
96
|
+
* the input arrays are untouched.
|
|
97
|
+
*/
|
|
98
|
+
function groupByPinned(skills, usage, pinned) {
|
|
99
|
+
const pinnedSet = new Set(pinned)
|
|
100
|
+
const pinnedList = pinned.map((name) => skills.find((s) => s.name === name)).filter(Boolean)
|
|
101
|
+
const ranked = rankByUsage(skills, usage)
|
|
102
|
+
const recent = []
|
|
103
|
+
const rest = []
|
|
104
|
+
for (const skill of ranked) {
|
|
105
|
+
if (pinnedSet.has(skill.name)) continue
|
|
106
|
+
if (usage[skill.name] !== undefined) recent.push(skill)
|
|
107
|
+
else rest.push(skill)
|
|
108
|
+
}
|
|
109
|
+
return [
|
|
110
|
+
{ title: '📌 置顶', items: pinnedList },
|
|
111
|
+
{ title: '🔥 最近使用', items: recent },
|
|
112
|
+
{ title: '🗂️ 全部', items: rest },
|
|
113
|
+
].filter((group) => group.items.length > 0)
|
|
114
|
+
}
|
|
115
|
+
|
|
68
116
|
/**
|
|
69
117
|
* Pinyin search text for a skill name/description: spaced full pinyin
|
|
70
118
|
* (`ji yi`), joined (`jiyi`), and initials (`jy`) for the name, plus joined
|
|
@@ -241,10 +289,38 @@ function SkillPickerButton(props) {
|
|
|
241
289
|
const [source, setSource] = useState(undefined)
|
|
242
290
|
const [query, setQuery] = useState('')
|
|
243
291
|
const [usage, setUsage] = useState(() => loadUsage())
|
|
292
|
+
const [pinned, setPinned] = useState(() => loadPinned())
|
|
244
293
|
const [active, setActive] = useState(0)
|
|
245
294
|
const boxRef = useRef(null)
|
|
246
295
|
const itemRefs = useRef([])
|
|
247
296
|
|
|
297
|
+
// The usage store is shared with the official `/` menu: picks made there go
|
|
298
|
+
// through window.__dshSkillPickerTrack (localStorage only). Refresh this
|
|
299
|
+
// panel's state from storage whenever that event fires, so a slash pick
|
|
300
|
+
// shows up as "recently used" here too — not just in the slash list.
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
const onUsageUpdated = () => setUsage(loadUsage())
|
|
303
|
+
window.addEventListener('dsh-skill-picker:usage-updated', onUsageUpdated)
|
|
304
|
+
return () => window.removeEventListener('dsh-skill-picker:usage-updated', onUsageUpdated)
|
|
305
|
+
}, [])
|
|
306
|
+
|
|
307
|
+
// Latest draft mirror: `useInput` is a selector hook and may only be called
|
|
308
|
+
// during render, while the pick handler runs from a click callback. Sync the
|
|
309
|
+
// store's current draft into a ref here (render time), so the click handler
|
|
310
|
+
// appends onto the REAL current draft instead of a stale snapshot. The
|
|
311
|
+
// owner-provided `input` snapshot is a secondary fallback only.
|
|
312
|
+
const draftRef = useRef('')
|
|
313
|
+
if (typeof props.useInput === 'function') {
|
|
314
|
+
try {
|
|
315
|
+
const state = props.useInput((s) => s)
|
|
316
|
+
if (state !== undefined && typeof state.draft === 'string') draftRef.current = state.draft
|
|
317
|
+
} catch {
|
|
318
|
+
/* keep the last known draft */
|
|
319
|
+
}
|
|
320
|
+
} else if (props.input !== undefined && typeof props.input.draft === 'string') {
|
|
321
|
+
draftRef.current = props.input.draft
|
|
322
|
+
}
|
|
323
|
+
|
|
248
324
|
const load = useCallback(async () => {
|
|
249
325
|
if (skills !== undefined || error !== undefined) return
|
|
250
326
|
try {
|
|
@@ -273,23 +349,18 @@ function SkillPickerButton(props) {
|
|
|
273
349
|
}, [skills, error, props.listSkills, props.session, props.cwd])
|
|
274
350
|
|
|
275
351
|
const toggle = () => {
|
|
276
|
-
if (!open)
|
|
352
|
+
if (!open) {
|
|
353
|
+
setUsage(loadUsage())
|
|
354
|
+
void load()
|
|
355
|
+
}
|
|
277
356
|
setOpen(!open)
|
|
278
357
|
}
|
|
279
358
|
|
|
280
359
|
const pick = (name) => {
|
|
281
|
-
// Draft source:
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
draft = props.input.draft
|
|
286
|
-
} else if (typeof props.useInput === 'function') {
|
|
287
|
-
const state = props.useInput((s) => s)
|
|
288
|
-
if (state !== undefined && typeof state.draft === 'string') draft = state.draft
|
|
289
|
-
}
|
|
290
|
-
} catch (cause) {
|
|
291
|
-
console.error('[dsh-skill-picker] reading draft failed:', cause)
|
|
292
|
-
}
|
|
360
|
+
// Draft source: the render-time mirror of the live input store (see the
|
|
361
|
+
// `draftRef` sync above). Appending onto anything else risks overwriting
|
|
362
|
+
// the user's typed draft with a stale snapshot.
|
|
363
|
+
const draft = draftRef.current
|
|
293
364
|
const separator = draft === '' || draft.endsWith(' ') || draft.endsWith('\n') ? '' : ' '
|
|
294
365
|
const next = `${draft}${separator}/${name} `
|
|
295
366
|
try {
|
|
@@ -311,6 +382,12 @@ function SkillPickerButton(props) {
|
|
|
311
382
|
setQuery('')
|
|
312
383
|
}
|
|
313
384
|
|
|
385
|
+
const togglePin = (name) => {
|
|
386
|
+
const next = pinned.includes(name) ? pinned.filter((n) => n !== name) : [...pinned, name]
|
|
387
|
+
setPinned(next)
|
|
388
|
+
savePinned(next)
|
|
389
|
+
}
|
|
390
|
+
|
|
314
391
|
// Close on outside pointer-down (the shell's menu convention).
|
|
315
392
|
useEffect(() => {
|
|
316
393
|
if (!open) return
|
|
@@ -321,11 +398,12 @@ function SkillPickerButton(props) {
|
|
|
321
398
|
return () => document.removeEventListener('mousedown', onDown)
|
|
322
399
|
}, [open])
|
|
323
400
|
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
const
|
|
401
|
+
// Grouped ordering: pinned first (manual), then usage-ranked (recent then
|
|
402
|
+
// frequent then untouched by name). Shared rule with the `/` completion.
|
|
403
|
+
const groups = groupByPinned(skills ?? [], usage, pinned)
|
|
404
|
+
const flat = groups.flatMap((group) => group.items)
|
|
327
405
|
|
|
328
|
-
const filtered =
|
|
406
|
+
const filtered = flat
|
|
329
407
|
.filter((skill) => {
|
|
330
408
|
const q = query.trim().toLowerCase()
|
|
331
409
|
if (q === '') return true
|
|
@@ -337,6 +415,11 @@ function SkillPickerButton(props) {
|
|
|
337
415
|
})
|
|
338
416
|
.slice(0, 60)
|
|
339
417
|
|
|
418
|
+
// Group titles show only while browsing (no query); searching collapses the
|
|
419
|
+
// list into one flat, pinned-first result set.
|
|
420
|
+
const showTitles = query.trim() === '' && groups.length > 1
|
|
421
|
+
const filteredNames = new Set(filtered.map((skill) => skill.name))
|
|
422
|
+
|
|
340
423
|
// Keyboard navigation (#1): reset highlight when the query changes, keep it
|
|
341
424
|
// in range when the result list shrinks, and keep the highlighted row visible.
|
|
342
425
|
useEffect(() => {
|
|
@@ -401,8 +484,9 @@ function SkillPickerButton(props) {
|
|
|
401
484
|
<div style={listStyle}>
|
|
402
485
|
{filtered.length === 0 ? (
|
|
403
486
|
<div style={statusStyle}>没有匹配的技能</div>
|
|
404
|
-
) : (
|
|
405
|
-
|
|
487
|
+
) : (() => {
|
|
488
|
+
let itemIndex = 0
|
|
489
|
+
const renderItem = (skill, index) => (
|
|
406
490
|
<button
|
|
407
491
|
key={skill.name}
|
|
408
492
|
type="button"
|
|
@@ -419,16 +503,73 @@ function SkillPickerButton(props) {
|
|
|
419
503
|
}}
|
|
420
504
|
style={{
|
|
421
505
|
...itemStyle,
|
|
506
|
+
flexDirection: 'row',
|
|
507
|
+
alignItems: 'center',
|
|
422
508
|
...(index === active
|
|
423
509
|
? { background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))' }
|
|
424
510
|
: {}),
|
|
425
511
|
}}
|
|
426
512
|
>
|
|
427
|
-
<span style={
|
|
428
|
-
|
|
513
|
+
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '2px', flex: '1', minWidth: '0' }}>
|
|
514
|
+
<span style={nameStyle}>{`/${skill.name}`}</span>
|
|
515
|
+
<span style={descStyle}>{skill.description ?? ''}</span>
|
|
516
|
+
</span>
|
|
517
|
+
<span
|
|
518
|
+
role="button"
|
|
519
|
+
tabIndex={-1}
|
|
520
|
+
title={pinned.includes(skill.name) ? '取消置顶' : '置顶到列表顶部'}
|
|
521
|
+
aria-label={pinned.includes(skill.name) ? '取消置顶' : '置顶'}
|
|
522
|
+
onClick={(event) => {
|
|
523
|
+
event.stopPropagation()
|
|
524
|
+
togglePin(skill.name)
|
|
525
|
+
}}
|
|
526
|
+
style={{
|
|
527
|
+
flex: 'none',
|
|
528
|
+
marginLeft: '6px',
|
|
529
|
+
padding: '2px 4px',
|
|
530
|
+
borderRadius: '6px',
|
|
531
|
+
fontSize: '12px',
|
|
532
|
+
lineHeight: '16px',
|
|
533
|
+
cursor: 'pointer',
|
|
534
|
+
color: pinned.includes(skill.name)
|
|
535
|
+
? 'var(--dsw-alias-label-primary-bluish, #4cc9f0)'
|
|
536
|
+
: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
537
|
+
opacity: pinned.includes(skill.name) ? 1 : 0.55,
|
|
538
|
+
userSelect: 'none',
|
|
539
|
+
}}
|
|
540
|
+
>
|
|
541
|
+
{pinned.includes(skill.name) ? '📌' : '📍'}
|
|
542
|
+
</span>
|
|
429
543
|
</button>
|
|
430
|
-
)
|
|
431
|
-
|
|
544
|
+
)
|
|
545
|
+
if (!showTitles) {
|
|
546
|
+
return filtered.map((skill) => renderItem(skill, itemIndex++))
|
|
547
|
+
}
|
|
548
|
+
return groups.map((group) => {
|
|
549
|
+
const items = group.items.filter((skill) => filteredNames.has(skill.name))
|
|
550
|
+
if (items.length === 0) return null
|
|
551
|
+
return (
|
|
552
|
+
<Fragment key={group.title}>
|
|
553
|
+
<div
|
|
554
|
+
style={{
|
|
555
|
+
display: 'flex',
|
|
556
|
+
alignItems: 'center',
|
|
557
|
+
justifyContent: 'space-between',
|
|
558
|
+
padding: '6px 10px 2px',
|
|
559
|
+
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
560
|
+
fontSize: '11px',
|
|
561
|
+
fontWeight: 600,
|
|
562
|
+
letterSpacing: '0.04em',
|
|
563
|
+
}}
|
|
564
|
+
>
|
|
565
|
+
<span>{group.title}</span>
|
|
566
|
+
<span style={{ opacity: 0.7 }}>{items.length}</span>
|
|
567
|
+
</div>
|
|
568
|
+
{items.map((skill) => renderItem(skill, itemIndex++))}
|
|
569
|
+
</Fragment>
|
|
570
|
+
)
|
|
571
|
+
})
|
|
572
|
+
})()}
|
|
432
573
|
</div>
|
|
433
574
|
{source === 'host' && (
|
|
434
575
|
<div style={sourceBadgeStyle} title="官方技能 API 不可用,列表来自本地目录扫描(与官方 / 补全同源)">
|
|
@@ -445,12 +586,6 @@ function SkillPickerButton(props) {
|
|
|
445
586
|
|
|
446
587
|
/** Apply the browser half: register the picker into the composer tool row. */
|
|
447
588
|
export function apply(ctx) {
|
|
448
|
-
// Resolve the trigger pipeline service at apply scope (not inside an effect),
|
|
449
|
-
// exactly like the official ui-skill plugin does — in DSH 0.1.2-alpha.x the
|
|
450
|
-
// service lives in @deepseek-ai/dsh-client-ui-input-trigger and is only
|
|
451
|
-
// reachable from the plugin root context.
|
|
452
|
-
const inputTriggers = (typeof ctx.get === 'function' ? ctx.get('inputTriggers') : ctx.inputTriggers)
|
|
453
|
-
|
|
454
589
|
// Primary skill source: the official host skills API. In DSH 0.1.2-alpha.x
|
|
455
590
|
// the RPC moved from `connection.api.skills` (rc.x) to `remote.skills`
|
|
456
591
|
// (used by the official ui-skill plugin); try both before falling back.
|
|
@@ -481,28 +616,6 @@ export function apply(ctx) {
|
|
|
481
616
|
}
|
|
482
617
|
}
|
|
483
618
|
|
|
484
|
-
// Host-route fallback: same scan the ⚡ panel uses (official provider roots).
|
|
485
|
-
const fetchHostSkills = async () => {
|
|
486
|
-
const cwd = typeof currentCwd === 'string' && currentCwd !== '' ? `?cwd=${encodeURIComponent(currentCwd)}` : ''
|
|
487
|
-
const res = await fetch(`/dsh-skill-picker/skills${cwd}`, { headers: { accept: 'application/json' } })
|
|
488
|
-
const json = await res.json()
|
|
489
|
-
if (!json.ok) throw new Error(json.error || 'bad response')
|
|
490
|
-
return Array.isArray(json.skills) ? json.skills : []
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// Resolve skills by official RPC first, then the host scan route; never
|
|
494
|
-
// throws (returns [] when both fail) so `/` completion degrades gracefully.
|
|
495
|
-
const resolveSkills = async (sessionId) => {
|
|
496
|
-
try {
|
|
497
|
-
return await listSkills(sessionId)
|
|
498
|
-
} catch {
|
|
499
|
-
try {
|
|
500
|
-
return await fetchHostSkills()
|
|
501
|
-
} catch {
|
|
502
|
-
return []
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
619
|
syncCwd()
|
|
507
620
|
const unsubscribe = ctx.sessions.list.subscribe(syncCwd)
|
|
508
621
|
ctx.effect(() => {
|
|
@@ -523,107 +636,57 @@ export function apply(ctx) {
|
|
|
523
636
|
}
|
|
524
637
|
}, 'dsh-skill-picker: composer input slot')
|
|
525
638
|
|
|
526
|
-
// Fuzzy `/` completion
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
639
|
+
// Fuzzy `/` completion: instead of registering a parallel source group
|
|
640
|
+
// (which would appear as a second list next to the official one), expose a
|
|
641
|
+
// global matcher that the patched official ui-skill candidates calls. The
|
|
642
|
+
// official group stays THE single `/` list; only its matching behaviour is
|
|
643
|
+
// upgraded to fuzzy + pinyin (name AND description, subsequence scoring).
|
|
530
644
|
ctx.effect(() => {
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
645
|
+
// Mirror the ⚡ panel's ordering: pinned first, then recently/frequently
|
|
646
|
+
// used skills, then the untouched rest — so both stay in sync.
|
|
647
|
+
const fuzzyMatch = (skills, query = '') => {
|
|
648
|
+
const ordered = groupByPinned(skills, loadUsage(), loadPinned()).flatMap((group) => group.items)
|
|
649
|
+
const q = String(query).trim().toLowerCase()
|
|
650
|
+
if (q === '') return ordered
|
|
651
|
+
// Rank by the ⚡ panel's exact order (pinned → recent → frequent → rest)
|
|
652
|
+
// so both lists stay in sync: fuzzysort only decides WHO matches, not
|
|
653
|
+
// the display order. Without this, a slash query re-sorts matches by
|
|
654
|
+
// match score and the two menus diverge for the same skill.
|
|
655
|
+
const rankByName = new Map(ordered.map((skill, index) => [skill.name, index]))
|
|
656
|
+
const targets = ordered.map((s) => ({
|
|
657
|
+
s,
|
|
658
|
+
search: `${s.name} ${s.description ?? ''} ${skillPinyinText(s.name, s.description ?? '')}`,
|
|
659
|
+
}))
|
|
660
|
+
const results = fuzzysort.go(q, targets, {
|
|
661
|
+
key: 'search',
|
|
662
|
+
limit: 30,
|
|
663
|
+
threshold: -10000,
|
|
664
|
+
})
|
|
665
|
+
return results
|
|
666
|
+
.filter((r) => r.score > 0)
|
|
667
|
+
.map((r) => r.obj.s)
|
|
668
|
+
.sort((a, b) => (rankByName.get(a.name) ?? 0) - (rankByName.get(b.name) ?? 0))
|
|
539
669
|
}
|
|
540
|
-
|
|
670
|
+
window.__dshSkillPickerFuzzy = fuzzyMatch
|
|
671
|
+
// Usage tracking for picks made from the official `/` menu: the patched
|
|
672
|
+
// ui-skill onPick calls this so a slash pick ranks like a bolt-panel pick.
|
|
673
|
+
const trackPick = (name) => {
|
|
674
|
+
const usage = loadUsage()
|
|
675
|
+
const nextUsage = { ...usage, [name]: { count: (usage[name]?.count ?? 0) + 1, lastUsed: Date.now() } }
|
|
676
|
+
saveUsage(nextUsage)
|
|
677
|
+
// Notify the bolt panel (and any other listeners) to re-read storage so
|
|
678
|
+
// a slash pick ranks as "recently used" there too, not only in the
|
|
679
|
+
// official / menu.
|
|
541
680
|
try {
|
|
542
|
-
|
|
543
|
-
namesCache.set(sessionId, (Array.isArray(skills) ? skills : []).map((s) => s.name))
|
|
544
|
-
notifyLexicon(sessionId)
|
|
681
|
+
window.dispatchEvent(new CustomEvent('dsh-skill-picker:usage-updated'))
|
|
545
682
|
} catch {
|
|
546
|
-
/*
|
|
683
|
+
/* best-effort */
|
|
547
684
|
}
|
|
548
685
|
}
|
|
549
|
-
|
|
550
|
-
const source = {
|
|
551
|
-
trigger: '/',
|
|
552
|
-
name: 'skill-fuzzy',
|
|
553
|
-
order: -10,
|
|
554
|
-
async candidates(session, { query, signal }) {
|
|
555
|
-
const skills = await resolveSkills(session.sessionId)
|
|
556
|
-
if (signal.aborted) return []
|
|
557
|
-
namesCache.set(session.sessionId, skills.map((s) => s.name))
|
|
558
|
-
notifyLexicon(session.sessionId)
|
|
559
|
-
const ordered = rankByUsage(skills, loadUsage())
|
|
560
|
-
const q = String(query ?? '').trim().toLowerCase()
|
|
561
|
-
if (q === '') {
|
|
562
|
-
// Empty `/` surfaces ALL skills, usage first (same rule as ⚡ panel).
|
|
563
|
-
return ordered.map((s) => ({ name: s.name, description: s.description }))
|
|
564
|
-
}
|
|
565
|
-
// Typing uses fuzzysort (subsequence matching + relevance score) over
|
|
566
|
-
// name AND description, so partial/gappy queries and keywords match.
|
|
567
|
-
// The search string also carries the skill's pinyin forms (spaced,
|
|
568
|
-
// joined, initials — name and description), so `ji yi` / `jiyi` / `jy`
|
|
569
|
-
// match Chinese skill names & descriptions.
|
|
570
|
-
const targets = ordered.map((s) => ({
|
|
571
|
-
s,
|
|
572
|
-
search: `${s.name} ${s.description ?? ''} ${skillPinyinText(s.name, s.description ?? '')}`,
|
|
573
|
-
}))
|
|
574
|
-
const results = fuzzysort.go(q, targets, {
|
|
575
|
-
key: 'search',
|
|
576
|
-
limit: 12,
|
|
577
|
-
threshold: -10000,
|
|
578
|
-
})
|
|
579
|
-
return results
|
|
580
|
-
.filter((r) => r.score > 0)
|
|
581
|
-
.map((r) => ({ name: r.obj.s.name, description: r.obj.s.description }))
|
|
582
|
-
},
|
|
583
|
-
warm(session) {
|
|
584
|
-
refreshNames(session.sessionId)
|
|
585
|
-
},
|
|
586
|
-
lexicon(session) {
|
|
587
|
-
return namesCache.get(session.sessionId)
|
|
588
|
-
},
|
|
589
|
-
subscribeLexicon(session, listener) {
|
|
590
|
-
const key = session.sessionId
|
|
591
|
-
const set = lexiconListeners.get(key) ?? new Set()
|
|
592
|
-
set.add(listener)
|
|
593
|
-
lexiconListeners.set(key, set)
|
|
594
|
-
return () => {
|
|
595
|
-
const cur = lexiconListeners.get(key)
|
|
596
|
-
if (cur !== undefined) {
|
|
597
|
-
cur.delete(listener)
|
|
598
|
-
if (cur.size === 0) lexiconListeners.delete(key)
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
},
|
|
602
|
-
onPick({ candidate }) {
|
|
603
|
-
// Record usage (same rule as the ⚡ panel) so slash-picked skills
|
|
604
|
-
// rank higher the next time they open `/`.
|
|
605
|
-
try {
|
|
606
|
-
const usage = loadUsage()
|
|
607
|
-
const name = candidate.name
|
|
608
|
-
const next = { ...usage, [name]: { count: (usage[name]?.count ?? 0) + 1, lastUsed: Date.now() } }
|
|
609
|
-
saveUsage(next)
|
|
610
|
-
} catch {
|
|
611
|
-
/* usage recording is best-effort */
|
|
612
|
-
}
|
|
613
|
-
return { text: `/${candidate.name} ` }
|
|
614
|
-
},
|
|
615
|
-
}
|
|
616
|
-
// inputTriggers is resolved at apply scope (see top of apply); fail loudly
|
|
617
|
-
// instead of silently dropping the fuzzy `/` source.
|
|
618
|
-
if (inputTriggers === undefined || typeof inputTriggers.registerSource !== 'function') {
|
|
619
|
-
console.warn('[dsh-skill-picker] inputTriggers service unavailable; fuzzy / completion disabled (⚡ panel still works)')
|
|
620
|
-
return
|
|
621
|
-
}
|
|
622
|
-
const unregister = inputTriggers.registerSource(source)
|
|
686
|
+
window.__dshSkillPickerTrack = trackPick
|
|
623
687
|
return () => {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
lexiconListeners.clear()
|
|
688
|
+
if (window.__dshSkillPickerFuzzy === fuzzyMatch) delete window.__dshSkillPickerFuzzy
|
|
689
|
+
if (window.__dshSkillPickerTrack === trackPick) delete window.__dshSkillPickerTrack
|
|
627
690
|
}
|
|
628
|
-
}, 'dsh-skill-picker: fuzzy / source')
|
|
691
|
+
}, 'dsh-skill-picker: fuzzy matcher for official / source')
|
|
629
692
|
}
|
package/src/index.js
CHANGED
|
@@ -22,6 +22,8 @@ import { readFile, readdir } from 'node:fs/promises'
|
|
|
22
22
|
import os from 'node:os'
|
|
23
23
|
import path from 'node:path'
|
|
24
24
|
|
|
25
|
+
import { healUiSkillPatches } from './patch-ui-skill.js'
|
|
26
|
+
|
|
25
27
|
/** Required services: the route registry and the prompt band. */
|
|
26
28
|
export const inject = ['webServer', 'systemPrompt']
|
|
27
29
|
|
|
@@ -138,4 +140,19 @@ export function apply(ctx) {
|
|
|
138
140
|
order: SECTION_ORDER,
|
|
139
141
|
text: SKILL_PICKER_GUIDANCE,
|
|
140
142
|
}), 'dsh-skill-picker: prompt section')
|
|
143
|
+
|
|
144
|
+
// Self-healing patch for the official ui-skill package: keeps the `/`
|
|
145
|
+
// completion's skill group ordered above commands and its matching fuzzy
|
|
146
|
+
// across DSH upgrades. Runs once per boot; idempotent, backed up, and
|
|
147
|
+
// never allowed to take the host down.
|
|
148
|
+
ctx.effect(() => {
|
|
149
|
+
healUiSkillPatches().then((report) => {
|
|
150
|
+
if (report.files.length > 0) {
|
|
151
|
+
console.log('[dsh-skill-picker] ui-skill patch report:', JSON.stringify(report))
|
|
152
|
+
}
|
|
153
|
+
}).catch((error) => {
|
|
154
|
+
console.warn('[dsh-skill-picker] ui-skill patch failed:', error)
|
|
155
|
+
})
|
|
156
|
+
return () => {}
|
|
157
|
+
}, 'dsh-skill-picker: ui-skill self-heal patch')
|
|
141
158
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-skill-picker — host-side self-healing patch for the official
|
|
3
|
+
* `@deepseek-ai/dsh-client-ui-skill` package.
|
|
4
|
+
*
|
|
5
|
+
* The picker upgrades the official `/` completion in two ways that the
|
|
6
|
+
* official package does not provide out of the box:
|
|
7
|
+
*
|
|
8
|
+
* 1. `order: 2 → -1` — the skill group sorts ABOVE the command group
|
|
9
|
+
* (commands register with the default order 0; lower = higher in the
|
|
10
|
+
* official menu).
|
|
11
|
+
* 2. fuzzy+pinyin candidates — the official prefix-only matcher
|
|
12
|
+
* (`skill.name.startsWith(query)`) is replaced by the picker's
|
|
13
|
+
* `window.__dshSkillPickerFuzzy` matcher when the picker is mounted
|
|
14
|
+
* (single source group, same list, upgraded matching).
|
|
15
|
+
*
|
|
16
|
+
* Every DSH boot this module scans every profile under `$DSH_HOME/profiles`
|
|
17
|
+
* (default `~/.dsh/profiles`) for an installed ui-skill `lib/client.js` —
|
|
18
|
+
* either the user's local patched copy (`local/dsh-client-ui-skill`) or the
|
|
19
|
+
* plain npm install (`node_modules/@deepseek-ai/dsh-client-ui-skill`) — and
|
|
20
|
+
* re-applies both patches when DSH upgrades overwrote them. The original file
|
|
21
|
+
* is backed up once as `<file>.dsh-skill-picker.bak` before the first write.
|
|
22
|
+
* All operations are idempotent and never throw: a missing profile, package
|
|
23
|
+
* or read failure is reported and skipped so a broken patch can never take
|
|
24
|
+
* the host down.
|
|
25
|
+
*
|
|
26
|
+
* @module dsh-skill-picker/patch-ui-skill
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFile, writeFile, copyFile, readdir, access } from 'node:fs/promises'
|
|
30
|
+
import os from 'node:os'
|
|
31
|
+
import path from 'node:path'
|
|
32
|
+
|
|
33
|
+
/** Marker that the candidates patch is already in place. */
|
|
34
|
+
const FUZZY_MARKER = '__dshSkillPickerFuzzy'
|
|
35
|
+
|
|
36
|
+
/** Marker that the pick-tracking patch is already in place. */
|
|
37
|
+
const TRACK_MARKER = '__dshSkillPickerTrack'
|
|
38
|
+
|
|
39
|
+
/** The self-healing patches, in application order. */
|
|
40
|
+
export const PATCHES = [
|
|
41
|
+
{
|
|
42
|
+
id: 'order',
|
|
43
|
+
title: 'skill group order 2 → -1 (above commands)',
|
|
44
|
+
isApplied(text) {
|
|
45
|
+
return /name: "skill",[\s\S]*?order:\s*-1,/.test(text)
|
|
46
|
+
},
|
|
47
|
+
apply(text) {
|
|
48
|
+
return text.replace(/(name: "skill",\s*order: )2,/, '$1-1,')
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'fuzzy-candidates',
|
|
53
|
+
title: 'prefix matcher → fuzzy+pinyin matcher',
|
|
54
|
+
isApplied(text) {
|
|
55
|
+
return text.includes(FUZZY_MARKER)
|
|
56
|
+
},
|
|
57
|
+
apply(text) {
|
|
58
|
+
return text.replace(
|
|
59
|
+
/(\t*)return skills\.filter\(\(skill\) => skill\.name\.startsWith\(query\)\)\.map\(\(skill\) => \(\{/,
|
|
60
|
+
(match, indent) =>
|
|
61
|
+
`${indent}// dsh-skill-picker patch: fuzzy+pinyin matcher (self-healed)\n` +
|
|
62
|
+
`${indent}const matcher = typeof window.${FUZZY_MARKER} === "function" ? window.${FUZZY_MARKER}(skills, query) : skills.filter((skill) => skill.name.startsWith(query));\n` +
|
|
63
|
+
`${indent}return matcher.map((skill) => ({`,
|
|
64
|
+
)
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'pick-tracking',
|
|
69
|
+
title: 'record usage when picked from the official / menu',
|
|
70
|
+
isApplied(text) {
|
|
71
|
+
return text.includes(TRACK_MARKER)
|
|
72
|
+
},
|
|
73
|
+
apply(text) {
|
|
74
|
+
return text.replace(
|
|
75
|
+
/(\t*)onPick\(\{ candidate \}\) \{\n(\t*)return \{ text: `\/\$\{candidate\.name\} ` \};\n(\t*)\}/,
|
|
76
|
+
(match, i1, i2, i3) =>
|
|
77
|
+
`${i1}onPick({ candidate }) {\n` +
|
|
78
|
+
`${i2} // dsh-skill-picker patch: usage tracking (self-healed)\n` +
|
|
79
|
+
`${i2} try { window.${TRACK_MARKER}?.(candidate.name) } catch { /* best-effort */ }\n` +
|
|
80
|
+
`${i2} return { text: \`/\${candidate.name} \` };\n` +
|
|
81
|
+
`${i3}}`,
|
|
82
|
+
)
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
/** Resolve the DSH home directory, mirroring the official convention. */
|
|
88
|
+
export function dshHome() {
|
|
89
|
+
return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Enumerate every installed ui-skill `lib/client.js` across all profiles:
|
|
94
|
+
* the user's local patched copy first (that is what the profile actually
|
|
95
|
+
* loads when linked), then the plain npm install. Deduplicated by real path.
|
|
96
|
+
* Never throws — a missing profiles dir yields [].
|
|
97
|
+
* @returns {Promise<string[]>} candidate file paths.
|
|
98
|
+
*/
|
|
99
|
+
export async function uiSkillClientPaths() {
|
|
100
|
+
const profilesDir = path.join(dshHome(), 'profiles')
|
|
101
|
+
let profiles
|
|
102
|
+
try {
|
|
103
|
+
profiles = await readdir(profilesDir, { withFileTypes: true })
|
|
104
|
+
} catch {
|
|
105
|
+
return []
|
|
106
|
+
}
|
|
107
|
+
const seen = new Set()
|
|
108
|
+
const found = []
|
|
109
|
+
for (const entry of profiles) {
|
|
110
|
+
if (!entry.isDirectory()) continue
|
|
111
|
+
const candidates = [
|
|
112
|
+
path.join(profilesDir, entry.name, 'local', 'dsh-client-ui-skill', 'lib', 'client.js'),
|
|
113
|
+
path.join(profilesDir, entry.name, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'),
|
|
114
|
+
]
|
|
115
|
+
for (const candidate of candidates) {
|
|
116
|
+
try {
|
|
117
|
+
await access(candidate)
|
|
118
|
+
const real = await import('node:fs/promises').then(({ realpath }) => realpath(candidate))
|
|
119
|
+
if (!seen.has(real)) {
|
|
120
|
+
seen.add(real)
|
|
121
|
+
found.push(candidate)
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
/* not present at this location */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return found
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Apply both patches to one ui-skill client.js. Idempotent: already-applied
|
|
133
|
+
* patches are reported as skipped; the original file is backed up once before
|
|
134
|
+
* the first modification. Never throws for a patch that does not match (it is
|
|
135
|
+
* reported as `noop`), only for actual I/O failures.
|
|
136
|
+
* @param {string} file - absolute path to the target client.js.
|
|
137
|
+
* @returns {Promise<{file: string, patched: string[], skipped: string[], noop: string[]}>}
|
|
138
|
+
*/
|
|
139
|
+
export async function patchUiSkillFile(file) {
|
|
140
|
+
const text = await readFile(file, 'utf8')
|
|
141
|
+
const result = { file, patched: [], skipped: [], noop: [] }
|
|
142
|
+
let next = text
|
|
143
|
+
for (const patch of PATCHES) {
|
|
144
|
+
if (patch.isApplied(next)) {
|
|
145
|
+
result.skipped.push(patch.id)
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
const candidate = patch.apply(next)
|
|
149
|
+
if (candidate === next) {
|
|
150
|
+
result.noop.push(patch.id)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
next = candidate
|
|
154
|
+
result.patched.push(patch.id)
|
|
155
|
+
}
|
|
156
|
+
if (result.patched.length === 0) return result
|
|
157
|
+
const backup = `${file}.dsh-skill-picker.bak`
|
|
158
|
+
try {
|
|
159
|
+
await access(backup)
|
|
160
|
+
} catch {
|
|
161
|
+
await copyFile(file, backup)
|
|
162
|
+
}
|
|
163
|
+
await writeFile(file, next, 'utf8')
|
|
164
|
+
return result
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Self-heal entry point: scan all profiles, patch every ui-skill copy found,
|
|
169
|
+
* and return one combined report. Never throws — each failure is collected
|
|
170
|
+
* into `errors` so the host boot is never taken down by a broken patch.
|
|
171
|
+
* @returns {Promise<{files: Array, errors: string[]}>}
|
|
172
|
+
*/
|
|
173
|
+
export async function healUiSkillPatches() {
|
|
174
|
+
const files = await uiSkillClientPaths()
|
|
175
|
+
const filesReport = []
|
|
176
|
+
const errors = []
|
|
177
|
+
for (const file of files) {
|
|
178
|
+
try {
|
|
179
|
+
filesReport.push(await patchUiSkillFile(file))
|
|
180
|
+
} catch (error) {
|
|
181
|
+
errors.push(`${file}: ${String(error?.message ?? error)}`)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { files: filesReport, errors }
|
|
185
|
+
}
|