@weibaohui/experts-management 0.1.4 → 0.3.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: '来源',
@@ -78,6 +82,34 @@ const ZH = {
78
82
  install: '安装',
79
83
  installing: '安装中…',
80
84
  installedDone: '已安装到用户库',
85
+ shareBtn: '分享',
86
+ shareTitle: '分享专家到官方仓库',
87
+ shareHint: 'AI 将读取本机令牌,fork 官方仓库 → 建分支 → 提交该专家目录 → 创建 PR。确认或修改提示词后,复制到当前会话发送执行。',
88
+ shareParamName: '专家名',
89
+ shareParamVersion: '版本',
90
+ shareParamDir: '本机目录',
91
+ copyPrompt: '复制提示词',
92
+ copied: '已复制',
93
+ runBtn: '执行',
94
+ running: '执行中…',
95
+ runDone: '完成',
96
+ runFailed: '失败',
97
+ outputLabel: '执行输出',
98
+ editMeta: '编辑资料',
99
+ displayName: '显示名',
100
+ professionLabel: '职业',
101
+ displayDescription: '描述',
102
+ defaultInitPrompt: '默认开场',
103
+ tagsLabel: '标签',
104
+ edit: '编辑',
105
+ save: '保存',
106
+ cancel: '取消',
107
+ saved: '已保存',
108
+ attachSkill: '添加技能',
109
+ detach: '移除',
110
+ detachConfirm: '仅移除专家的技能副本,不影响技能库本体。确认移除?',
111
+ uploadAvatar: '更换头像',
112
+ uploading: '上传中…',
81
113
  overwrite: '覆盖安装',
82
114
  remove: '删除',
83
115
  removing: '删除中…',
@@ -97,10 +129,10 @@ const ZH = {
97
129
  filesLabel: '文件',
98
130
  versionLabel: '版本',
99
131
  sourceReadonly: '只读来源(可在 NTD 中管理,或安装到用户库)',
100
- marketSettings: '市场设置',
132
+ builtinSettings: '内置设置',
101
133
  syncNow: '立即同步',
102
134
  syncing: '同步中,可能需要一分钟…',
103
- syncDoneUpdated: '同步完成,市场已更新',
135
+ syncDoneUpdated: '同步完成,内置已更新',
104
136
  syncDoneLatest: '已是最新版本',
105
137
  firstCloneDone: '首次克隆完成',
106
138
  repoUrlLabel: '仓库地址',
@@ -123,18 +155,22 @@ const ZH = {
123
155
  noDescription: '暂无描述',
124
156
  avatarLoadFailed: '头像加载失败',
125
157
  menuGroup: '专家',
126
- pickExpert: '+专家',
158
+ pickExpert: '+ 专家',
127
159
  pickExpertTitle: '选择一位专家,以该专家的身份执行本条任务',
160
+ pickerLoading: '正在加载专家目录…',
161
+ pickerEmpty: '没有匹配的专家',
162
+ pickerTabAgents: '专家',
163
+ pickerTabTeams: '专家团',
128
164
  }
129
165
 
130
166
  const EN = {
131
- title: 'Expert Market',
167
+ title: 'Expert Management',
132
168
  close: 'Close',
133
- tabInstalled: 'Installed',
134
- tabMarket: 'Market',
169
+ tabMine: 'Mine',
170
+ tabBuiltin: 'Built-in',
135
171
  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.',
172
+ mineEmpty: 'No experts in the user library yet. Browse the Built-in tab and install one.',
173
+ builtinEmpty: 'Built-in experts are empty. Sync the built-in repo in settings.',
138
174
  expertTypeAgent: 'Expert',
139
175
  expertTypeTeam: 'Team',
140
176
  sourceLabel: 'Source',
@@ -143,6 +179,34 @@ const EN = {
143
179
  install: 'Install',
144
180
  installing: 'Installing…',
145
181
  installedDone: 'Installed to the user library',
182
+ shareBtn: 'Share',
183
+ shareTitle: 'Share expert to the official repo',
184
+ shareHint: 'AI will read the local token, fork the official repo → create a branch → commit the expert directory → open a PR. Review or edit the prompt, then copy it into the conversation to run.',
185
+ shareParamName: 'Expert',
186
+ shareParamVersion: 'Version',
187
+ shareParamDir: 'Local dir',
188
+ copyPrompt: 'Copy prompt',
189
+ copied: 'Copied',
190
+ runBtn: 'Run',
191
+ running: 'Running…',
192
+ runDone: 'Done',
193
+ runFailed: 'Failed',
194
+ outputLabel: 'Output',
195
+ editMeta: 'Edit profile',
196
+ displayName: 'Display name',
197
+ professionLabel: 'Profession',
198
+ displayDescription: 'Description',
199
+ defaultInitPrompt: 'Default opener',
200
+ tagsLabel: 'Tags',
201
+ edit: 'Edit',
202
+ save: 'Save',
203
+ cancel: 'Cancel',
204
+ saved: 'Saved',
205
+ attachSkill: 'Add skill',
206
+ detach: 'Remove',
207
+ detachConfirm: "Only the expert's copy is removed — the skill library is untouched. Remove?",
208
+ uploadAvatar: 'Change avatar',
209
+ uploading: 'Uploading…',
146
210
  overwrite: 'Overwrite install',
147
211
  remove: 'Delete',
148
212
  removing: 'Deleting…',
@@ -162,10 +226,10 @@ const EN = {
162
226
  filesLabel: 'Files',
163
227
  versionLabel: 'Version',
164
228
  sourceReadonly: 'Read-only source (manage in NTD, or install into the user library)',
165
- marketSettings: 'Market settings',
229
+ builtinSettings: 'Built-in settings',
166
230
  syncNow: 'Sync now',
167
231
  syncing: 'Syncing, may take a minute…',
168
- syncDoneUpdated: 'Sync complete, market updated',
232
+ syncDoneUpdated: 'Sync complete, built-in updated',
169
233
  syncDoneLatest: 'Already up to date',
170
234
  firstCloneDone: 'First clone done',
171
235
  repoUrlLabel: 'Repository URL',
@@ -190,11 +254,15 @@ const EN = {
190
254
  menuGroup: 'Experts',
191
255
  pickExpert: '+ Expert',
192
256
  pickExpertTitle: 'Pick an expert to handle this message in their persona',
257
+ pickerLoading: 'Loading experts…',
258
+ pickerEmpty: 'No matching experts',
259
+ pickerTabAgents: 'Experts',
260
+ pickerTabTeams: 'Teams',
193
261
  }
194
262
 
195
263
  // ── Styles ───────────────────────────────────────────────────────────────
196
264
 
197
- const STYLE = `
265
+ const STYLE = `<style>
198
266
  .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
267
  .exp-toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
200
268
  .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,17 +306,32 @@ const STYLE = `
238
306
  .exp-member-avatar{width:32px;height:32px;border-radius:8px;object-fit:cover;background:var(--dsw-alias-bg-layer-3)}
239
307
  .exp-skill-row{display:flex;flex-direction:column;gap:2px;padding:8px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}
240
308
  .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
309
  .exp-form-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
243
310
  .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
- .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%}
311
+ .exp-flash{font-size:12px;color:var(--dsw-alias-state-positive,#3aa76d)}.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%}
245
312
  .exp-status-line{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary)}
246
313
  .exp-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary);border-radius:10px;padding:8px 18px;font-size:13px;z-index:80;box-shadow:0 8px 24px rgba(0,0,0,.25)}
247
314
  .exp-checkline{display:flex;gap:6px;align-items:center;font-size:13px;color:var(--dsw-alias-label-secondary)}
248
315
  .exp-checkline input{accent-color:var(--dsw-alias-brand-primary)}
249
316
  .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
317
  .exp-chip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
251
- `
318
+ .exp-picker-backdrop{position:fixed;inset:0;z-index:2147483200}
319
+ .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)}
320
+ .exp-picker-input{flex:none}
321
+ .exp-picker-tabs{flex:none;align-self:flex-start}
322
+ .exp-picker-list{display:flex;flex-direction:column;min-height:40px;overflow-y:auto}
323
+ .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}
324
+ .exp-picker-row[data-active="true"]{background:var(--dsw-alias-interactive-bg-hover)}
325
+ .exp-picker-icon{width:18px;flex:none;text-align:center}
326
+ .exp-picker-name{flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
327
+ .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}
328
+ .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}
329
+ .exp-picker-empty{padding:12px 10px;text-align:center;color:var(--dsw-alias-label-dimmed);font-size:13px}
330
+ .exp-overlay{position:fixed;inset:0;z-index:2147483000;background:var(--dsw-alias-bg-base);overflow:auto;padding:20px 26px}
331
+ .exp-head{display:flex;align-items:center;gap:10px;margin-bottom:4px}
332
+ .exp-title{font-weight:600;font-size:16px}
333
+ .exp-spacer{flex:1}
334
+ </style>`
252
335
 
253
336
  // ── Helpers ──────────────────────────────────────────────────────────────
254
337
 
@@ -296,17 +379,51 @@ let expertRosterAt = 0
296
379
  const rosterListeners = new Set()
297
380
  const ROSTER_TTL = 60_000
298
381
 
382
+ /**
383
+ * Roster row mapping (pure, testable). The host slash menu renders only
384
+ * name + description per row, so the human-readable displayName is prefixed
385
+ * into the description — expert teams (👥) and single experts both show
386
+ * their real name next to the `expert-<id>` literal. The picker popover
387
+ * renders `displayName` as the primary label and `plainDescription` as the
388
+ * secondary line instead. Rows come out sorted by displayName(中文按拼音),
389
+ * the picker splits them into 专家/专家团 tabs by `team`.
390
+ */
391
+ function toRosterRows(mine, builtin) {
392
+ const rows = [...(Array.isArray(mine) ? mine : []), ...(Array.isArray(builtin) ? builtin : [])].map((e) => {
393
+ const displayName = e.displayName || e.name
394
+ const desc = e.description || e.profession || ''
395
+ return {
396
+ name: `expert-${e.name}`,
397
+ displayName,
398
+ description: displayName && displayName !== e.name ? (desc === '' ? displayName : `${displayName} · ${desc}`) : desc,
399
+ plainDescription: desc,
400
+ team: e.expertType === 'team',
401
+ icon: e.expertType === 'team' ? '👥' : '🧑‍💼',
402
+ }
403
+ })
404
+ rows.sort((a, b) => String(a.displayName).localeCompare(String(b.displayName), 'zh'))
405
+ return rows
406
+ }
407
+
408
+ /** Picker tab split (pure, testable): single experts vs expert teams. */
409
+ function splitRosterByType(rows) {
410
+ const agents = [], teams = []
411
+ for (const r of Array.isArray(rows) ? rows : []) (r.team ? teams : agents).push(r)
412
+ return { agents, teams }
413
+ }
414
+
415
+ /** Picker filter predicate (pure): match the displayed fields, not the `expert-` prefix. */
416
+ function pickerRowMatch(r, lower) {
417
+ return matchExpert({ name: r.name.replace(/^expert-/, ''), displayName: r.displayName, description: r.plainDescription }, lower)
418
+ }
419
+
299
420
  async function fetchRoster(force) {
300
421
  if (!force && expertRoster !== null && Date.now() - expertRosterAt < ROSTER_TTL) return expertRoster
301
422
  try {
302
423
  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
- }))
424
+ const mine = Array.isArray(data.mine) ? data.mine : []
425
+ const builtin = (Array.isArray(data.builtin) ? data.builtin : []).filter((e) => !e.installed)
426
+ expertRoster = toRosterRows(mine, builtin)
310
427
  expertRosterAt = Date.now()
311
428
  for (const listener of [...rosterListeners]) { try { listener() } catch {} }
312
429
  } catch { /* 菜单失败静默:候选组保持 pending/缺席 */ }
@@ -346,6 +463,10 @@ function makeExpertSource(t) {
346
463
  * end (host toggleCommandMenu 同款调用形状)。The standard kit exposes no
347
464
  * caret, so the pick replaces a collapsed span at draft end — picks die
348
465
  * quietly on span-CAS if the draft moved since the click.
466
+ *
467
+ * NOTE: the + 专家 button no longer uses this — the host menu cannot offer
468
+ * a search box, so the button opens ExpertPicker instead. Kept for the
469
+ * contract tests and as the documented toggleSource path.
349
470
  */
350
471
  function openTriggerSource(composerScope, sessionId, input, sourceName) {
351
472
  const inputTriggers = composerScope && composerScope.inputTriggers
@@ -368,6 +489,115 @@ function openTriggerSource(composerScope, sessionId, input, sourceName) {
368
489
  return true
369
490
  }
370
491
 
492
+ /**
493
+ * Insert `text` at the end of the session draft through the same scoped
494
+ * event the host slash menu executes (`slash/input-insert-text`). The span
495
+ * CAS uses the freshest input snapshot handed to the slot props — while the
496
+ * picker popover is open the composer draft cannot move (focus is in the
497
+ * picker), so the splice applies; a stale snapshot quietly no-ops, same as
498
+ * the host menu's span-CAS.
499
+ */
500
+ function insertComposerText(scope, sessionId, input, text) {
501
+ const sessions = scope && scope.sessions
502
+ if (!sessions) return false
503
+ let actx
504
+ try { actx = sessions.scope(sessionId) } catch { return false }
505
+ if (actx === undefined || actx === null || typeof actx.bail !== 'function') return false
506
+ const draft = (input && input.draft) || ''
507
+ const at = draft.length
508
+ try {
509
+ return actx.bail(actx, 'slash/input-insert-text', {
510
+ text,
511
+ span: { start: at, end: at, draftRev: (input && input.draftRev) || 0 },
512
+ }) === true
513
+ } catch { return false }
514
+ }
515
+
516
+ /** Best-effort refocus of the composer textarea after the picker closes. */
517
+ function refocusComposer() {
518
+ try {
519
+ const card = document.querySelector('[data-composer-card]')
520
+ const ta = card && card.querySelector('textarea')
521
+ if (ta && typeof ta.focus === 'function') ta.focus()
522
+ } catch {}
523
+ }
524
+
525
+ /** Picker popover list cap — beyond this the search input is the filter. */
526
+ const PICKER_ROW_CAP = 200
527
+
528
+ /**
529
+ * + 专家 picker:锚定在按钮上方、自带搜索框的候选浮层(portal 到 body)。
530
+ * 宿主斜杠菜单靠「输入的 query」过滤,按钮打开的菜单没有输入载体——候选
531
+ * 太多时无从筛选,所以浮层自带搜索框。专家/专家团分两个 tab(tab 标签上的
532
+ * 计数跟随当前搜索过滤),各 tab 内按显示名排序(toRosterRows 已排好)。
533
+ * 键盘 ↑/↓/Enter/Esc,鼠标 hover+点击;tab 按钮 mousedown 不抢输入框焦点。
534
+ */
535
+ function ExpertPicker(props) {
536
+ const t = props.t
537
+ const [query, setQuery] = useState('')
538
+ const [tab, setTab] = useState('agent') // 'agent' | 'team'
539
+ const [active, setActive] = useState(0)
540
+ const inputRef = useRef(null)
541
+ const listRef = useRef(null)
542
+ useEffect(() => { try { if (inputRef.current) inputRef.current.focus() } catch {} }, [])
543
+ useEffect(() => {
544
+ const onKey = (e) => { if (e.key === 'Escape' && !(e.isComposing === true)) props.onClose() }
545
+ try { document.addEventListener('keydown', onKey) } catch {}
546
+ return () => { try { document.removeEventListener('keydown', onKey) } catch {} }
547
+ }, [])
548
+ const lower = query.trim().toLowerCase()
549
+ const { agents, teams } = splitRosterByType(props.rows)
550
+ const filteredAgents = lower === '' ? agents : agents.filter((r) => pickerRowMatch(r, lower))
551
+ const filteredTeams = lower === '' ? teams : teams.filter((r) => pickerRowMatch(r, lower))
552
+ const shown = (tab === 'team' ? filteredTeams : filteredAgents).slice(0, PICKER_ROW_CAP)
553
+ useEffect(() => { setActive(0) }, [lower, tab, props.rows])
554
+ useEffect(() => {
555
+ const list = listRef.current
556
+ const el = list && list.children[active]
557
+ if (el && typeof el.scrollIntoView === 'function') { try { el.scrollIntoView({ block: 'nearest' }) } catch {} }
558
+ }, [active])
559
+ const onKeyDown = (e) => {
560
+ // IME 组词中的按键不触发选择(回车是选定拼音候选,不是 pick)
561
+ if (e.nativeEvent && e.nativeEvent.isComposing === true) return
562
+ if (e.key === 'ArrowDown') { e.preventDefault(); setActive((i) => Math.min(i + 1, shown.length - 1)) }
563
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)) }
564
+ else if (e.key === 'Enter') { e.preventDefault(); const row = shown[active]; if (row) props.onPick(row) }
565
+ }
566
+ const width = 400
567
+ const winW = typeof window !== 'undefined' ? window.innerWidth : 800
568
+ const winH = typeof window !== 'undefined' ? window.innerHeight : 600
569
+ const left = Math.max(8, Math.min(props.anchor.left, winW - width - 8))
570
+ const bottom = Math.max(8, winH - props.anchor.top + 6)
571
+ const tabBtn = (key, labelKey, count) => h('button', {
572
+ type: 'button', role: 'tab', className: 'exp-tab', 'data-on': tab === key, 'aria-selected': tab === key,
573
+ onMouseDown: (e) => { e.preventDefault(); setTab(key) }, onClick: () => setTab(key),
574
+ }, `${t(labelKey)} (${count})`)
575
+ return h('div', { className: 'exp-picker-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) props.onClose() } },
576
+ h('div', { className: 'exp-picker', style: { left, bottom, width }, role: 'dialog', 'aria-label': t('pickExpertTitle') },
577
+ h('input', {
578
+ ref: inputRef, className: 'exp-input exp-picker-input', value: query,
579
+ placeholder: t('searchPlaceholder'), onChange: (e) => setQuery(e.target.value), onKeyDown,
580
+ }),
581
+ h('div', { className: 'exp-tabs exp-picker-tabs', role: 'tablist' },
582
+ tabBtn('agent', 'pickerTabAgents', filteredAgents.length),
583
+ tabBtn('team', 'pickerTabTeams', filteredTeams.length)),
584
+ h('div', { className: 'exp-picker-list', ref: listRef, role: 'listbox' },
585
+ props.rows === null
586
+ ? h('div', { className: 'exp-picker-empty' }, t('pickerLoading'))
587
+ : shown.length === 0
588
+ ? h('div', { className: 'exp-picker-empty' }, t('pickerEmpty'))
589
+ : shown.map((row, i) => h('button', {
590
+ key: row.name, type: 'button', role: 'option', 'aria-selected': i === active,
591
+ className: 'exp-picker-row', 'data-active': i === active,
592
+ onMouseEnter: () => setActive(i),
593
+ onMouseDown: (e) => { e.preventDefault(); props.onPick(row) },
594
+ },
595
+ row.icon ? h('span', { className: 'exp-picker-icon', 'aria-hidden': 'true' }, row.icon) : null,
596
+ h('span', { className: 'exp-picker-name' }, row.displayName || row.name),
597
+ row.displayName && row.displayName !== row.name ? h('span', { className: 'exp-picker-literal' }, row.name) : null,
598
+ h('span', { className: 'exp-picker-desc' }, row.plainDescription || ''))))))
599
+ }
600
+
371
601
  // ── Small components ─────────────────────────────────────────────────────
372
602
 
373
603
  function Avatar({ expert, size }) {
@@ -442,18 +672,136 @@ function MemberAvatar({ expertName, source, member, t }) {
442
672
  : h('div', { className: 'exp-member-avatar', title: t('avatarLoadFailed') }, '👤')
443
673
  }
444
674
 
445
- function DetailModal({ name, source, t, onClose, onInstalled, onDeleted }) {
675
+ let shareDialogComponent = null
676
+ function getShareDialogComponent() {
677
+ if (shareDialogComponent === null) shareDialogComponent = PluginKit.makeActionShareDialog(__React)
678
+ return shareDialogComponent
679
+ }
680
+
681
+ /** 专家分享提示词:提交到 ntd-resource 的 experts/ 子树(与技能分享同管线、同 token)。 */
682
+ const EXPERT_SHARE_PROMPT = [
683
+ '请把本地专家「{{expertName}}」{{version}}打包提交到 GitCode 官方仓库 weibaohui/ntd-resource 的 experts/ 子树,作为一个 PR 供维护者审核。',
684
+ '',
685
+ '## 关键信息',
686
+ '- 专家目录:{{resourceDir}}(~ 表示当前用户家目录,执行前先展开为绝对路径)',
687
+ '- 官方仓库:weibaohui/ntd-resource(GitCode,API base = https://api.gitcode.com)',
688
+ '- PAT 位置:{{settingsFile}} 中 skills-management.market 段的 token 字段(由技能市场设置面板保存,与技能分享同源)。',
689
+ '',
690
+ '## 执行步骤(严格按顺序)',
691
+ '1. 读取 PAT:读取 {{settingsFile}},定位 skills-management.market 段下的 token 字段。token 是敏感凭据,读取后不要把明文打印到输出、日志或最终结果里。',
692
+ '2. 展开专家目录为绝对路径,遍历该目录(含 agents/、skills/、avatars/ 子目录与 .codebuddy-plugin/plugin.json),收集每个文件的「相对该目录的路径」与内容;跳过 .git 一类同步元数据。',
693
+ '3. 把第 1 步读到的 token 作为 HTTP 认证令牌(bearer),按顺序调用 GitCode API:',
694
+ ' a. 验证用户:`GET https://api.gitcode.com/api/v5/user`,拿到 login 字段——后续所有 URL 里的 {owner} 一律用它。',
695
+ ' b. fork:`POST https://api.gitcode.com/api/v5/repos/weibaohui/ntd-resource/forks`;若返回 409/422 表示已 fork,视为成功。',
696
+ ' c. 建分支:`POST https://api.gitcode.com/api/v5/repos/{owner}/ntd-resource/branches`,JSON body 为 {"branch_name":"experts/{{expertName}}-<unix 时间戳>","refs":"main"}。',
697
+ ' d. 写文件:对第 2 步收集的每个文件,`POST https://api.gitcode.com/api/v5/repos/{owner}/ntd-resource/contents/experts/{{该文件相对专家目录的路径}}`。**必须**用 experts/ 前缀,不能写到仓库根目录。表单字段 content=<文件字节的 base64>、message="贡献专家 {{expertName}} {{version}}"、branch=<步骤 c 的分支名>。',
698
+ ' e. 创建 PR:`POST https://api.gitcode.com/api/v5/repos/weibaohui/ntd-resource/pulls`,JSON body 为 {"title":"[专家] {{expertName}} {{version}}","body":"专家目录 {{resourceDir}} 的文件清单与用途简介","head":"{owner}:{branch}","base":"main"}。',
699
+ '4. 完成后,最终输出 PR 的网页链接(响应里的 web_url 字段)。',
700
+ '',
701
+ '## 注意',
702
+ '- token 是敏感凭据,任何输出里都不要回显其明文。',
703
+ '- 如果任一步骤失败,先检查错误信息,不要盲目重试;若 token 失效,提示用户到技能市场的 ⚙ 设置面板重新填写。',
704
+ '- 全程与最终汇报都使用中文。',
705
+ ].join('\n')
706
+
707
+ function DetailModal({ name, source, t, onClose, onInstalled, onDeleted, onToast }) {
446
708
  const [detail, setDetail] = useState(null)
447
709
  const [error, setError] = useState('')
448
710
  const [busy, setBusy] = useState(false)
449
711
  const [agentMd, setAgentMd] = useState(null)
712
+ const detailUrl = () => `${API}/detail?name=${encodeURIComponent(name)}${source ? `&source=${encodeURIComponent(source)}` : ''}`
713
+ const loadDetail = () => fetchJson(detailUrl())
714
+ .then((d) => setDetail(d))
715
+ .catch((e) => setError(String(e && e.message)))
716
+ useEffect(() => { loadDetail() }, [name, source])
717
+ // ── 编辑态(v0.3,仅 dsh 用户库专家)──
718
+ const [editingMd, setEditingMd] = useState(null) // {agent, content} | null
719
+ const [metaEdit, setMetaEdit] = useState(false)
720
+ const [metaForm, setMetaForm] = useState(null)
721
+ const [skillsEdit, setSkillsEdit] = useState(false)
722
+ const [availSkills, setAvailSkills] = useState(null)
723
+ const [saving, setSaving] = useState(false)
724
+ const [savedFlash, setSavedFlash] = useState('')
725
+ const [avatarBusy, setAvatarBusy] = useState(false)
726
+ const [shareOpen, setShareOpen] = useState(false)
727
+ const [shareSettingsFile, setShareSettingsFile] = useState('')
728
+ const avatarInputRef = useRef(null)
450
729
  useEffect(() => {
451
- let live = true
452
- fetchJson(`${API}/detail?name=${encodeURIComponent(name)}${source ? `&source=${encodeURIComponent(source)}` : ''}`)
453
- .then((d) => { if (live) setDetail(d) })
454
- .catch((e) => { if (live) setError(String(e && e.message)) })
455
- return () => { live = false }
456
- }, [name, source])
730
+ if (shareOpen === false) return
731
+ fetchJson(`${API}/share/status`).then((d) => setShareSettingsFile(d.settingsFile || '')).catch(() => setShareSettingsFile(''))
732
+ }, [shareOpen])
733
+ const flash = (text) => { setSavedFlash(text); setTimeout(() => setSavedFlash(''), 1600) }
734
+ const startEditMd = (agentName) => {
735
+ fetchJson(`${API}/agent-md?name=${encodeURIComponent(name)}${source ? `&source=${encodeURIComponent(source)}` : ''}&agent=${encodeURIComponent(agentName)}`)
736
+ .then((r) => setEditingMd({ agent: agentName, content: r.content }))
737
+ .catch((e) => setError(String(e && e.message)))
738
+ }
739
+ const saveMd = async () => {
740
+ setSaving(true); setError('')
741
+ try {
742
+ await fetchJson(`${API}/agent-md`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, agent: editingMd.agent, content: editingMd.content }) })
743
+ setEditingMd(null); flash(t('saved')); loadDetail()
744
+ } catch (e) { setError(String(e && e.message)) } finally { setSaving(false) }
745
+ }
746
+ const startMetaEdit = () => {
747
+ const p = (detail && detail.pluginJson) || {}
748
+ const loc = (v) => ({ zh: (v && v.zh) || '', en: (v && v.en) || '' })
749
+ const tags = Array.isArray(p.tags) ? p.tags : []
750
+ setMetaForm({
751
+ displayName: loc(p.displayName), profession: loc(p.profession),
752
+ displayDescription: loc(p.displayDescription), defaultInitPrompt: loc(p.defaultInitPrompt),
753
+ tagsZh: tags.map((x) => x.zh || '').filter(Boolean).join(','),
754
+ tagsEn: tags.map((x) => x.en || '').filter(Boolean).join(','),
755
+ quickPrompts: Array.isArray(p.quickPrompts) ? p.quickPrompts : [],
756
+ })
757
+ setMetaEdit(true)
758
+ }
759
+ const saveMeta = async () => {
760
+ setSaving(true); setError('')
761
+ const splitList = (v) => String(v || '').split(/[,,]/).map((x) => x.trim()).filter(Boolean)
762
+ const body = { metadata: {
763
+ displayName: metaForm.displayName, profession: metaForm.profession,
764
+ displayDescription: metaForm.displayDescription, defaultInitPrompt: metaForm.defaultInitPrompt,
765
+ tags: (function () {
766
+ const zhList = splitList(metaForm.tagsZh); const enList = splitList(metaForm.tagsEn)
767
+ const len = Math.max(zhList.length, enList.length)
768
+ return Array.from({ length: len }, (_, i) => ({ zh: zhList[i] || '', en: enList[i] || '' })).filter((t) => t.zh !== '' || t.en !== '')
769
+ })(),
770
+ quickPrompts: metaForm.quickPrompts,
771
+ } }
772
+ try {
773
+ await fetchJson(`${API}/metadata`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, ...body }) })
774
+ setMetaEdit(false); flash(t('saved')); loadDetail()
775
+ } catch (e) { setError(String(e && e.message)) } finally { setSaving(false) }
776
+ }
777
+ const detachSkill = (skillName) => {
778
+ if (!window.confirm(t('detachConfirm'))) return
779
+ fetchJson(`${API}/expert-skills`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, detach: [skillName] }) })
780
+ .then(() => { flash(t('saved')); loadDetail() })
781
+ .catch((e) => setError(String(e && e.message)))
782
+ }
783
+ const openSkillsEdit = () => {
784
+ setSkillsEdit(true)
785
+ if (availSkills === null) {
786
+ fetchJson(`${API}/available-skills`).then((d) => setAvailSkills(d.skills || [])).catch((e) => setError(String(e && e.message)))
787
+ }
788
+ }
789
+ const attachSkill = (skillName) => {
790
+ fetchJson(`${API}/expert-skills`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, attach: [skillName] }) })
791
+ .then(() => { flash(t('saved')); loadDetail() })
792
+ .catch((e) => setError(String(e && e.message)))
793
+ }
794
+ const onAvatarFile = async (e) => {
795
+ const f = e.target.files && e.target.files[0]
796
+ e.target.value = ''
797
+ if (!f) return
798
+ setAvatarBusy(true); setError('')
799
+ try {
800
+ const buf = new Uint8Array(await f.arrayBuffer())
801
+ await fetchJson(`${API}/avatar?name=${encodeURIComponent(name)}`, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: buf })
802
+ flash(t('saved')); loadDetail()
803
+ } catch (ex) { setError(String(ex && ex.message)) } finally { setAvatarBusy(false) }
804
+ }
457
805
  const loadAgentMd = () => {
458
806
  fetchJson(`${API}/agent-md?name=${encodeURIComponent(name)}${source ? `&source=${encodeURIComponent(source)}` : ''}`)
459
807
  .then((r) => setAgentMd(r.content))
@@ -495,6 +843,24 @@ function DetailModal({ name, source, t, onClose, onInstalled, onDeleted }) {
495
843
  kv(t('filesLabel'), `${detail.fileCount} / ${formatSize(detail.totalSize)}`),
496
844
  kv(t('dirLabel'), detail.dir)),
497
845
  detail.readOnly ? h('div', { className: 'exp-kv' }, h('span', { style: { color: 'var(--dsw-alias-label-tertiary)' } }, t('sourceReadonly'))) : null,
846
+ detail.source === 'dsh' ? h('div', { className: 'exp-form-row' },
847
+ h('button', { className: 'exp-btn', disabled: busy || saving || avatarBusy, onClick: startMetaEdit }, t('editMeta')),
848
+ h('button', { className: 'exp-btn', disabled: busy || saving || avatarBusy, onClick: () => { if (avatarInputRef.current) avatarInputRef.current.click() } }, avatarBusy ? t('uploading') : t('uploadAvatar')),
849
+ savedFlash ? h('span', { className: 'exp-flash' }, savedFlash) : null,
850
+ h('input', { ref: avatarInputRef, type: 'file', accept: 'image/png,image/jpeg,image/gif,image/webp', style: { display: 'none' }, onChange: onAvatarFile })) : null,
851
+ metaEdit && metaForm ? h('div', { className: 'exp-section' },
852
+ h('div', { className: 'exp-section-title' }, t('editMeta')),
853
+ ...['displayName', 'profession', 'displayDescription', 'defaultInitPrompt'].map((key) => h('div', { key, style: { marginBottom: '8px' } },
854
+ h('div', { className: 'exp-section-title', style: { margin: '4px 0' } }, t(key === 'profession' ? 'professionLabel' : key === 'tags' ? 'tagsLabel' : key)),
855
+ h('input', { className: 'exp-input', value: metaForm[key].zh, placeholder: 'zh', onChange: (e) => setMetaForm({ ...metaForm, [key]: { ...metaForm[key], zh: e.target.value } }), style: { marginBottom: '4px', width: '100%' } }),
856
+ h('input', { className: 'exp-input', value: metaForm[key].en, placeholder: 'en', onChange: (e) => setMetaForm({ ...metaForm, [key]: { ...metaForm[key], en: e.target.value } }), style: { width: '100%' } }))),
857
+ h('div', { style: { marginBottom: '8px' } },
858
+ h('div', { className: 'exp-section-title', style: { margin: '4px 0' } }, t('tagsLabel')),
859
+ h('input', { className: 'exp-input', value: metaForm.tagsZh, placeholder: 'zh,逗号分隔', onChange: (e) => setMetaForm({ ...metaForm, tagsZh: e.target.value }), style: { marginBottom: '4px', width: '100%' } }),
860
+ h('input', { className: 'exp-input', value: metaForm.tagsEn, placeholder: 'en, comma separated', onChange: (e) => setMetaForm({ ...metaForm, tagsEn: e.target.value }), style: { width: '100%' } })),
861
+ h('div', { className: 'exp-form-row' },
862
+ h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: saving, onClick: saveMeta }, saving ? '…' : t('save')),
863
+ h('button', { className: 'exp-btn', disabled: saving, onClick: () => setMetaEdit(false) }, t('cancel')))) : null,
498
864
  (detail.quickPromptsZh || []).length > 0 ? h('div', { className: 'exp-section' },
499
865
  h('div', { className: 'exp-section-title' }, t('quickPrompts')),
500
866
  ...detail.quickPromptsZh.slice(0, 5).map((q, i) => h('div', { className: 'exp-kv', key: i }, '• ', q))) : null,
