@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/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 (sidebar overlay + settings section):
5
- * expert management page (installed / market views + detail modal + market
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 `+专家` button in the composer tool row (`conversation.input.left`)
11
- * that opens exactly that source via the per-session `toggleSource`.
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
- tabInstalled: '已安装',
69
- tabMarket: '市场',
72
+ tabMine: '我的',
73
+ tabBuiltin: '内置',
70
74
  searchPlaceholder: '搜索专家名称、职业、描述…',
71
- installedEmpty: '用户库还没有专家。去「市场」页浏览并安装。',
72
- marketEmpty: '市场为空。请在设置中同步市场仓库。',
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
- marketSettings: '市场设置',
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 Market',
139
+ title: 'Expert Management',
132
140
  close: 'Close',
133
- tabInstalled: 'Installed',
134
- tabMarket: 'Market',
141
+ tabMine: 'Mine',
142
+ tabBuiltin: 'Built-in',
135
143
  searchPlaceholder: 'Search experts by name, profession, description…',
136
- installedEmpty: 'No experts in the user library yet. Browse the Market tab and install one.',
137
- marketEmpty: 'Market is empty. Sync the market repo in settings.',
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
- marketSettings: 'Market settings',
173
+ builtinSettings: 'Built-in settings',
166
174
  syncNow: 'Sync now',
167
175
  syncing: 'Syncing, may take a minute…',
168
- syncDoneUpdated: 'Sync complete, market updated',
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 installed = Array.isArray(data.installed) ? data.installed : []
304
- const market = (Array.isArray(data.market) ? data.market : []).filter((e) => !e.installed)
305
- expertRoster = [...installed, ...market].map((e) => ({
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 MarketSettingsCard({ t, onToast, onSynced }) {
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}/market/status`).then((s) => { setStatus(s); setForm((f) => f ?? { url: s.url, branch: s.branch, repoDir: s.dir, token: '', autoSync: s.autoSync, syncOnStartup: s.syncOnStartup }) })
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}/market/sync`, { method: 'POST' })
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
- if (form.token === null) patch.token = null
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 field = (label, value, onChange, type) => h('label', null, label,
566
- h('input', { className: 'exp-input', type: type || 'text', value: value ?? '', onChange: (e) => onChange(e.target.value) }))
567
- if (status === null) return null
568
- return h('div', { className: 'exp-settings' },
569
- h('div', { className: 'exp-status-line' },
570
- h('b', null, t('marketSettings')),
571
- h('span', null, `${t('repoDirLabel')}: ${status.dir}`),
572
- h('span', null, `${t('lastSyncLabel')}: ${status.lastSyncAt ? formatTime(status.lastSyncAt) : t('never')}`),
573
- status.localCommit ? h('span', null, `${t('localCommitLabel')}: ${String(status.localCommit).slice(0, 8)}`) : null,
574
- status.remoteCommit ? h('span', null, `${t('remoteCommitLabel')}: ${String(status.remoteCommit).slice(0, 8)}`, status.needsUpdate ? Badge({ kind: 'type', children: t('needsUpdateTag') }) : null) : null,
575
- !status.gitAvailable ? h('span', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('gitMissing')) : null,
576
- status.sparsePaths ? h('span', null, `sparse: ${(status.sparsePaths || []).join(', ')}`) : null),
577
- h('div', { className: 'exp-form-row' },
578
- field(t('repoUrlLabel'), form.url, (v) => setForm({ ...form, url: v })),
579
- field(t('branchLabel'), form.branch, (v) => setForm({ ...form, branch: v }))),
580
- h('div', { className: 'exp-form-row' },
581
- field(t('repoDirLabel'), form.repoDir, (v) => setForm({ ...form, repoDir: v })),
582
- field(`${t('tokenLabel')}${status.hasToken ? ` (${t('tokenConfigured')})` : ''}`, form.token ?? '', (v) => setForm({ ...form, token: v }), 'password')),
583
- h('div', { className: 'exp-form-row' },
584
- h('label', { className: 'exp-checkline' },
585
- h('input', { type: 'checkbox', checked: !!form.autoSync, onChange: (e) => setForm({ ...form, autoSync: e.target.checked }) }), t('autoSyncLabel')),
586
- h('label', { className: 'exp-checkline' },
587
- h('input', { type: 'checkbox', checked: !!form.syncOnStartup, onChange: (e) => setForm({ ...form, syncOnStartup: e.target.checked }) }), t('syncOnStartupLabel')),
588
- h('span', { style: { flex: 1 } }),
589
- status.hasToken ? h('button', { className: 'exp-btn', disabled: busy, onClick: () => setForm({ ...form, token: null }) }, t('clearToken')) : null,
590
- h('button', { className: 'exp-btn', disabled: busy || status.syncing, onClick: sync }, busy || status.syncing ? t('syncing') : t('syncNow')),
591
- h(prim('Button'), { onClick: save, disabled: busy }, t('save'))))
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('market')
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 === 'installed' ? data.installed : data.market
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 === 'installed', onClick: () => setTab('installed') }, `${t('tabInstalled')}${data ? ` (${data.installed.length})` : ''}`),
644
- h('button', { className: 'exp-tab', 'data-on': tab === 'market', onClick: () => setTab('market') }, `${t('tabMarket')}${data ? ` (${data.market.length})` : ''}`)),
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
- tab === 'market' ? h(MarketSettingsCard, { t, onToast: showToast, onSynced: reload }) : null,
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 === 'installed' ? t('installedEmpty') : t('marketEmpty')) : null,
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, icon: '🧑‍💼', label: t('pickExpert'), title: t('pickExpertTitle'),
763
- source: EXPERT_SOURCE_NAME, composerScopeRef: () => composerScope,
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
- /** Footer slot entry: the button, and — when open — the whole experts page
773
- * portaled to <body> as a fullscreen overlay (same pattern as the skills
774
- * market footer entry). */
775
- function FooterSlotComponent(props) {
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.inputTriggers && composerScope.sessions && props.sessionId)
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
- onClick: () => { openTriggerSource(composerScope, props.sessionId, props.input, props.source) },
799
- }, `${props.icon || ''}${props.icon ? ' ' : ''}${props.label}`)
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.1.3",
4
- "description": "dsh 插件 · 专家市场:管理 ntd 格式的专家与专家团队(plugin.json + Agent MD + 技能集),浏览/安装 50+ 内置专家市场;每个专家注册为「仅用户可调用」的技能,在对话输入框用 /expert-名称 即可以该专家的身份执行任务(宿主确定性注入角色定义与技能清单,不占用模型目录 token)。",
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",