@weibaohui/experts-management 0.1.4 → 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/bundle.js CHANGED
@@ -11,14 +11,18 @@ window.__ModuleLoader__.load({
11
11
  /**
12
12
  * dsh-plugin-experts-management - Browser half.
13
13
  *
14
- * One React app for every surface (sidebar overlay + settings section):
15
- * expert management page (installed / market views + detail modal + market
14
+ * One React app for every surface (settings section; the former sidebar
15
+ * fullscreen-overlay entry was retired pair with dsh-settings-ui):
16
+ * expert management page (mine / built-in views + detail modal + built-in
16
17
  * sync settings). Plus the composer integration:
17
18
  * - an `expert` input-trigger source on `/` (candidates from the plugin's own
18
19
  * HTTP API; pick inserts the literal `/expert-<name> ` token whose send the
19
20
  * host's user-explicit gesture boundary turns into the expert prompt), and
20
- * - a `+专家` button in the composer tool row (`conversation.input.left`)
21
- * that opens exactly that source via the per-session `toggleSource`.
21
+ * - a `+ 专家` button in the composer tool row (`conversation.input.left`)
22
+ * that opens the plugin's own searchable picker popover (the host slash menu
23
+ * filters only by a typed query, which a button click cannot provide); the
24
+ * pick is written into the draft through the same scoped
25
+ * `slash/input-insert-text` event the host menu uses.
22
26
  *
23
27
  * All interactive controls are host primitives
24
28
  * (@deepseek-ai/dsh-client-ui-primitives); all colors come from the ui-theme
@@ -73,13 +77,13 @@ window.__ModuleLoader__.load({
73
77
  const NS = 'expertsManagement'
74
78
 
75
79
  const ZH = {
76
- title: '专家市场',
80
+ title: '专家管理',
77
81
  close: '关闭',
78
- tabInstalled: '已安装',
79
- tabMarket: '市场',
82
+ tabMine: '我的',
83
+ tabBuiltin: '内置',
80
84
  searchPlaceholder: '搜索专家名称、职业、描述…',
81
- installedEmpty: '用户库还没有专家。去「市场」页浏览并安装。',
82
- marketEmpty: '市场为空。请在设置中同步市场仓库。',
85
+ mineEmpty: '用户库还没有专家。去「内置」页浏览并安装。',
86
+ builtinEmpty: '内置专家为空。请在设置中同步内置仓库。',
83
87
  expertTypeAgent: '专家',
84
88
  expertTypeTeam: '团队',
85
89
  sourceLabel: '来源',
@@ -107,10 +111,10 @@ window.__ModuleLoader__.load({
107
111
  filesLabel: '文件',
108
112
  versionLabel: '版本',
109
113
  sourceReadonly: '只读来源(可在 NTD 中管理,或安装到用户库)',
110
- marketSettings: '市场设置',
114
+ builtinSettings: '内置设置',
111
115
  syncNow: '立即同步',
112
116
  syncing: '同步中,可能需要一分钟…',
113
- syncDoneUpdated: '同步完成,市场已更新',
117
+ syncDoneUpdated: '同步完成,内置已更新',
114
118
  syncDoneLatest: '已是最新版本',
115
119
  firstCloneDone: '首次克隆完成',
116
120
  repoUrlLabel: '仓库地址',
@@ -133,18 +137,22 @@ window.__ModuleLoader__.load({
133
137
  noDescription: '暂无描述',
134
138
  avatarLoadFailed: '头像加载失败',
135
139
  menuGroup: '专家',
136
- pickExpert: '+专家',
140
+ pickExpert: '+ 专家',
137
141
  pickExpertTitle: '选择一位专家,以该专家的身份执行本条任务',
142
+ pickerLoading: '正在加载专家目录…',
143
+ pickerEmpty: '没有匹配的专家',
144
+ pickerTabAgents: '专家',
145
+ pickerTabTeams: '专家团',
138
146
  }
139
147
 
140
148
  const EN = {
141
- title: 'Expert Market',
149
+ title: 'Expert Management',
142
150
  close: 'Close',
143
- tabInstalled: 'Installed',
144
- tabMarket: 'Market',
151
+ tabMine: 'Mine',
152
+ tabBuiltin: 'Built-in',
145
153
  searchPlaceholder: 'Search experts by name, profession, description…',
146
- installedEmpty: 'No experts in the user library yet. Browse the Market tab and install one.',
147
- marketEmpty: 'Market is empty. Sync the market repo in settings.',
154
+ mineEmpty: 'No experts in the user library yet. Browse the Built-in tab and install one.',
155
+ builtinEmpty: 'Built-in experts are empty. Sync the built-in repo in settings.',
148
156
  expertTypeAgent: 'Expert',
149
157
  expertTypeTeam: 'Team',
150
158
  sourceLabel: 'Source',
@@ -172,10 +180,10 @@ window.__ModuleLoader__.load({
172
180
  filesLabel: 'Files',
173
181
  versionLabel: 'Version',
174
182
  sourceReadonly: 'Read-only source (manage in NTD, or install into the user library)',
175
- marketSettings: 'Market settings',
183
+ builtinSettings: 'Built-in settings',
176
184
  syncNow: 'Sync now',
177
185
  syncing: 'Syncing, may take a minute…',
178
- syncDoneUpdated: 'Sync complete, market updated',
186
+ syncDoneUpdated: 'Sync complete, built-in updated',
179
187
  syncDoneLatest: 'Already up to date',
180
188
  firstCloneDone: 'First clone done',
181
189
  repoUrlLabel: 'Repository URL',
@@ -200,11 +208,15 @@ window.__ModuleLoader__.load({
200
208
  menuGroup: 'Experts',
201
209
  pickExpert: '+ Expert',
202
210
  pickExpertTitle: 'Pick an expert to handle this message in their persona',
211
+ pickerLoading: 'Loading experts…',
212
+ pickerEmpty: 'No matching experts',
213
+ pickerTabAgents: 'Experts',
214
+ pickerTabTeams: 'Teams',
203
215
  }
204
216
 
205
217
  // ── Styles ───────────────────────────────────────────────────────────────
206
218
 
207
- const STYLE = `
219
+ const STYLE = `<style>
208
220
  .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)}
209
221
  .exp-toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
210
222
  .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}
@@ -248,7 +260,6 @@ window.__ModuleLoader__.load({
248
260
  .exp-member-avatar{width:32px;height:32px;border-radius:8px;object-fit:cover;background:var(--dsw-alias-bg-layer-3)}
249
261
  .exp-skill-row{display:flex;flex-direction:column;gap:2px;padding:8px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}
250
262
  .exp-skill-row:last-child{border-bottom:0}
251
- .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}
252
263
  .exp-form-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
253
264
  .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}
254
265
  .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%}
@@ -258,7 +269,23 @@ window.__ModuleLoader__.load({
258
269
  .exp-checkline input{accent-color:var(--dsw-alias-brand-primary)}
259
270
  .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}
260
271
  .exp-chip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
261
- `
272
+ .exp-picker-backdrop{position:fixed;inset:0;z-index:2147483200}
273
+ .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)}
274
+ .exp-picker-input{flex:none}
275
+ .exp-picker-tabs{flex:none;align-self:flex-start}
276
+ .exp-picker-list{display:flex;flex-direction:column;min-height:40px;overflow-y:auto}
277
+ .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}
278
+ .exp-picker-row[data-active="true"]{background:var(--dsw-alias-interactive-bg-hover)}
279
+ .exp-picker-icon{width:18px;flex:none;text-align:center}
280
+ .exp-picker-name{flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
281
+ .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}
282
+ .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}
283
+ .exp-picker-empty{padding:12px 10px;text-align:center;color:var(--dsw-alias-label-dimmed);font-size:13px}
284
+ .exp-overlay{position:fixed;inset:0;z-index:2147483000;background:var(--dsw-alias-bg-base);overflow:auto;padding:20px 26px}
285
+ .exp-head{display:flex;align-items:center;gap:10px;margin-bottom:4px}
286
+ .exp-title{font-weight:600;font-size:16px}
287
+ .exp-spacer{flex:1}
288
+ </style>`
262
289
 
263
290
  // ── Helpers ──────────────────────────────────────────────────────────────
264
291
 
@@ -306,17 +333,51 @@ window.__ModuleLoader__.load({
306
333
  const rosterListeners = new Set()
307
334
  const ROSTER_TTL = 60_000
308
335
 
336
+ /**
337
+ * Roster row mapping (pure, testable). The host slash menu renders only
338
+ * name + description per row, so the human-readable displayName is prefixed
339
+ * into the description — expert teams (👥) and single experts both show
340
+ * their real name next to the `expert-<id>` literal. The picker popover
341
+ * renders `displayName` as the primary label and `plainDescription` as the
342
+ * secondary line instead. Rows come out sorted by displayName(中文按拼音),
343
+ * the picker splits them into 专家/专家团 tabs by `team`.
344
+ */
345
+ function toRosterRows(mine, builtin) {
346
+ const rows = [...(Array.isArray(mine) ? mine : []), ...(Array.isArray(builtin) ? builtin : [])].map((e) => {
347
+ const displayName = e.displayName || e.name
348
+ const desc = e.description || e.profession || ''
349
+ return {
350
+ name: `expert-${e.name}`,
351
+ displayName,
352
+ description: displayName && displayName !== e.name ? (desc === '' ? displayName : `${displayName} · ${desc}`) : desc,
353
+ plainDescription: desc,
354
+ team: e.expertType === 'team',
355
+ icon: e.expertType === 'team' ? '👥' : '🧑‍💼',
356
+ }
357
+ })
358
+ rows.sort((a, b) => String(a.displayName).localeCompare(String(b.displayName), 'zh'))
359
+ return rows
360
+ }
361
+
362
+ /** Picker tab split (pure, testable): single experts vs expert teams. */
363
+ function splitRosterByType(rows) {
364
+ const agents = [], teams = []
365
+ for (const r of Array.isArray(rows) ? rows : []) (r.team ? teams : agents).push(r)
366
+ return { agents, teams }
367
+ }
368
+
369
+ /** Picker filter predicate (pure): match the displayed fields, not the `expert-` prefix. */
370
+ function pickerRowMatch(r, lower) {
371
+ return matchExpert({ name: r.name.replace(/^expert-/, ''), displayName: r.displayName, description: r.plainDescription }, lower)
372
+ }
373
+
309
374
  async function fetchRoster(force) {
310
375
  if (!force && expertRoster !== null && Date.now() - expertRosterAt < ROSTER_TTL) return expertRoster
311
376
  try {
312
377
  const data = await fetchJson(API)
313
- const installed = Array.isArray(data.installed) ? data.installed : []
314
- const market = (Array.isArray(data.market) ? data.market : []).filter((e) => !e.installed)
315
- expertRoster = [...installed, ...market].map((e) => ({
316
- name: `expert-${e.name}`,
317
- description: e.description || e.profession || '',
318
- icon: e.expertType === 'team' ? '👥' : '🧑‍💼',
319
- }))
378
+ const mine = Array.isArray(data.mine) ? data.mine : []
379
+ const builtin = (Array.isArray(data.builtin) ? data.builtin : []).filter((e) => !e.installed)
380
+ expertRoster = toRosterRows(mine, builtin)
320
381
  expertRosterAt = Date.now()
321
382
  for (const listener of [...rosterListeners]) { try { listener() } catch {} }
322
383
  } catch { /* 菜单失败静默:候选组保持 pending/缺席 */ }
@@ -356,6 +417,10 @@ window.__ModuleLoader__.load({
356
417
  * end (host toggleCommandMenu 同款调用形状)。The standard kit exposes no
357
418
  * caret, so the pick replaces a collapsed span at draft end — picks die
358
419
  * quietly on span-CAS if the draft moved since the click.
420
+ *
421
+ * NOTE: the + 专家 button no longer uses this — the host menu cannot offer
422
+ * a search box, so the button opens ExpertPicker instead. Kept for the
423
+ * contract tests and as the documented toggleSource path.
359
424
  */
360
425
  function openTriggerSource(composerScope, sessionId, input, sourceName) {
361
426
  const inputTriggers = composerScope && composerScope.inputTriggers
@@ -378,6 +443,115 @@ window.__ModuleLoader__.load({
378
443
  return true
379
444
  }
380
445
 
446
+ /**
447
+ * Insert `text` at the end of the session draft through the same scoped
448
+ * event the host slash menu executes (`slash/input-insert-text`). The span
449
+ * CAS uses the freshest input snapshot handed to the slot props — while the
450
+ * picker popover is open the composer draft cannot move (focus is in the
451
+ * picker), so the splice applies; a stale snapshot quietly no-ops, same as
452
+ * the host menu's span-CAS.
453
+ */
454
+ function insertComposerText(scope, sessionId, input, text) {
455
+ const sessions = scope && scope.sessions
456
+ if (!sessions) return false
457
+ let actx
458
+ try { actx = sessions.scope(sessionId) } catch { return false }
459
+ if (actx === undefined || actx === null || typeof actx.bail !== 'function') return false
460
+ const draft = (input && input.draft) || ''
461
+ const at = draft.length
462
+ try {
463
+ return actx.bail(actx, 'slash/input-insert-text', {
464
+ text,
465
+ span: { start: at, end: at, draftRev: (input && input.draftRev) || 0 },
466
+ }) === true
467
+ } catch { return false }
468
+ }
469
+
470
+ /** Best-effort refocus of the composer textarea after the picker closes. */
471
+ function refocusComposer() {
472
+ try {
473
+ const card = document.querySelector('[data-composer-card]')
474
+ const ta = card && card.querySelector('textarea')
475
+ if (ta && typeof ta.focus === 'function') ta.focus()
476
+ } catch {}
477
+ }
478
+
479
+ /** Picker popover list cap — beyond this the search input is the filter. */
480
+ const PICKER_ROW_CAP = 200
481
+
482
+ /**
483
+ * + 专家 picker:锚定在按钮上方、自带搜索框的候选浮层(portal 到 body)。
484
+ * 宿主斜杠菜单靠「输入的 query」过滤,按钮打开的菜单没有输入载体——候选
485
+ * 太多时无从筛选,所以浮层自带搜索框。专家/专家团分两个 tab(tab 标签上的
486
+ * 计数跟随当前搜索过滤),各 tab 内按显示名排序(toRosterRows 已排好)。
487
+ * 键盘 ↑/↓/Enter/Esc,鼠标 hover+点击;tab 按钮 mousedown 不抢输入框焦点。
488
+ */
489
+ function ExpertPicker(props) {
490
+ const t = props.t
491
+ const [query, setQuery] = useState('')
492
+ const [tab, setTab] = useState('agent') // 'agent' | 'team'
493
+ const [active, setActive] = useState(0)
494
+ const inputRef = useRef(null)
495
+ const listRef = useRef(null)
496
+ useEffect(() => { try { if (inputRef.current) inputRef.current.focus() } catch {} }, [])
497
+ useEffect(() => {
498
+ const onKey = (e) => { if (e.key === 'Escape' && !(e.isComposing === true)) props.onClose() }
499
+ try { document.addEventListener('keydown', onKey) } catch {}
500
+ return () => { try { document.removeEventListener('keydown', onKey) } catch {} }
501
+ }, [])
502
+ const lower = query.trim().toLowerCase()
503
+ const { agents, teams } = splitRosterByType(props.rows)
504
+ const filteredAgents = lower === '' ? agents : agents.filter((r) => pickerRowMatch(r, lower))
505
+ const filteredTeams = lower === '' ? teams : teams.filter((r) => pickerRowMatch(r, lower))
506
+ const shown = (tab === 'team' ? filteredTeams : filteredAgents).slice(0, PICKER_ROW_CAP)
507
+ useEffect(() => { setActive(0) }, [lower, tab, props.rows])
508
+ useEffect(() => {
509
+ const list = listRef.current
510
+ const el = list && list.children[active]
511
+ if (el && typeof el.scrollIntoView === 'function') { try { el.scrollIntoView({ block: 'nearest' }) } catch {} }
512
+ }, [active])
513
+ const onKeyDown = (e) => {
514
+ // IME 组词中的按键不触发选择(回车是选定拼音候选,不是 pick)
515
+ if (e.nativeEvent && e.nativeEvent.isComposing === true) return
516
+ if (e.key === 'ArrowDown') { e.preventDefault(); setActive((i) => Math.min(i + 1, shown.length - 1)) }
517
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)) }
518
+ else if (e.key === 'Enter') { e.preventDefault(); const row = shown[active]; if (row) props.onPick(row) }
519
+ }
520
+ const width = 400
521
+ const winW = typeof window !== 'undefined' ? window.innerWidth : 800
522
+ const winH = typeof window !== 'undefined' ? window.innerHeight : 600
523
+ const left = Math.max(8, Math.min(props.anchor.left, winW - width - 8))
524
+ const bottom = Math.max(8, winH - props.anchor.top + 6)
525
+ const tabBtn = (key, labelKey, count) => h('button', {
526
+ type: 'button', role: 'tab', className: 'exp-tab', 'data-on': tab === key, 'aria-selected': tab === key,
527
+ onMouseDown: (e) => { e.preventDefault(); setTab(key) }, onClick: () => setTab(key),
528
+ }, `${t(labelKey)} (${count})`)
529
+ return h('div', { className: 'exp-picker-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) props.onClose() } },
530
+ h('div', { className: 'exp-picker', style: { left, bottom, width }, role: 'dialog', 'aria-label': t('pickExpertTitle') },
531
+ h('input', {
532
+ ref: inputRef, className: 'exp-input exp-picker-input', value: query,
533
+ placeholder: t('searchPlaceholder'), onChange: (e) => setQuery(e.target.value), onKeyDown,
534
+ }),
535
+ h('div', { className: 'exp-tabs exp-picker-tabs', role: 'tablist' },
536
+ tabBtn('agent', 'pickerTabAgents', filteredAgents.length),
537
+ tabBtn('team', 'pickerTabTeams', filteredTeams.length)),
538
+ h('div', { className: 'exp-picker-list', ref: listRef, role: 'listbox' },
539
+ props.rows === null
540
+ ? h('div', { className: 'exp-picker-empty' }, t('pickerLoading'))
541
+ : shown.length === 0
542
+ ? h('div', { className: 'exp-picker-empty' }, t('pickerEmpty'))
543
+ : shown.map((row, i) => h('button', {
544
+ key: row.name, type: 'button', role: 'option', 'aria-selected': i === active,
545
+ className: 'exp-picker-row', 'data-active': i === active,
546
+ onMouseEnter: () => setActive(i),
547
+ onMouseDown: (e) => { e.preventDefault(); props.onPick(row) },
548
+ },
549
+ row.icon ? h('span', { className: 'exp-picker-icon', 'aria-hidden': 'true' }, row.icon) : null,
550
+ h('span', { className: 'exp-picker-name' }, row.displayName || row.name),
551
+ row.displayName && row.displayName !== row.name ? h('span', { className: 'exp-picker-literal' }, row.name) : null,
552
+ h('span', { className: 'exp-picker-desc' }, row.plainDescription || ''))))))
553
+ }
554
+
381
555
  // ── Small components ─────────────────────────────────────────────────────
382
556
 
383
557
  function Avatar({ expert, size }) {
@@ -541,16 +715,16 @@ window.__ModuleLoader__.load({
541
715
  ) : null))
542
716
  }
543
717
 
544
- function MarketSettingsCard({ t, onToast, onSynced }) {
718
+ function BuiltinSettingsDialog({ t, onClose, onToast, onSynced }) {
545
719
  const [status, setStatus] = useState(null)
546
720
  const [form, setForm] = useState(null)
547
721
  const [busy, setBusy] = useState(false)
548
- 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 }) })
722
+ 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 }) })
549
723
  useEffect(() => { load() }, [])
