@weibaohui/experts-management 0.1.3 → 0.2.0
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 +14 -8
- package/client/bundle.js +308 -107
- package/client/index.js +308 -107
- package/docs/demo.gif +0 -0
- package/package.json +2 -2
- package/src/index.js +101 -85
package/client/index.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-plugin-experts-management - Browser half.
|
|
3
3
|
*
|
|
4
|
-
* One React app for every surface (
|
|
5
|
-
*
|
|
4
|
+
* One React app for every surface (settings section; the former sidebar
|
|
5
|
+
* fullscreen-overlay entry was retired — pair with dsh-settings-ui):
|
|
6
|
+
* expert management page (mine / built-in views + detail modal + built-in
|
|
6
7
|
* sync settings). Plus the composer integration:
|
|
7
8
|
* - an `expert` input-trigger source on `/` (candidates from the plugin's own
|
|
8
9
|
* HTTP API; pick inserts the literal `/expert-<name> ` token whose send the
|
|
9
10
|
* host's user-explicit gesture boundary turns into the expert prompt), and
|
|
10
|
-
* - a
|
|
11
|
-
* that opens
|
|
11
|
+
* - a `+ 专家` button in the composer tool row (`conversation.input.left`)
|
|
12
|
+
* that opens the plugin's own searchable picker popover (the host slash menu
|
|
13
|
+
* filters only by a typed query, which a button click cannot provide); the
|
|
14
|
+
* pick is written into the draft through the same scoped
|
|
15
|
+
* `slash/input-insert-text` event the host menu uses.
|
|
12
16
|
*
|
|
13
17
|
* All interactive controls are host primitives
|
|
14
18
|
* (@deepseek-ai/dsh-client-ui-primitives); all colors come from the ui-theme
|
|
@@ -63,13 +67,13 @@ const prim = (name) => P && P[name]
|
|
|
63
67
|
const NS = 'expertsManagement'
|
|
64
68
|
|
|
65
69
|
const ZH = {
|
|
66
|
-
title: '
|
|
70
|
+
title: '专家管理',
|
|
67
71
|
close: '关闭',
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
tabMine: '我的',
|
|
73
|
+
tabBuiltin: '内置',
|
|
70
74
|
searchPlaceholder: '搜索专家名称、职业、描述…',
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
mineEmpty: '用户库还没有专家。去「内置」页浏览并安装。',
|
|
76
|
+
builtinEmpty: '内置专家为空。请在设置中同步内置仓库。',
|
|
73
77
|
expertTypeAgent: '专家',
|
|
74
78
|
expertTypeTeam: '团队',
|
|
75
79
|
sourceLabel: '来源',
|
|
@@ -97,10 +101,10 @@ const ZH = {
|
|
|
97
101
|
filesLabel: '文件',
|
|
98
102
|
versionLabel: '版本',
|
|
99
103
|
sourceReadonly: '只读来源(可在 NTD 中管理,或安装到用户库)',
|
|
100
|
-
|
|
104
|
+
builtinSettings: '内置设置',
|
|
101
105
|
syncNow: '立即同步',
|
|
102
106
|
syncing: '同步中,可能需要一分钟…',
|
|
103
|
-
syncDoneUpdated: '
|
|
107
|
+
syncDoneUpdated: '同步完成,内置已更新',
|
|
104
108
|
syncDoneLatest: '已是最新版本',
|
|
105
109
|
firstCloneDone: '首次克隆完成',
|
|
106
110
|
repoUrlLabel: '仓库地址',
|
|
@@ -123,18 +127,22 @@ const ZH = {
|
|
|
123
127
|
noDescription: '暂无描述',
|
|
124
128
|
avatarLoadFailed: '头像加载失败',
|
|
125
129
|
menuGroup: '专家',
|
|
126
|
-
pickExpert: '
|
|
130
|
+
pickExpert: '+ 专家',
|
|
127
131
|
pickExpertTitle: '选择一位专家,以该专家的身份执行本条任务',
|
|
132
|
+
pickerLoading: '正在加载专家目录…',
|
|
133
|
+
pickerEmpty: '没有匹配的专家',
|
|
134
|
+
pickerTabAgents: '专家',
|
|
135
|
+
pickerTabTeams: '专家团',
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
const EN = {
|
|
131
|
-
title: 'Expert
|
|
139
|
+
title: 'Expert Management',
|
|
132
140
|
close: 'Close',
|
|
133
|
-
|
|
134
|
-
|
|
141
|
+
tabMine: 'Mine',
|
|
142
|
+
tabBuiltin: 'Built-in',
|
|
135
143
|
searchPlaceholder: 'Search experts by name, profession, description…',
|
|
136
|
-
|
|
137
|
-
|
|
144
|
+
mineEmpty: 'No experts in the user library yet. Browse the Built-in tab and install one.',
|
|
145
|
+
builtinEmpty: 'Built-in experts are empty. Sync the built-in repo in settings.',
|
|
138
146
|
expertTypeAgent: 'Expert',
|
|
139
147
|
expertTypeTeam: 'Team',
|
|
140
148
|
sourceLabel: 'Source',
|
|
@@ -162,10 +170,10 @@ const EN = {
|
|
|
162
170
|
filesLabel: 'Files',
|
|
163
171
|
versionLabel: 'Version',
|
|
164
172
|
sourceReadonly: 'Read-only source (manage in NTD, or install into the user library)',
|
|
165
|
-
|
|
173
|
+
builtinSettings: 'Built-in settings',
|
|
166
174
|
syncNow: 'Sync now',
|
|
167
175
|
syncing: 'Syncing, may take a minute…',
|
|
168
|
-
syncDoneUpdated: 'Sync complete,
|
|
176
|
+
syncDoneUpdated: 'Sync complete, built-in updated',
|
|
169
177
|
syncDoneLatest: 'Already up to date',
|
|
170
178
|
firstCloneDone: 'First clone done',
|
|
171
179
|
repoUrlLabel: 'Repository URL',
|
|
@@ -190,11 +198,15 @@ const EN = {
|
|
|
190
198
|
menuGroup: 'Experts',
|
|
191
199
|
pickExpert: '+ Expert',
|
|
192
200
|
pickExpertTitle: 'Pick an expert to handle this message in their persona',
|
|
201
|
+
pickerLoading: 'Loading experts…',
|
|
202
|
+
pickerEmpty: 'No matching experts',
|
|
203
|
+
pickerTabAgents: 'Experts',
|
|
204
|
+
pickerTabTeams: 'Teams',
|
|
193
205
|
}
|
|
194
206
|
|
|
195
207
|
// ── Styles ───────────────────────────────────────────────────────────────
|
|
196
208
|
|
|
197
|
-
const STYLE =
|
|
209
|
+
const STYLE = `<style>
|
|
198
210
|
.exp-page{position:relative;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:var(--dsw-font-sm-14,14px)}
|
|
199
211
|
.exp-toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
200
212
|
.exp-tabs{display:flex;gap:4px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:3px}
|
|
@@ -238,7 +250,6 @@ const STYLE = `
|
|
|
238
250
|
.exp-member-avatar{width:32px;height:32px;border-radius:8px;object-fit:cover;background:var(--dsw-alias-bg-layer-3)}
|
|
239
251
|
.exp-skill-row{display:flex;flex-direction:column;gap:2px;padding:8px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
240
252
|
.exp-skill-row:last-child{border-bottom:0}
|
|
241
|
-
.exp-settings{display:flex;flex-direction:column;gap:10px;padding:14px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:14px}
|
|
242
253
|
.exp-form-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
|
243
254
|
.exp-form-row label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary);flex:1;min-width:160px}
|
|
244
255
|
.exp-input{background:var(--dsw-alias-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 10px;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;width:100%}
|
|
@@ -248,7 +259,23 @@ const STYLE = `
|
|
|
248
259
|
.exp-checkline input{accent-color:var(--dsw-alias-brand-primary)}
|
|
249
260
|
.exp-chip{display:inline-flex;align-items:center;gap:4px;height:26px;padding:0 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer;white-space:nowrap}
|
|
250
261
|
.exp-chip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
251
|
-
|
|
262
|
+
.exp-picker-backdrop{position:fixed;inset:0;z-index:2147483200}
|
|
263
|
+
.exp-picker{position:fixed;z-index:2147483201;width:400px;max-width:92vw;max-height:360px;display:flex;flex-direction:column;gap:4px;padding:6px;background:var(--dsw-specific-menu);border:1px solid var(--dsw-alias-border-inverted);border-radius:12px;box-shadow:var(--dsw-shadow-lv3)}
|
|
264
|
+
.exp-picker-input{flex:none}
|
|
265
|
+
.exp-picker-tabs{flex:none;align-self:flex-start}
|
|
266
|
+
.exp-picker-list{display:flex;flex-direction:column;min-height:40px;overflow-y:auto}
|
|
267
|
+
.exp-picker-row{display:flex;align-items:center;gap:8px;width:100%;min-height:36px;padding:6px 10px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);text-align:left;cursor:pointer;font:inherit;font-size:13px}
|
|
268
|
+
.exp-picker-row[data-active="true"]{background:var(--dsw-alias-interactive-bg-hover)}
|
|
269
|
+
.exp-picker-icon{width:18px;flex:none;text-align:center}
|
|
270
|
+
.exp-picker-name{flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
|
|
271
|
+
.exp-picker-literal{flex:none;max-width:32%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:11px}
|
|
272
|
+
.exp-picker-desc{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px}
|
|
273
|
+
.exp-picker-empty{padding:12px 10px;text-align:center;color:var(--dsw-alias-label-dimmed);font-size:13px}
|
|
274
|
+
.exp-overlay{position:fixed;inset:0;z-index:2147483000;background:var(--dsw-alias-bg-base);overflow:auto;padding:20px 26px}
|
|
275
|
+
.exp-head{display:flex;align-items:center;gap:10px;margin-bottom:4px}
|
|
276
|
+
.exp-title{font-weight:600;font-size:16px}
|
|
277
|
+
.exp-spacer{flex:1}
|
|
278
|
+
</style>`
|
|
252
279
|
|
|
253
280
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
254
281
|
|
|
@@ -296,17 +323,51 @@ let expertRosterAt = 0
|
|
|
296
323
|
const rosterListeners = new Set()
|
|
297
324
|
const ROSTER_TTL = 60_000
|
|
298
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Roster row mapping (pure, testable). The host slash menu renders only
|
|
328
|
+
* name + description per row, so the human-readable displayName is prefixed
|
|
329
|
+
* into the description — expert teams (👥) and single experts both show
|
|
330
|
+
* their real name next to the `expert-<id>` literal. The picker popover
|
|
331
|
+
* renders `displayName` as the primary label and `plainDescription` as the
|
|
332
|
+
* secondary line instead. Rows come out sorted by displayName(中文按拼音),
|
|
333
|
+
* the picker splits them into 专家/专家团 tabs by `team`.
|
|
334
|
+
*/
|
|
335
|
+
function toRosterRows(mine, builtin) {
|
|
336
|
+
const rows = [...(Array.isArray(mine) ? mine : []), ...(Array.isArray(builtin) ? builtin : [])].map((e) => {
|
|
337
|
+
const displayName = e.displayName || e.name
|
|
338
|
+
const desc = e.description || e.profession || ''
|
|
339
|
+
return {
|
|
340
|
+
name: `expert-${e.name}`,
|
|
341
|
+
displayName,
|
|
342
|
+
description: displayName && displayName !== e.name ? (desc === '' ? displayName : `${displayName} · ${desc}`) : desc,
|
|
343
|
+
plainDescription: desc,
|
|
344
|
+
team: e.expertType === 'team',
|
|
345
|
+
icon: e.expertType === 'team' ? '👥' : '🧑💼',
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
rows.sort((a, b) => String(a.displayName).localeCompare(String(b.displayName), 'zh'))
|
|
349
|
+
return rows
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Picker tab split (pure, testable): single experts vs expert teams. */
|
|
353
|
+
function splitRosterByType(rows) {
|
|
354
|
+
const agents = [], teams = []
|
|
355
|
+
for (const r of Array.isArray(rows) ? rows : []) (r.team ? teams : agents).push(r)
|
|
356
|
+
return { agents, teams }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Picker filter predicate (pure): match the displayed fields, not the `expert-` prefix. */
|
|
360
|
+
function pickerRowMatch(r, lower) {
|
|
361
|
+
return matchExpert({ name: r.name.replace(/^expert-/, ''), displayName: r.displayName, description: r.plainDescription }, lower)
|
|
362
|
+
}
|
|
363
|
+
|
|
299
364
|
async function fetchRoster(force) {
|
|
300
365
|
if (!force && expertRoster !== null && Date.now() - expertRosterAt < ROSTER_TTL) return expertRoster
|
|
301
366
|
try {
|
|
302
367
|
const data = await fetchJson(API)
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
expertRoster =
|
|
306
|
-
name: `expert-${e.name}`,
|
|
307
|
-
description: e.description || e.profession || '',
|
|
308
|
-
icon: e.expertType === 'team' ? '👥' : '🧑💼',
|
|
309
|
-
}))
|
|
368
|
+
const mine = Array.isArray(data.mine) ? data.mine : []
|
|
369
|
+
const builtin = (Array.isArray(data.builtin) ? data.builtin : []).filter((e) => !e.installed)
|
|
370
|
+
expertRoster = toRosterRows(mine, builtin)
|
|
310
371
|
expertRosterAt = Date.now()
|
|
311
372
|
for (const listener of [...rosterListeners]) { try { listener() } catch {} }
|
|
312
373
|
} catch { /* 菜单失败静默:候选组保持 pending/缺席 */ }
|
|
@@ -346,6 +407,10 @@ function makeExpertSource(t) {
|
|
|
346
407
|
* end (host toggleCommandMenu 同款调用形状)。The standard kit exposes no
|
|
347
408
|
* caret, so the pick replaces a collapsed span at draft end — picks die
|
|
348
409
|
* quietly on span-CAS if the draft moved since the click.
|
|
410
|
+
*
|
|
411
|
+
* NOTE: the + 专家 button no longer uses this — the host menu cannot offer
|
|
412
|
+
* a search box, so the button opens ExpertPicker instead. Kept for the
|
|
413
|
+
* contract tests and as the documented toggleSource path.
|
|
349
414
|
*/
|
|
350
415
|
function openTriggerSource(composerScope, sessionId, input, sourceName) {
|
|
351
416
|
const inputTriggers = composerScope && composerScope.inputTriggers
|
|
@@ -368,6 +433,115 @@ function openTriggerSource(composerScope, sessionId, input, sourceName) {
|
|
|
368
433
|
return true
|
|
369
434
|
}
|
|
370
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Insert `text` at the end of the session draft through the same scoped
|
|
438
|
+
* event the host slash menu executes (`slash/input-insert-text`). The span
|
|
439
|
+
* CAS uses the freshest input snapshot handed to the slot props — while the
|
|
440
|
+
* picker popover is open the composer draft cannot move (focus is in the
|
|
441
|
+
* picker), so the splice applies; a stale snapshot quietly no-ops, same as
|
|
442
|
+
* the host menu's span-CAS.
|
|
443
|
+
*/
|
|
444
|
+
function insertComposerText(scope, sessionId, input, text) {
|
|
445
|
+
const sessions = scope && scope.sessions
|
|
446
|
+
if (!sessions) return false
|
|
447
|
+
let actx
|
|
448
|
+
try { actx = sessions.scope(sessionId) } catch { return false }
|
|
449
|
+
if (actx === undefined || actx === null || typeof actx.bail !== 'function') return false
|
|
450
|
+
const draft = (input && input.draft) || ''
|
|
451
|
+
const at = draft.length
|
|
452
|
+
try {
|
|
453
|
+
return actx.bail(actx, 'slash/input-insert-text', {
|
|
454
|
+
text,
|
|
455
|
+
span: { start: at, end: at, draftRev: (input && input.draftRev) || 0 },
|
|
456
|
+
}) === true
|
|
457
|
+
} catch { return false }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Best-effort refocus of the composer textarea after the picker closes. */
|
|
461
|
+
function refocusComposer() {
|
|
462
|
+
try {
|
|
463
|
+
const card = document.querySelector('[data-composer-card]')
|
|
464
|
+
const ta = card && card.querySelector('textarea')
|
|
465
|
+
if (ta && typeof ta.focus === 'function') ta.focus()
|
|
466
|
+
} catch {}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Picker popover list cap — beyond this the search input is the filter. */
|
|
470
|
+
const PICKER_ROW_CAP = 200
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* + 专家 picker:锚定在按钮上方、自带搜索框的候选浮层(portal 到 body)。
|
|
474
|
+
* 宿主斜杠菜单靠「输入的 query」过滤,按钮打开的菜单没有输入载体——候选
|
|
475
|
+
* 太多时无从筛选,所以浮层自带搜索框。专家/专家团分两个 tab(tab 标签上的
|
|
476
|
+
* 计数跟随当前搜索过滤),各 tab 内按显示名排序(toRosterRows 已排好)。
|
|
477
|
+
* 键盘 ↑/↓/Enter/Esc,鼠标 hover+点击;tab 按钮 mousedown 不抢输入框焦点。
|
|
478
|
+
*/
|
|
479
|
+
function ExpertPicker(props) {
|
|
480
|
+
const t = props.t
|
|
481
|
+
const [query, setQuery] = useState('')
|
|
482
|
+
const [tab, setTab] = useState('agent') // 'agent' | 'team'
|
|
483
|
+
const [active, setActive] = useState(0)
|
|
484
|
+
const inputRef = useRef(null)
|
|
485
|
+
const listRef = useRef(null)
|
|
486
|
+
useEffect(() => { try { if (inputRef.current) inputRef.current.focus() } catch {} }, [])
|
|
487
|
+
useEffect(() => {
|
|
488
|
+
const onKey = (e) => { if (e.key === 'Escape' && !(e.isComposing === true)) props.onClose() }
|
|
489
|
+
try { document.addEventListener('keydown', onKey) } catch {}
|
|
490
|
+
return () => { try { document.removeEventListener('keydown', onKey) } catch {} }
|
|
491
|
+
}, [])
|
|
492
|
+
const lower = query.trim().toLowerCase()
|
|
493
|
+
const { agents, teams } = splitRosterByType(props.rows)
|
|
494
|
+
const filteredAgents = lower === '' ? agents : agents.filter((r) => pickerRowMatch(r, lower))
|
|
495
|
+
const filteredTeams = lower === '' ? teams : teams.filter((r) => pickerRowMatch(r, lower))
|
|
496
|
+
const shown = (tab === 'team' ? filteredTeams : filteredAgents).slice(0, PICKER_ROW_CAP)
|
|
497
|
+
useEffect(() => { setActive(0) }, [lower, tab, props.rows])
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
const list = listRef.current
|
|
500
|
+
const el = list && list.children[active]
|
|
501
|
+
if (el && typeof el.scrollIntoView === 'function') { try { el.scrollIntoView({ block: 'nearest' }) } catch {} }
|
|
502
|
+
}, [active])
|
|
503
|
+
const onKeyDown = (e) => {
|
|
504
|
+
// IME 组词中的按键不触发选择(回车是选定拼音候选,不是 pick)
|
|
505
|
+
if (e.nativeEvent && e.nativeEvent.isComposing === true) return
|
|
506
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); setActive((i) => Math.min(i + 1, shown.length - 1)) }
|
|
507
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)) }
|
|
508
|
+
else if (e.key === 'Enter') { e.preventDefault(); const row = shown[active]; if (row) props.onPick(row) }
|
|
509
|
+
}
|
|
510
|
+
const width = 400
|
|
511
|
+
const winW = typeof window !== 'undefined' ? window.innerWidth : 800
|
|
512
|
+
const winH = typeof window !== 'undefined' ? window.innerHeight : 600
|
|
513
|
+
const left = Math.max(8, Math.min(props.anchor.left, winW - width - 8))
|
|
514
|
+
const bottom = Math.max(8, winH - props.anchor.top + 6)
|
|
515
|
+
const tabBtn = (key, labelKey, count) => h('button', {
|
|
516
|
+
type: 'button', role: 'tab', className: 'exp-tab', 'data-on': tab === key, 'aria-selected': tab === key,
|
|
517
|
+
onMouseDown: (e) => { e.preventDefault(); setTab(key) }, onClick: () => setTab(key),
|
|
518
|
+
}, `${t(labelKey)} (${count})`)
|
|
519
|
+
return h('div', { className: 'exp-picker-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) props.onClose() } },
|
|
520
|
+
h('div', { className: 'exp-picker', style: { left, bottom, width }, role: 'dialog', 'aria-label': t('pickExpertTitle') },
|
|
521
|
+
h('input', {
|
|
522
|
+
ref: inputRef, className: 'exp-input exp-picker-input', value: query,
|
|
523
|
+
placeholder: t('searchPlaceholder'), onChange: (e) => setQuery(e.target.value), onKeyDown,
|
|
524
|
+
}),
|
|
525
|
+
h('div', { className: 'exp-tabs exp-picker-tabs', role: 'tablist' },
|
|
526
|
+
tabBtn('agent', 'pickerTabAgents', filteredAgents.length),
|
|
527
|
+
tabBtn('team', 'pickerTabTeams', filteredTeams.length)),
|
|
528
|
+
h('div', { className: 'exp-picker-list', ref: listRef, role: 'listbox' },
|
|
529
|
+
props.rows === null
|
|
530
|
+
? h('div', { className: 'exp-picker-empty' }, t('pickerLoading'))
|
|
531
|
+
: shown.length === 0
|
|
532
|
+
? h('div', { className: 'exp-picker-empty' }, t('pickerEmpty'))
|
|
533
|
+
: shown.map((row, i) => h('button', {
|
|
534
|
+
key: row.name, type: 'button', role: 'option', 'aria-selected': i === active,
|
|
535
|
+
className: 'exp-picker-row', 'data-active': i === active,
|
|
536
|
+
onMouseEnter: () => setActive(i),
|
|
537
|
+
onMouseDown: (e) => { e.preventDefault(); props.onPick(row) },
|
|
538
|
+
},
|
|
539
|
+
row.icon ? h('span', { className: 'exp-picker-icon', 'aria-hidden': 'true' }, row.icon) : null,
|
|
540
|
+
h('span', { className: 'exp-picker-name' }, row.displayName || row.name),
|
|
541
|
+
row.displayName && row.displayName !== row.name ? h('span', { className: 'exp-picker-literal' }, row.name) : null,
|
|
542
|
+
h('span', { className: 'exp-picker-desc' }, row.plainDescription || ''))))))
|
|
543
|
+
}
|
|
544
|
+
|
|
371
545
|
// ── Small components ─────────────────────────────────────────────────────
|
|
372
546
|
|
|
373
547
|
function Avatar({ expert, size }) {
|
|
@@ -531,16 +705,16 @@ function DetailModal({ name, source, t, onClose, onInstalled, onDeleted }) {
|
|
|
531
705
|
) : null))
|
|
532
706
|
}
|
|
533
707
|
|
|
534
|
-
function
|
|
708
|
+
function BuiltinSettingsDialog({ t, onClose, onToast, onSynced }) {
|
|
535
709
|
const [status, setStatus] = useState(null)
|
|
536
710
|
const [form, setForm] = useState(null)
|
|
537
711
|
const [busy, setBusy] = useState(false)
|
|
538
|
-
const load = () => fetchJson(`${API}/
|
|
712
|
+
const load = () => fetchJson(`${API}/builtin/status`).then((s) => { setStatus(s); setForm((f) => f ?? { url: s.url, branch: s.branch, repoDir: s.dir, token: '', autoSync: s.autoSync, syncOnStartup: s.syncOnStartup }) })
|
|
539
713
|
useEffect(() => { load() }, [])
|
|
540
714
|
const sync = async () => {
|
|
541
715
|
setBusy(true)
|
|
542
716
|
try {
|
|
543
|
-
const r = await fetchJson(`${API}/
|
|
717
|
+
const r = await fetchJson(`${API}/builtin/sync`, { method: 'POST' })
|
|
544
718
|
onToast(r.isFirstClone ? t('firstCloneDone') : r.hasUpdates ? t('syncDoneUpdated') : t('syncDoneLatest'))
|
|
545
719
|
await load()
|
|
546
720
|
onSynced()
|
|
@@ -554,51 +728,72 @@ function MarketSettingsCard({ t, onToast, onSynced }) {
|
|
|
554
728
|
const dirText = (form.repoDir || '').trim()
|
|
555
729
|
if (dirText !== '' && dirText !== (status && status.dir)) patch.repoDir = dirText
|
|
556
730
|
if (typeof form.token === 'string' && form.token !== '') patch.token = form.token
|
|
557
|
-
|
|
558
|
-
await fetchJson(`${API}/market/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
|
|
731
|
+
await fetchJson(`${API}/builtin/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
|
|
559
732
|
setForm((f) => ({ ...f, token: '' }))
|
|
560
733
|
onToast(t('saved'))
|
|
561
734
|
await load()
|
|
562
735
|
} catch (e) { onToast(String(e && e.message)) }
|
|
563
736
|
setBusy(false)
|
|
564
737
|
}
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
h('div', { className: 'exp-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
h('
|
|
585
|
-
h('
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
738
|
+
const clearToken = async () => {
|
|
739
|
+
setBusy(true)
|
|
740
|
+
try {
|
|
741
|
+
await fetchJson(`${API}/builtin/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: null }) })
|
|
742
|
+
onToast(t('saved'))
|
|
743
|
+
await load()
|
|
744
|
+
} catch (e) { onToast(String(e && e.message)) }
|
|
745
|
+
setBusy(false)
|
|
746
|
+
}
|
|
747
|
+
// 布局逐行对齐技能市场的市场设置弹窗:状态区 = 左标签/右值成行;
|
|
748
|
+
// 输入区 = 全宽堆叠、placeholder 即标签;底部 = 右对齐 保存 + 立即同步(主按钮)。
|
|
749
|
+
const short = (c) => (c ? String(c).slice(0, 8) : '-')
|
|
750
|
+
const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
|
|
751
|
+
h('span', { className: 'exp-profession' }, label), h('span', { style: { wordBreak: 'break-all', textAlign: 'right', fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, value))
|
|
752
|
+
return h('div', { className: 'exp-modal-backdrop', onClick: (e) => { if (e.target === e.currentTarget) onClose() } },
|
|
753
|
+
h('div', { className: 'exp-modal', style: { maxWidth: 640 } },
|
|
754
|
+
h('div', { className: 'exp-modal-head' },
|
|
755
|
+
h('span', { className: 'exp-title' }, t('builtinSettings')),
|
|
756
|
+
h('button', { className: 'exp-btn exp-modal-close', onClick: onClose }, t('close'))),
|
|
757
|
+
status === null ? h('div', { className: 'exp-empty' }, '…')
|
|
758
|
+
: h('div', { style: { display: 'contents' } },
|
|
759
|
+
!status.gitAvailable ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12 } }, t('gitMissing')) : null,
|
|
760
|
+
h('div', null,
|
|
761
|
+
row(t('repoUrlLabel'), status.url),
|
|
762
|
+
row(t('branchLabel'), status.branch),
|
|
763
|
+
row(t('localCommitLabel'), short(status.localCommit)),
|
|
764
|
+
status.remoteCommit ? h('div', { style: { display: 'flex', justifyContent: 'flex-end' } },
|
|
765
|
+
status.needsUpdate ? Badge({ kind: 'type', children: t('needsUpdateTag') }) : null) : null,
|
|
766
|
+
row(t('remoteCommitLabel'), short(status.remoteCommit)),
|
|
767
|
+
row(t('lastSyncLabel'), status.lastSyncAt ? formatTime(status.lastSyncAt) : t('never')),
|
|
768
|
+
row(t('repoDirLabel'), status.dir),
|
|
769
|
+
status.sparsePaths ? row('sparse', (status.sparsePaths || []).join(', ')) : null),
|
|
770
|
+
h('div', { style: { display: 'flex', gap: 16, flexWrap: 'wrap' } },
|
|
771
|
+
h('label', { className: 'exp-checkline' },
|
|
772
|
+
h('input', { type: 'checkbox', checked: !!form.autoSync, onChange: (e) => setForm({ ...form, autoSync: e.target.checked }) }), t('autoSyncLabel')),
|
|
773
|
+
h('label', { className: 'exp-checkline' },
|
|
774
|
+
h('input', { type: 'checkbox', checked: !!form.syncOnStartup, onChange: (e) => setForm({ ...form, syncOnStartup: e.target.checked }) }), t('syncOnStartupLabel'))),
|
|
775
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
|
|
776
|
+
h('input', { className: 'exp-input', value: form.url ?? '', placeholder: t('repoUrlLabel'), onChange: (e) => setForm({ ...form, url: e.target.value }), style: { width: '100%', boxSizing: 'border-box' } }),
|
|
777
|
+
h('input', { className: 'exp-input', value: form.branch ?? '', placeholder: t('branchLabel'), onChange: (e) => setForm({ ...form, branch: e.target.value }), style: { width: '100%', boxSizing: 'border-box' } }),
|
|
778
|
+
h('input', { className: 'exp-input', value: form.repoDir ?? '', placeholder: t('repoDirLabel'), onChange: (e) => setForm({ ...form, repoDir: e.target.value }), style: { width: '100%', boxSizing: 'border-box' } }),
|
|
779
|
+
h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
780
|
+
h('input', { className: 'exp-input', type: 'password', value: form.token ?? '', onChange: (e) => setForm({ ...form, token: e.target.value }),
|
|
781
|
+
placeholder: status.hasToken ? `${t('tokenLabel')} · ${t('tokenConfigured')}` : t('tokenLabel'), style: { flex: 1 } }),
|
|
782
|
+
status.hasToken ? h('button', { className: 'exp-btn', disabled: busy, onClick: clearToken }, t('clearToken')) : null)),
|
|
783
|
+
h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 } },
|
|
784
|
+
h('button', { className: 'exp-btn', disabled: busy, onClick: save }, t('save')),
|
|
785
|
+
h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: busy || status.syncing, onClick: sync }, busy || status.syncing ? t('syncing') : t('syncNow'))))))
|
|
592
786
|
}
|
|
593
787
|
|
|
594
788
|
// ── Page ─────────────────────────────────────────────────────────────────
|
|
595
789
|
|
|
596
|
-
function ExpertsPage({ t, embedded }) {
|
|
597
|
-
const [tab, setTab] = useState('
|
|
790
|
+
function ExpertsPage({ t, embedded, onClose }) {
|
|
791
|
+
const [tab, setTab] = useState('builtin')
|
|
598
792
|
const [data, setData] = useState(null)
|
|
599
793
|
const [error, setError] = useState('')
|
|
600
794
|
const [search, setSearch] = useState('')
|
|
601
795
|
const [selected, setSelected] = useState(null) // {name, source}
|
|
796
|
+
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
602
797
|
const [busyName, setBusyName] = useState(null)
|
|
603
798
|
const [toast, setToast] = useState(null)
|
|
604
799
|
const showToast = (text) => { setToast(text); setTimeout(() => setToast(null), 2600) }
|
|
@@ -607,7 +802,7 @@ function ExpertsPage({ t, embedded }) {
|
|
|
607
802
|
const rows = useMemo(() => {
|
|
608
803
|
if (!data) return []
|
|
609
804
|
const lower = search.trim().toLowerCase()
|
|
610
|
-
const list = tab === '
|
|
805
|
+
const list = tab === 'mine' ? data.mine : data.builtin
|
|
611
806
|
return list.filter((e) => matchExpert(e, lower))
|
|
612
807
|
}, [data, tab, search])
|
|
613
808
|
const install = async (row) => {
|
|
@@ -637,16 +832,20 @@ function ExpertsPage({ t, embedded }) {
|
|
|
637
832
|
} catch (e) { showToast(String(e && e.message)) }
|
|
638
833
|
setBusyName(null)
|
|
639
834
|
}
|
|
640
|
-
return h('div', { className: 'exp-page' },
|
|
835
|
+
return h('div', { className: 'exp-page' + (embedded ? '' : ' exp-overlay') },
|
|
836
|
+
!embedded ? h('div', { className: 'exp-head' },
|
|
837
|
+
h('span', { className: 'exp-title' }, t('title')),
|
|
838
|
+
h('span', { className: 'exp-spacer' }),
|
|
839
|
+
h('button', { className: 'exp-btn', onClick: () => { if (onClose) onClose() } }, t('close'))) : null,
|
|
641
840
|
h('div', { className: 'exp-toolbar' },
|
|
642
841
|
h('div', { className: 'exp-tabs' },
|
|
643
|
-
h('button', { className: 'exp-tab', 'data-on': tab === '
|
|
644
|
-
h('button', { className: 'exp-tab', 'data-on': tab === '
|
|
842
|
+
h('button', { className: 'exp-tab', 'data-on': tab === 'mine', onClick: () => setTab('mine') }, `${t('tabMine')}${data ? ` (${data.mine.length})` : ''}`),
|
|
843
|
+
h('button', { className: 'exp-tab', 'data-on': tab === 'builtin', onClick: () => setTab('builtin') }, `${t('tabBuiltin')}${data ? ` (${data.builtin.length})` : ''}`)),
|
|
645
844
|
h('input', { className: 'exp-input exp-search', placeholder: t('searchPlaceholder'), value: search, onChange: (e) => setSearch(e.target.value) }),
|
|
646
|
-
h('span', { className: 'exp-count' }, `${rows.length}`)
|
|
647
|
-
|
|
845
|
+
h('span', { className: 'exp-count' }, `${rows.length}`),
|
|
846
|
+
tab === 'builtin' ? h('button', { className: 'exp-btn', title: t('builtinSettings'), onClick: () => setSettingsOpen(true) }, t('builtinSettings')) : null),
|
|
648
847
|
error !== '' ? h('div', { className: 'exp-empty' }, `${t('loadFailed')}: ${error}`) : null,
|
|
649
|
-
data !== null && rows.length === 0 ? h('div', { className: 'exp-empty' }, tab === '
|
|
848
|
+
data !== null && rows.length === 0 ? h('div', { className: 'exp-empty' }, tab === 'mine' ? t('mineEmpty') : t('builtinEmpty')) : null,
|
|
650
849
|
rows.length > 0
|
|
651
850
|
? h(PagedGrid, {
|
|
652
851
|
items: rows,
|
|
@@ -658,6 +857,9 @@ function ExpertsPage({ t, embedded }) {
|
|
|
658
857
|
onInstalled: () => { setSelected(null); showToast(t('installedDone')); reload() },
|
|
659
858
|
onDeleted: () => { setSelected(null); showToast(t('removedDone')); reload() },
|
|
660
859
|
}) : null,
|
|
860
|
+
settingsOpen ? h(BuiltinSettingsDialog, {
|
|
861
|
+
t, onClose: () => setSettingsOpen(false), onToast: showToast, onSynced: reload,
|
|
862
|
+
}) : null,
|
|
661
863
|
toast !== null ? h('div', { className: 'exp-toast' }, toast) : null)
|
|
662
864
|
}
|
|
663
865
|
|
|
@@ -669,6 +871,7 @@ module.exports = {
|
|
|
669
871
|
__internals: {
|
|
670
872
|
NS, ZH, EN, matchExpert, formatSize, formatTime, avatarUrl,
|
|
671
873
|
EXPERT_SOURCE_NAME, makeExpertSource, openTriggerSource, fetchRoster,
|
|
874
|
+
toRosterRows, insertComposerText, splitRosterByType, pickerRowMatch,
|
|
672
875
|
},
|
|
673
876
|
/** Test/host helper: mount a standalone page into any container. */
|
|
674
877
|
__boot(container, opts = {}) {
|
|
@@ -720,20 +923,6 @@ module.exports = {
|
|
|
720
923
|
})
|
|
721
924
|
}
|
|
722
925
|
} catch (e) { try { console.error('[experts-management] composer inject:', e) } catch {} }
|
|
723
|
-
ctx.effect(() => {
|
|
724
|
-
try {
|
|
725
|
-
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
|
726
|
-
name: 'sidebar.footer.action',
|
|
727
|
-
id: CLIENT_NAME,
|
|
728
|
-
order: 60,
|
|
729
|
-
locale: NS,
|
|
730
|
-
label: () => t('title'),
|
|
731
|
-
inject: () => ({ t }),
|
|
732
|
-
}, function FooterSlot(apiProps) {
|
|
733
|
-
return h(FooterSlotComponent, { __t: t, wide: apiProps && apiProps.wide })
|
|
734
|
-
}))
|
|
735
|
-
} catch (e) { (globalThis.__expErrors = globalThis.__expErrors || []).push('footer:' + (e && e.message)); throw e }
|
|
736
|
-
}, 'experts-management: sidebar footer action')
|
|
737
926
|
ctx.effect(() => {
|
|
738
927
|
try {
|
|
739
928
|
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
@@ -759,8 +948,8 @@ module.exports = {
|
|
|
759
948
|
inject: () => ({ t }),
|
|
760
949
|
}, function ExpertButtonSlot(apiProps) {
|
|
761
950
|
return h(ComposerButtonSlot, {
|
|
762
|
-
__t: t,
|
|
763
|
-
|
|
951
|
+
__t: t, label: t('pickExpert'), title: t('pickExpertTitle'),
|
|
952
|
+
composerScopeRef: () => composerScope,
|
|
764
953
|
sessionId: apiProps && apiProps.sessionId, input: apiProps && apiProps.input,
|
|
765
954
|
})
|
|
766
955
|
}))
|
|
@@ -769,34 +958,46 @@ module.exports = {
|
|
|
769
958
|
},
|
|
770
959
|
}
|
|
771
960
|
|
|
772
|
-
/**
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
|
|
776
|
-
const t = props.__t
|
|
777
|
-
const [open, setOpen] = useState(false)
|
|
778
|
-
useEffect(ensureStyles, [])
|
|
779
|
-
if (!open) {
|
|
780
|
-
return h('button', { className: 'exp-btn', onClick: () => setOpen(true), title: t('title') }, t('title'))
|
|
781
|
-
}
|
|
782
|
-
const page = h(ExpertsPage, { t, embedded: false, onClose: () => setOpen(false) })
|
|
783
|
-
if (RDP && typeof RDP.createPortal === 'function') return RDP.createPortal(page, document.body)
|
|
784
|
-
return page
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
/** Composer tool-row button: opens one registered '/' source over the
|
|
788
|
-
* session's trigger controller. Hidden while the inputTriggers/sessions
|
|
789
|
-
* services are absent (plugin composed without the trigger pipeline). */
|
|
961
|
+
/** Composer tool-row button: 加号+文字 chip,点击在按钮上方打开自带搜索的
|
|
962
|
+
* 专家 picker 浮层;pick 经 slash/input-insert-text 写入 `/expert-<name> `。
|
|
963
|
+
* 浮层面板盖住按钮以外的区域,再点一次按钮会先落在背板上——天然形成开关切换。
|
|
964
|
+
* inputTriggers/sessions 服务缺席时按钮隐藏(管理页不受影响)。 */
|
|
790
965
|
function ComposerButtonSlot(props) {
|
|
791
966
|
useEffect(ensureStyles, [])
|
|
967
|
+
const [picker, setPicker] = useState(null) // {left, top} 锚点快照;null = 关闭
|
|
968
|
+
const [rows, setRows] = useState(null) // null = 加载中
|
|
969
|
+
const btnRef = useRef(null)
|
|
970
|
+
const liveInput = useRef(props.input)
|
|
971
|
+
liveInput.current = props.input
|
|
792
972
|
const composerScope = props.composerScopeRef ? props.composerScopeRef() : null
|
|
793
|
-
const ready = !!(composerScope && composerScope.
|
|
973
|
+
const ready = !!(composerScope && composerScope.sessions && props.sessionId)
|
|
794
974
|
if (!ready) return null
|
|
975
|
+
const close = () => setPicker(null)
|
|
976
|
+
const open = () => {
|
|
977
|
+
let anchor = { left: 16, top: 160 }
|
|
978
|
+
try { if (btnRef.current) anchor = btnRef.current.getBoundingClientRect() } catch {}
|
|
979
|
+
setPicker({ left: anchor.left, top: anchor.top })
|
|
980
|
+
setRows(null)
|
|
981
|
+
Promise.resolve(fetchRoster())
|
|
982
|
+
.then((list) => setRows(Array.isArray(list) ? list : []))
|
|
983
|
+
.catch(() => setRows([]))
|
|
984
|
+
}
|
|
985
|
+
const pick = (row) => {
|
|
986
|
+
insertComposerText(composerScope, props.sessionId, liveInput.current, `/${row.name} `)
|
|
987
|
+
close()
|
|
988
|
+
refocusComposer()
|
|
989
|
+
}
|
|
990
|
+
const popover = picker !== null && RDP && typeof RDP.createPortal === 'function'
|
|
991
|
+
? RDP.createPortal(h(ExpertPicker, { t: props.__t, anchor: picker, rows, onClose: close, onPick: pick }), document.body)
|
|
992
|
+
: null
|
|
795
993
|
return h('button', {
|
|
796
994
|
className: 'exp-chip',
|
|
995
|
+
ref: btnRef,
|
|
797
996
|
title: props.title || props.label,
|
|
798
|
-
|
|
799
|
-
|
|
997
|
+
'aria-haspopup': 'dialog',
|
|
998
|
+
'aria-expanded': picker !== null,
|
|
999
|
+
onClick: open,
|
|
1000
|
+
}, props.label, popover)
|
|
800
1001
|
}
|
|
801
1002
|
|
|
802
1003
|
/** Settings section slot entry: render the page directly in the host tree. */
|
package/docs/demo.gif
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weibaohui/experts-management",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "dsh 插件 ·
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "dsh 插件 · 专家管理:管理 ntd 格式的专家与专家团队(plugin.json + Agent MD + 技能集),浏览/安装 50+ 内置专家市场;每个专家注册为「仅用户可调用」的技能,在对话输入框用 /expert-名称 即可以该专家的身份执行任务(宿主确定性注入角色定义与技能清单,不占用模型目录 token)。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|