@@ -510,13 +876,33 @@ function DetailModal({ name, source, t, onClose, onInstalled, onDeleted }) {
510
876
  h('div', { className: 'exp-section-title' }, t('skills')),
511
877
  ...detail.skillMeta.map((s) => h('div', { className: 'exp-skill-row', key: s.skillName },
512
878
  h('div', { style: { fontSize: 13 } }, `${s.emoji ? s.emoji + ' ' : ''}${s.skillName}`),
513
- h('div', { className: 'exp-profession' }, s.descriptionZh || s.descriptionEn || s.description || '')))) : null,
879
+ h('div', { className: 'exp-profession' }, s.descriptionZh || s.descriptionEn || s.description || ''),
880
+ detail.source === 'dsh' ? h('button', { className: 'exp-btn', style: { marginLeft: 'auto' }, onClick: () => detachSkill(s.skillName) }, t('detach')) : null)),
881
+ detail.source === 'dsh' ? h('div', { className: 'exp-form-row' },
882
+ skillsEdit ? null : h('button', { className: 'exp-btn', onClick: openSkillsEdit }, t('attachSkill'))) : null,
883
+ detail.source === 'dsh' && skillsEdit ? h('div', { className: 'exp-form-row' },
884
+ availSkills === null ? h('span', { className: 'exp-profession' }, '…')
885
+ : availSkills.length === 0 ? h('span', { className: 'exp-profession' }, '—')
886
+ : availSkills.map((sk) => h('button', { key: sk.name, className: 'exp-btn', title: sk.description, onClick: () => attachSkill(sk.name) }, `+ ${sk.name}`))) : null) : null,
514
887
  (detail.agentFiles || []).length > 0 ? h('div', { className: 'exp-section' },
515
888
  h('div', { className: 'exp-section-title' }, t('agents')),
516
889
  ...detail.agentFiles.map((a) => h('div', { className: 'exp-skill-row', key: a.relPath },
517
890
  h('div', { style: { fontSize: 13 } }, `${a.emoji ? a.emoji + ' ' : ''}${a.name}${a.name === detail.leadAgentFile ? ' ★' : ''}`),
518
- h('div', { className: 'exp-profession' }, a.description || '')))) : null,
891
+ h('div', { className: 'exp-profession' }, a.description || ''),
892
+ detail.source === 'dsh' ? h('button', { className: 'exp-btn', style: { marginLeft: 'auto' }, onClick: () => startEditMd(a.name) }, t('edit')) : null))) : null,
893
+ editingMd !== null ? h('div', { className: 'exp-section' },
894
+ h('div', { className: 'exp-section-title' }, `${t('edit')} · ${editingMd.agent}`),
895
+ h('textarea', {
896
+ className: 'exp-input', value: editingMd.content,
897
+ onChange: (e) => setEditingMd({ ...editingMd, content: e.target.value }),
898
+ spellCheck: false,
899
+ style: { width: '100%', minHeight: '260px', fontFamily: 'ui-monospace,monospace', fontSize: '12px', lineHeight: 1.6, whiteSpace: 'pre-wrap', background: 'var(--dsw-alias-bg-layer-2,transparent)', color: 'inherit', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3))', borderRadius: '8px', padding: '10px', boxSizing: 'border-box' },
900
+ }),
901
+ h('div', { className: 'exp-form-row' },
902
+ h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: saving, onClick: saveMd }, saving ? '…' : t('save')),
903
+ h('button', { className: 'exp-btn', disabled: saving, onClick: () => setEditingMd(null) }, t('cancel')))) : null,
519
904
  h('div', { className: 'exp-form-row' },
905
+ h('button', { className: 'exp-btn', onClick: () => setShareOpen(true) }, t('shareBtn')),
520
906
  h('button', { className: 'exp-btn', onClick: () => (agentMd === null ? loadAgentMd() : setAgentMd(null)) }, agentMd === null ? t('viewAgentMd') : t('hideAgentMd')),
521
907
  detail.source !== 'dsh'
522
908
  ? h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: busy, onClick: () => install(false) }, busy ? t('installing') : t('install'))