550
724
  const sync = async () => {
551
725
  setBusy(true)
552
726
  try {
553
- const r = await fetchJson(`${API}/market/sync`, { method: 'POST' })
727
+ const r = await fetchJson(`${API}/builtin/sync`, { method: 'POST' })
554
728
  onToast(r.isFirstClone ? t('firstCloneDone') : r.hasUpdates ? t('syncDoneUpdated') : t('syncDoneLatest'))
555
729
  await load()
556
730
  onSynced()
@@ -564,51 +738,72 @@ window.__ModuleLoader__.load({
564
738
  const dirText = (form.repoDir || '').trim()
565
739
  if (dirText !== '' && dirText !== (status && status.dir)) patch.repoDir = dirText
566
740
  if (typeof form.token === 'string' && form.token !== '') patch.token = form.token
567
- if (form.token === null) patch.token = null
568
- await fetchJson(`${API}/market/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
741
+ await fetchJson(`${API}/builtin/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
569
742
  setForm((f) => ({ ...f, token: '' }))
570
743
  onToast(t('saved'))
571
744
  await load()
572
745
  } catch (e) { onToast(String(e && e.message)) }
573
746
  setBusy(false)
574
747
  }
575
- const field = (label, value, onChange, type) => h('label', null, label,
576
- h('input', { className: 'exp-input', type: type || 'text', value: value ?? '', onChange: (e) => onChange(e.target.value) }))
577
- if (status === null) return null
578
- return h('div', { className: 'exp-settings' },
579
- h('div', { className: 'exp-status-line' },
580
- h('b', null, t('marketSettings')),
581
- h('span', null, `${t('repoDirLabel')}: ${status.dir}`),
582
- h('span', null, `${t('lastSyncLabel')}: ${status.lastSyncAt ? formatTime(status.lastSyncAt) : t('never')}`),
583
- status.localCommit ? h('span', null, `${t('localCommitLabel')}: ${String(status.localCommit).slice(0, 8)}`) : null,
584
- status.remoteCommit ? h('span', null, `${t('remoteCommitLabel')}: ${String(status.remoteCommit).slice(0, 8)}`, status.needsUpdate ? Badge({ kind: 'type', children: t('needsUpdateTag') }) : null) : null,
585
- !status.gitAvailable ? h('span', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('gitMissing')) : null,
586
- status.sparsePaths ? h('span', null, `sparse: ${(status.sparsePaths || []).join(', ')}`) : null),
587
- h('div', { className: 'exp-form-row' },
588
- field(t('repoUrlLabel'), form.url, (v) => setForm({ ...form, url: v })),
589
- field(t('branchLabel'), form.branch, (v) => setForm({ ...form, branch: v }))),
590
- h('div', { className: 'exp-form-row' },
591
- field(t('repoDirLabel'), form.repoDir, (v) => setForm({ ...form, repoDir: v })),
592
- field(`${t('tokenLabel')}${status.hasToken ? ` (${t('tokenConfigured')})` : ''}`, form.token ?? '', (v) => setForm({ ...form, token: v }), 'password')),
593
- h('div', { className: 'exp-form-row' },
594
- h('label', { className: 'exp-checkline' },
595
- h('input', { type: 'checkbox', checked: !!form.autoSync, onChange: (e) => setForm({ ...form, autoSync: e.target.checked }) }), t('autoSyncLabel')),
596
- h('label', { className: 'exp-checkline' },
597
- h('input', { type: 'checkbox', checked: !!form.syncOnStartup, onChange: (e) => setForm({ ...form, syncOnStartup: e.target.checked }) }), t('syncOnStartupLabel')),
598
- h('span', { style: { flex: 1 } }),
599
- status.hasToken ? h('button', { className: 'exp-btn', disabled: busy, onClick: () => setForm({ ...form, token: null }) }, t('clearToken')) : null,
600
- h('button', { className: 'exp-btn', disabled: busy || status.syncing, onClick: sync }, busy || status.syncing ? t('syncing') : t('syncNow')),
601
- h(prim('Button'), { onClick: save, disabled: busy }, t('save'))))
748
+ const clearToken = async () => {
749
+ setBusy(true)
750
+ try {
751
+ await fetchJson(`${API}/builtin/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: null }) })
752
+ onToast(t('saved'))
753
+ await load()
754
+ } catch (e) { onToast(String(e && e.message)) }
755
+ setBusy(false)
756
+ }
757
+ // 布局逐行对齐技能市场的市场设置弹窗:状态区 = 左标签/右值成行;
758
+ // 输入区 = 全宽堆叠、placeholder 即标签;底部 = 右对齐 保存 + 立即同步(主按钮)
759
+ const short = (c) => (c ? String(c).slice(0, 8) : '-')
760
+ const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
761
+ h('span', { className: 'exp-profession' }, label), h('span', { style: { wordBreak: 'break-all', textAlign: 'right', fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, value))
762
+ return h('div', { className: 'exp-modal-backdrop', onClick: (e) => { if (e.target === e.currentTarget) onClose() } },
763
+ h('div', { className: 'exp-modal', style: { maxWidth: 640 } },
764
+ h('div', { className: 'exp-modal-head' },
765
+ h('span', { className: 'exp-title' }, t('builtinSettings')),
766
+ h('button', { className: 'exp-btn exp-modal-close', onClick: onClose }, t('close'))),
767
+ status === null ? h('div', { className: 'exp-empty' }, '…')
768
+ : h('div', { style: { display: 'contents' } },
769
+ !status.gitAvailable ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12 } }, t('gitMissing')) : null,
770
+ h('div', null,
771
+ row(t('repoUrlLabel'), status.url),
772
+ row(t('branchLabel'), status.branch),
773
+ row(t('localCommitLabel'), short(status.localCommit)),
774
+ status.remoteCommit ? h('div', { style: { display: 'flex', justifyContent: 'flex-end' } },
775
+ status.needsUpdate ? Badge({ kind: 'type', children: t('needsUpdateTag') }) : null) : null,
776
+ row(t('remoteCommitLabel'), short(status.remoteCommit)),
777
+ row(t('lastSyncLabel'), status.lastSyncAt ? formatTime(status.lastSyncAt) : t('never')),
778
+ row(t('repoDirLabel'), status.dir),
779
+ status.sparsePaths ? row('sparse', (status.sparsePaths || []).join(', ')) : null),
780
+ h('div', { style: { display: 'flex', gap: 16, flexWrap: 'wrap' } },
781
+ h('label', { className: 'exp-checkline' },
782
+ h('input', { type: 'checkbox', checked: !!form.autoSync, onChange: (e) => setForm({ ...form, autoSync: e.target.checked }) }), t('autoSyncLabel')),
783
+ h('label', { className: 'exp-checkline' },
784
+ h('input', { type: 'checkbox', checked: !!form.syncOnStartup, onChange: (e) => setForm({ ...form, syncOnStartup: e.target.checked }) }), t('syncOnStartupLabel'))),
785
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
786
+ 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' } }),
787
+ 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' } }),
788
+ 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' } }),
789
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
790
+ h('input', { className: 'exp-input', type: 'password', value: form.token ?? '', onChange: (e) => setForm({ ...form, token: e.target.value }),
791
+ placeholder: status.hasToken ? `${t('tokenLabel')} · ${t('tokenConfigured')}` : t('tokenLabel'), style: { flex: 1 } }),
792
+ status.hasToken ? h('button', { className: 'exp-btn', disabled: busy, onClick: clearToken }, t('clearToken')) : null)),
793
+ h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 } },
794
+ h('button', { className: 'exp-btn', disabled: busy, onClick: save }, t('save')),
795
+ h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: busy || status.syncing, onClick: sync }, busy || status.syncing ? t('syncing') : t('syncNow'))))))
602
796
  }
603
797
 
604
798
  // ── Page ─────────────────────────────────────────────────────────────────
605
799
 
606
- function ExpertsPage({ t, embedded }) {
607
- const [tab, setTab] = useState('market')
800
+ function ExpertsPage({ t, embedded, onClose }) {
801
+ const [tab, setTab] = useState('builtin')
608
802
  const [data, setData] = useState(null)
609
803
  const [error, setError] = useState('')
610
804
  const [search, setSearch] = useState('')
611
805
  const [selected, setSelected] = useState(null) // {name, source}
806
+ const [settingsOpen, setSettingsOpen] = useState(false)
612
807
  const [busyName, setBusyName] = useState(null)
613
808
  const [toast, setToast] = useState(null)
614
809
  const showToast = (text) => { setToast(text); setTimeout(() => setToast(null), 2600) }
@@ -617,7 +812,7 @@ window.__ModuleLoader__.load({
617
812
  const rows = useMemo(() => {
618
813
  if (!data) return []
619
814
  const lower = search.trim().toLowerCase()
620
- const list = tab === 'installed' ? data.installed : data.market
815
+ const list = tab === 'mine' ? data.mine : data.builtin
621
816
  return list.filter((e) => matchExpert(e, lower))
622
817
  }, [data, tab, search])
623
818
  const install = async (row) => {
@@ -647,16 +842,20 @@ window.__ModuleLoader__.load({
647
842
  } catch (e) { showToast(String(e && e.message)) }
648
843
  setBusyName(null)
649
844
  }
650
- return h('div', { className: 'exp-page' },
845
+ return h('div', { className: 'exp-page' + (embedded ? '' : ' exp-overlay') },
846
+ !embedded ? h('div', { className: 'exp-head' },
847
+ h('span', { className: 'exp-title' }, t('title')),
848
+ h('span', { className: 'exp-spacer' }),
849
+ h('button', { className: 'exp-btn', onClick: () => { if (onClose) onClose() } }, t('close'))) : null,
651
850
  h('div', { className: 'exp-toolbar' },
652
851
  h('div', { className: 'exp-tabs' },
653
- h('button', { className: 'exp-tab', 'data-on': tab === 'installed', onClick: () => setTab('installed') }, `${t('tabInstalled')}${data ? ` (${data.installed.length})` : ''}`),
654
- h('button', { className: 'exp-tab', 'data-on': tab === 'market', onClick: () => setTab('market') }, `${t('tabMarket')}${data ? ` (${data.market.length})` : ''}`)),
852
+ h('button', { className: 'exp-tab', 'data-on': tab === 'mine', onClick: () => setTab('mine') }, `${t('tabMine')}${data ? ` (${data.mine.length})` : ''}`),
853
+ h('button', { className: 'exp-tab', 'data-on': tab === 'builtin', onClick: () => setTab('builtin') }, `${t('tabBuiltin')}${data ? ` (${data.builtin.length})` : ''}`)),
655
854
  h('input', { className: 'exp-input exp-search', placeholder: t('searchPlaceholder'), value: search, onChange: (e) => setSearch(e.target.value) }),
656
- h('span', { className: 'exp-count' }, `${rows.length}`)),
657
- tab === 'market' ? h(MarketSettingsCard, { t, onToast: showToast, onSynced: reload }) : null,
855
+ h('span', { className: 'exp-count' }, `${rows.length}`),
856
+ tab === 'builtin' ? h('button', { className: 'exp-btn', title: t('builtinSettings'), onClick: () => setSettingsOpen(true) }, t('builtinSettings')) : null),
658
857
  error !== '' ? h('div', { className: 'exp-empty' }, `${t('loadFailed')}: ${error}`) : null,
659
- data !== null && rows.length === 0 ? h('div', { className: 'exp-empty' }, tab === 'installed' ? t('installedEmpty') : t('marketEmpty')) : null,
858
+ data !== null && rows.length === 0 ? h('div', { className: 'exp-empty' }, tab === 'mine' ? t('mineEmpty') : t('builtinEmpty')) : null,
660
859
  rows.length > 0
661
860
  ? h(PagedGrid, {
662
861
  items: rows,
@@ -668,6 +867,9 @@ window.__ModuleLoader__.load({
668
867
  onInstalled: () => { setSelected(null); showToast(t('installedDone')); reload() },
669
868
  onDeleted: () => { setSelected(null); showToast(t('removedDone')); reload() },
670
869
  }) : null,
870
+ settingsOpen ? h(BuiltinSettingsDialog, {
871
+ t, onClose: () => setSettingsOpen(false), onToast: showToast, onSynced: reload,
872
+ }) : null,
671
873
  toast !== null ? h('div', { className: 'exp-toast' }, toast) : null)
672
874
  }
673
875
 
@@ -679,6 +881,7 @@ window.__ModuleLoader__.load({
679
881
  __internals: {
680
882
  NS, ZH, EN, matchExpert, formatSize, formatTime, avatarUrl,
681
883
  EXPERT_SOURCE_NAME, makeExpertSource, openTriggerSource, fetchRoster,
884
+ toRosterRows, insertComposerText, splitRosterByType, pickerRowMatch,
682
885
  },
683
886
  /** Test/host helper: mount a standalone page into any container. */
684
887
  __boot(container, opts = {}) {
@@ -730,20 +933,6 @@ window.__ModuleLoader__.load({
730
933
  })
731
934
  }
732
935
  } catch (e) { try { console.error('[experts-management] composer inject:', e) } catch {} }
733
- ctx.effect(() => {
734
- try {
735
- ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
736
- name: 'sidebar.footer.action',
737
- id: CLIENT_NAME,
738
- order: 60,
739
- locale: NS,
740
- label: () => t('title'),
741
- inject: () => ({ t }),
742
- }, function FooterSlot(apiProps) {
743
- return h(FooterSlotComponent, { __t: t, wide: apiProps && apiProps.wide })
744
- }))
745
- } catch (e) { (globalThis.__expErrors = globalThis.__expErrors || []).push('footer:' + (e && e.message)); throw e }
746
- }, 'experts-management: sidebar footer action')
747
936
  ctx.effect(() => {
748
937
  try {
749
938
  ctx.slots.inject('settings.section', () => ctx.slots.register({
@@ -769,8 +958,8 @@ window.__ModuleLoader__.load({
769
958
  inject: () => ({ t }),
770
959
  }, function ExpertButtonSlot(apiProps) {
771
960
  return h(ComposerButtonSlot, {
772
- __t: t, icon: '🧑‍💼', label: t('pickExpert'), title: t('pickExpertTitle'),
773
- source: EXPERT_SOURCE_NAME, composerScopeRef: () => composerScope,
961
+ __t: t, label: t('pickExpert'), title: t('pickExpertTitle'),
962
+ composerScopeRef: () => composerScope,
774
963
  sessionId: apiProps && apiProps.sessionId, input: apiProps && apiProps.input,
775
964
  })
776
965
  }))
@@ -779,34 +968,46 @@ window.__ModuleLoader__.load({
779
968
  },
780
969
  }
781
970
 
782
- /** Footer slot entry: the button, and — when open — the whole experts page
783
- * portaled to <body> as a fullscreen overlay (same pattern as the skills
784
- * market footer entry). */
785
- function FooterSlotComponent(props) {
786
- const t = props.__t
787
- const [open, setOpen] = useState(false)
788
- useEffect(ensureStyles, [])
789
- if (!open) {
790
- return h('button', { className: 'exp-btn', onClick: () => setOpen(true), title: t('title') }, t('title'))
791
- }
792
- const page = h(ExpertsPage, { t, embedded: false, onClose: () => setOpen(false) })
793
- if (RDP && typeof RDP.createPortal === 'function') return RDP.createPortal(page, document.body)
794
- return page
795
- }
796
-
797
- /** Composer tool-row button: opens one registered '/' source over the
798
- * session's trigger controller. Hidden while the inputTriggers/sessions
799
- * services are absent (plugin composed without the trigger pipeline). */
971
+ /** Composer tool-row button: 加号+文字 chip,点击在按钮上方打开自带搜索的
972
+ * 专家 picker 浮层;pick slash/input-insert-text 写入 `/expert-<name> `。
973
+ * 浮层面板盖住按钮以外的区域,再点一次按钮会先落在背板上——天然形成开关切换。
974
+ * inputTriggers/sessions 服务缺席时按钮隐藏(管理页不受影响)。 */
800
975
  function ComposerButtonSlot(props) {
801
976
  useEffect(ensureStyles, [])
977
+ const [picker, setPicker] = useState(null) // {left, top} 锚点快照;null = 关闭
978
+ const [rows, setRows] = useState(null) // null = 加载中
979
+ const btnRef = useRef(null)
980
+ const liveInput = useRef(props.input)
981
+ liveInput.current = props.input
802
982
  const composerScope = props.composerScopeRef ? props.composerScopeRef() : null
803
- const ready = !!(composerScope && composerScope.inputTriggers && composerScope.sessions && props.sessionId)
983
+ const ready = !!(composerScope && composerScope.sessions && props.sessionId)
804
984
  if (!ready) return null
985
+ const close = () => setPicker(null)
986
+ const open = () => {
987
+ let anchor = { left: 16, top: 160 }
988
+ try { if (btnRef.current) anchor = btnRef.current.getBoundingClientRect() } catch {}
989
+ setPicker({ left: anchor.left, top: anchor.top })
990
+ setRows(null)
991
+ Promise.resolve(fetchRoster())
992
+ .then((list) => setRows(Array.isArray(list) ? list : []))
993
+ .catch(() => setRows([]))
994
+ }
995
+ const pick = (row) => {
996
+ insertComposerText(composerScope, props.sessionId, liveInput.current, `/${row.name} `)
997
+ close()
998
+ refocusComposer()
999
+ }
1000
+ const popover = picker !== null && RDP && typeof RDP.createPortal === 'function'
1001
+ ? RDP.createPortal(h(ExpertPicker, { t: props.__t, anchor: picker, rows, onClose: close, onPick: pick }), document.body)
1002
+ : null
805
1003
  return h('button', {
806
1004
  className: 'exp-chip',
1005
+ ref: btnRef,
807
1006
  title: props.title || props.label,
808
- onClick: () => { openTriggerSource(composerScope, props.sessionId, props.input, props.source) },
809
- }, `${props.icon || ''}${props.icon ? ' ' : ''}${props.label}`)
1007
+ 'aria-haspopup': 'dialog',
1008
+ 'aria-expanded': picker !== null,
1009
+ onClick: open,
1010
+ }, props.label, popover)
810
1011
  }
811
1012
 
812
1013
  /** Settings section slot entry: render the page directly in the host tree. */