@@ -528,19 +914,28 @@ function DetailModal({ name, source, t, onClose, onInstalled, onDeleted }) {
528
914
  detail.plugin ? h('details', null,
529
915
  h('summary', { style: { cursor: 'pointer', fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } }, t('pluginJson')),
530
916
  h('pre', { className: 'exp-pre' }, JSON.stringify(detail.plugin, null, 2))) : null,
531
- ) : null))
917
+ ) : null,
918
+ shareOpen ? h(getShareDialogComponent(), {
919
+ title: t('shareTitle'), hint: t('shareHint'),
920
+ rows: [[t('shareParamName'), detail.name], [t('shareParamVersion'), detail.version || '1.0.0'], [t('shareParamDir'), detail.dir]],
921
+ initialPrompt: PluginKit.substituteParams(EXPERT_SHARE_PROMPT, { expertName: detail.name, version: detail.version || '1.0.0', resourceDir: detail.dir, settingsFile: shareSettingsFile }),
922
+ labels: { copy: t('copyPrompt'), copied: t('copied'), run: t('runBtn'), running: t('running'), done: t('runDone'), failed: t('runFailed'), outputLabel: t('outputLabel') },
923
+ run: (prompt) => fetchJson(`${API}/share/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, dir: detail.dir }) }),
924
+ poll: (id) => fetchJson(`${API}/share/run?id=${encodeURIComponent(id)}`),
925
+ onClose: () => setShareOpen(false),
926
+ }) : null))
532
927
  }
533
928
 
534
- function MarketSettingsCard({ t, onToast, onSynced }) {
929
+ function BuiltinSettingsDialog({ t, onClose, onToast, onSynced }) {
535
930
  const [status, setStatus] = useState(null)
536
931
  const [form, setForm] = useState(null)
537
932
  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 }) })
933
+ 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
934
  useEffect(() => { load() }, [])
540
935
  const sync = async () => {
541
936
  setBusy(true)
542
937
  try {
543
- const r = await fetchJson(`${API}/market/sync`, { method: 'POST' })
938
+ const r = await fetchJson(`${API}/builtin/sync`, { method: 'POST' })
544
939
  onToast(r.isFirstClone ? t('firstCloneDone') : r.hasUpdates ? t('syncDoneUpdated') : t('syncDoneLatest'))
545
940
  await load()
546
941
  onSynced()
@@ -554,51 +949,72 @@ function MarketSettingsCard({ t, onToast, onSynced }) {
554
949
  const dirText = (form.repoDir || '').trim()
555
950
  if (dirText !== '' && dirText !== (status && status.dir)) patch.repoDir = dirText
556
951
  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) })
952
+ await fetchJson(`${API}/builtin/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
559
953
  setForm((f) => ({ ...f, token: '' }))
560
954
  onToast(t('saved'))
561
955
  await load()
562
956
  } catch (e) { onToast(String(e && e.message)) }
563
957
  setBusy(false)
564
958
  }
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'))))
959
+ const clearToken = async () => {
960
+ setBusy(true)
961
+ try {
962
+ await fetchJson(`${API}/builtin/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: null }) })
963
+ onToast(t('saved'))
964
+ await load()
965
+ } catch (e) { onToast(String(e && e.message)) }
966
+ setBusy(false)
967
+ }
968
+ // 布局逐行对齐技能市场的市场设置弹窗:状态区 = 左标签/右值成行;
969
+ // 输入区 = 全宽堆叠、placeholder 即标签;底部 = 右对齐 保存 + 立即同步(主按钮)
970
+ const short = (c) => (c ? String(c).slice(0, 8) : '-')
971
+ const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
972
+ h('span', { className: 'exp-profession' }, label), h('span', { style: { wordBreak: 'break-all', textAlign: 'right', fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, value))
973
+ return h('div', { className: 'exp-modal-backdrop', onClick: (e) => { if (e.target === e.currentTarget) onClose() } },
974
+ h('div', { className: 'exp-modal', style: { maxWidth: 640 } },
975
+ h('div', { className: 'exp-modal-head' },
976
+ h('span', { className: 'exp-title' }, t('builtinSettings')),
977
+ h('button', { className: 'exp-btn exp-modal-close', onClick: onClose }, t('close'))),
978
+ status === null ? h('div', { className: 'exp-empty' }, '…')
979
+ : h('div', { style: { display: 'contents' } },
980
+ !status.gitAvailable ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12 } }, t('gitMissing')) : null,
981
+ h('div', null,
982
+ row(t('repoUrlLabel'), status.url),
983
+ row(t('branchLabel'), status.branch),
984
+ row(t('localCommitLabel'), short(status.localCommit)),
985
+ status.remoteCommit ? h('div', { style: { display: 'flex', justifyContent: 'flex-end' } },
986
+ status.needsUpdate ? Badge({ kind: 'type', children: t('needsUpdateTag') }) : null) : null,
987
+ row(t('remoteCommitLabel'), short(status.remoteCommit)),
988
+ row(t('lastSyncLabel'), status.lastSyncAt ? formatTime(status.lastSyncAt) : t('never')),
989
+ row(t('repoDirLabel'), status.dir),
990
+ status.sparsePaths ? row('sparse', (status.sparsePaths || []).join(', ')) : null),
991
+ h('div', { style: { display: 'flex', gap: 16, flexWrap: 'wrap' } },
992
+ h('label', { className: 'exp-checkline' },
993
+ h('input', { type: 'checkbox', checked: !!form.autoSync, onChange: (e) => setForm({ ...form, autoSync: e.target.checked }) }), t('autoSyncLabel')),
994
+ h('label', { className: 'exp-checkline' },
995
+ h('input', { type: 'checkbox', checked: !!form.syncOnStartup, onChange: (e) => setForm({ ...form, syncOnStartup: e.target.checked }) }), t('syncOnStartupLabel'))),
996
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
997
+ 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' } }),
998
+ 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' } }),
999
+ 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' } }),
1000
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
1001
+ h('input', { className: 'exp-input', type: 'password', value: form.token ?? '', onChange: (e) => setForm({ ...form, token: e.target.value }),
1002
+ placeholder: status.hasToken ? `${t('tokenLabel')} · ${t('tokenConfigured')}` : t('tokenLabel'), style: { flex: 1 } }),
1003
+ status.hasToken ? h('button', { className: 'exp-btn', disabled: busy, onClick: clearToken }, t('clearToken')) : null)),
1004
+ h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 } },
1005
+ h('button', { className: 'exp-btn', disabled: busy, onClick: save }, t('save')),
1006
+ h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: busy || status.syncing, onClick: sync }, busy || status.syncing ? t('syncing') : t('syncNow'))))))
592
1007
  }
593
1008
 
594
1009
  // ── Page ─────────────────────────────────────────────────────────────────
595
1010
 
596
- function ExpertsPage({ t, embedded }) {
597
- const [tab, setTab] = useState('market')
1011
+ function ExpertsPage({ t, embedded, onClose }) {
1012
+ const [tab, setTab] = useState('builtin')
598
1013
  const [data, setData] = useState(null)
599
1014
  const [error, setError] = useState('')
600
1015
  const [search, setSearch] = useState('')
601
1016
  const [selected, setSelected] = useState(null) // {name, source}
1017
+ const [settingsOpen, setSettingsOpen] = useState(false)
602
1018
  const [busyName, setBusyName] = useState(null)
603
1019
  const [toast, setToast] = useState(null)
604
1020
  const showToast = (text) => { setToast(text); setTimeout(() => setToast(null), 2600) }
@@ -607,7 +1023,7 @@ function ExpertsPage({ t, embedded }) {
607
1023
  const rows = useMemo(() => {
608
1024
  if (!data) return []
609
1025
  const lower = search.trim().toLowerCase()
610
- const list = tab === 'installed' ? data.installed : data.market
1026
+ const list = tab === 'mine' ? data.mine : data.builtin
611
1027
  return list.filter((e) => matchExpert(e, lower))
612
1028
  }, [data, tab, search])
613
1029
  const install = async (row) => {
@@ -637,16 +1053,20 @@ function ExpertsPage({ t, embedded }) {
637
1053
  } catch (e) { showToast(String(e && e.message)) }
638
1054
  setBusyName(null)
639
1055
  }
640
- return h('div', { className: 'exp-page' },
1056
+ return h('div', { className: 'exp-page' + (embedded ? '' : ' exp-overlay') },
1057
+ !embedded ? h('div', { className: 'exp-head' },
1058
+ h('span', { className: 'exp-title' }, t('title')),
1059
+ h('span', { className: 'exp-spacer' }),
1060
+ h('button', { className: 'exp-btn', onClick: () => { if (onClose) onClose() } }, t('close'))) : null,
641
1061
  h('div', { className: 'exp-toolbar' },
642
1062
  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})` : ''}`)),
1063
+ h('button', { className: 'exp-tab', 'data-on': tab === 'mine', onClick: () => setTab('mine') }, `${t('tabMine')}${data ? ` (${data.mine.length})` : ''}`),
1064
+ h('button', { className: 'exp-tab', 'data-on': tab === 'builtin', onClick: () => setTab('builtin') }, `${t('tabBuiltin')}${data ? ` (${data.builtin.length})` : ''}`)),
645
1065
  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,
1066
+ h('span', { className: 'exp-count' }, `${rows.length}`),
1067
+ tab === 'builtin' ? h('button', { className: 'exp-btn', title: t('builtinSettings'), onClick: () => setSettingsOpen(true) }, t('builtinSettings')) : null),
648
1068
  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,
1069
+ data !== null && rows.length === 0 ? h('div', { className: 'exp-empty' }, tab === 'mine' ? t('mineEmpty') : t('builtinEmpty')) : null,
650
1070
  rows.length > 0
651
1071
  ? h(PagedGrid, {
652
1072
  items: rows,
@@ -658,6 +1078,9 @@ function ExpertsPage({ t, embedded }) {
658
1078
  onInstalled: () => { setSelected(null); showToast(t('installedDone')); reload() },
659
1079
  onDeleted: () => { setSelected(null); showToast(t('removedDone')); reload() },
660
1080
  }) : null,
1081
+ settingsOpen ? h(BuiltinSettingsDialog, {
1082
+ t, onClose: () => setSettingsOpen(false), onToast: showToast, onSynced: reload,
1083
+ }) : null,
661
1084
  toast !== null ? h('div', { className: 'exp-toast' }, toast) : null)
662
1085
  }
663
1086
 
@@ -669,6 +1092,7 @@ module.exports = {
669
1092
  __internals: {
670
1093
  NS, ZH, EN, matchExpert, formatSize, formatTime, avatarUrl,
671
1094
  EXPERT_SOURCE_NAME, makeExpertSource, openTriggerSource, fetchRoster,
1095
+ toRosterRows, insertComposerText, splitRosterByType, pickerRowMatch,
672
1096
  },
673
1097
  /** Test/host helper: mount a standalone page into any container. */
674
1098
  __boot(container, opts = {}) {
@@ -720,20 +1144,6 @@ module.exports = {
720
1144
  })
721
1145
  }
722
1146
  } 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
1147
  ctx.effect(() => {
738
1148
  try {
739
1149
  ctx.slots.inject('settings.section', () => ctx.slots.register({
@@ -759,8 +1169,8 @@ module.exports = {
759
1169
  inject: () => ({ t }),
760
1170
  }, function ExpertButtonSlot(apiProps) {
761
1171
  return h(ComposerButtonSlot, {
762
- __t: t, icon: '🧑‍💼', label: t('pickExpert'), title: t('pickExpertTitle'),
763
- source: EXPERT_SOURCE_NAME, composerScopeRef: () => composerScope,
1172
+ __t: t, label: t('pickExpert'), title: t('pickExpertTitle'),
1173
+ composerScopeRef: () => composerScope,
764
1174
  sessionId: apiProps && apiProps.sessionId, input: apiProps && apiProps.input,
765
1175
  })
766
1176
  }))
@@ -769,34 +1179,46 @@ module.exports = {
769
1179
  },
770
1180
  }
771
1181
 
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). */
1182
+ /** Composer tool-row button: 加号+文字 chip,点击在按钮上方打开自带搜索的
1183
+ * 专家 picker 浮层;pick slash/input-insert-text 写入 `/expert-<name> `。
1184
+ * 浮层面板盖住按钮以外的区域,再点一次按钮会先落在背板上——天然形成开关切换。
1185
+ * inputTriggers/sessions 服务缺席时按钮隐藏(管理页不受影响)。 */
790
1186
  function ComposerButtonSlot(props) {
791
1187
  useEffect(ensureStyles, [])
1188
+ const [picker, setPicker] = useState(null) // {left, top} 锚点快照;null = 关闭
1189
+ const [rows, setRows] = useState(null) // null = 加载中
1190
+ const btnRef = useRef(null)
1191
+ const liveInput = useRef(props.input)
1192
+ liveInput.current = props.input
792
1193
  const composerScope = props.composerScopeRef ? props.composerScopeRef() : null
793
- const ready = !!(composerScope && composerScope.inputTriggers && composerScope.sessions && props.sessionId)
1194
+ const ready = !!(composerScope && composerScope.sessions && props.sessionId)
794
1195
  if (!ready) return null
1196
+ const close = () => setPicker(null)
1197
+ const open = () => {
1198
+ let anchor = { left: 16, top: 160 }
1199
+ try { if (btnRef.current) anchor = btnRef.current.getBoundingClientRect() } catch {}
1200
+ setPicker({ left: anchor.left, top: anchor.top })
1201
+ setRows(null)
1202
+ Promise.resolve(fetchRoster())
1203
+ .then((list) => setRows(Array.isArray(list) ? list : []))
1204
+ .catch(() => setRows([]))
1205
+ }
1206
+ const pick = (row) => {
1207
+ insertComposerText(composerScope, props.sessionId, liveInput.current, `/${row.name} `)
1208
+ close()
1209
+ refocusComposer()
1210
+ }
1211
+ const popover = picker !== null && RDP && typeof RDP.createPortal === 'function'
1212
+ ? RDP.createPortal(h(ExpertPicker, { t: props.__t, anchor: picker, rows, onClose: close, onPick: pick }), document.body)
1213
+ : null
795
1214
  return h('button', {
796
1215
  className: 'exp-chip',
1216
+ ref: btnRef,
797
1217
  title: props.title || props.label,
798
- onClick: () => { openTriggerSource(composerScope, props.sessionId, props.input, props.source) },
799
- }, `${props.icon || ''}${props.icon ? ' ' : ''}${props.label}`)
1218
+ 'aria-haspopup': 'dialog',
1219
+ 'aria-expanded': picker !== null,
1220
+ onClick: open,
1221
+ }, props.label, popover)
800
1222
  }
801
1223
 
802
1224
  /** Settings section slot entry: render the page directly in the host tree. */