@weibaohui/experts-management 0.1.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.
@@ -0,0 +1,801 @@
1
+ /**
2
+ * dsh-plugin-experts-management - Browser half.
3
+ *
4
+ * One React app for every surface (sidebar overlay + settings section):
5
+ * expert management page (installed / market views + detail modal + market
6
+ * sync settings). Plus the composer integration:
7
+ * - an `expert` input-trigger source on `/` (candidates from the plugin's own
8
+ * HTTP API; pick inserts the literal `/expert-<name> ` token whose send the
9
+ * 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`.
12
+ *
13
+ * All interactive controls are host primitives
14
+ * (@deepseek-ai/dsh-client-ui-primitives); all colors come from the ui-theme
15
+ * `--dsw-*` token layers so light/dark follows the shell; all copy comes from
16
+ * the locale registry (`zh`/`en`).
17
+ */
18
+
19
+ // React is a loader platform module. Under plain Node (contract tests) a
20
+ // minimal createElement/hook shim keeps the source loadable for assertions.
21
+ let __React = null
22
+ try { __React = require('react') } catch {}
23
+ if (!__React || typeof __React.createElement !== 'function') {
24
+ __React = {
25
+ createElement(type, props, ...kids) {
26
+ return { type, props: props || {}, kids: kids.flat(9).filter(k => k !== null && k !== undefined && k !== false && k !== true) }
27
+ },
28
+ useState(init) { const v = [typeof init === 'function' ? init() : init]; return [v[0], x => { v[0] = typeof x === 'function' ? x(v[0]) : x }] },
29
+ useEffect() {}, useMemo(fn) { return fn() }, useRef(v = null) { return { current: v } },
30
+ }
31
+ }
32
+ const { createElement: h, useState, useEffect, useMemo, useRef } = __React
33
+
34
+ // Platform module — always present in the loader's seeded require table.
35
+ // Under plain Node (tests) a shim keeps the tree structurally testable.
36
+ let P = null
37
+ try { P = require('@deepseek-ai/dsh-client-ui-primitives') } catch {}
38
+ let RDP = null
39
+ try { RDP = require('react-dom') } catch {}
40
+
41
+ const CLIENT_NAME = '@weibaohui/experts-management'
42
+ const API = '/experts-management/api'
43
+
44
+ /** Idempotent stylesheet injection. */
45
+ function ensureStyles() {
46
+ if (typeof document === 'undefined' || document.getElementById('exp-styles')) return
47
+ const holder = document.createElement('div')
48
+ holder.id = 'exp-styles'
49
+ holder.style.display = 'none'
50
+ holder.innerHTML = STYLE
51
+ document.head.appendChild(holder)
52
+ }
53
+
54
+ const prim = (name) => P && P[name]
55
+ ? P[name]
56
+ : function Shim(props) {
57
+ const { children, ...rest } = props
58
+ return h('button', { ...rest, 'data-p-shim': name }, children)
59
+ }
60
+
61
+ // ── Locale ───────────────────────────────────────────────────────────────
62
+
63
+ const NS = 'expertsManagement'
64
+
65
+ const ZH = {
66
+ title: '专家市场',
67
+ close: '关闭',
68
+ tabInstalled: '已安装',
69
+ tabMarket: '市场',
70
+ searchPlaceholder: '搜索专家名称、职业、描述…',
71
+ installedEmpty: '用户库还没有专家。去「市场」页浏览并安装。',
72
+ marketEmpty: '市场为空。请在设置中同步市场仓库。',
73
+ expertTypeAgent: '专家',
74
+ expertTypeTeam: '团队',
75
+ sourceLabel: '来源',
76
+ installedTag: '已安装',
77
+ detail: '详情',
78
+ install: '安装',
79
+ installing: '安装中…',
80
+ installedDone: '已安装到用户库',
81
+ overwrite: '覆盖安装',
82
+ remove: '删除',
83
+ removing: '删除中…',
84
+ removedDone: '已从用户库删除',
85
+ deleteConfirm: '确定从用户库删除该专家?',
86
+ members: '团队成员',
87
+ lead: '负责人',
88
+ member: '成员',
89
+ agents: '角色定义(Agent MD)',
90
+ skills: '关联技能',
91
+ quickPrompts: '快捷指令',
92
+ initPrompt: '默认开场',
93
+ viewAgentMd: '查看角色定义全文',
94
+ hideAgentMd: '收起',
95
+ pluginJson: 'plugin.json',
96
+ dirLabel: '目录',
97
+ filesLabel: '文件',
98
+ versionLabel: '版本',
99
+ sourceReadonly: '只读来源(可在 NTD 中管理,或安装到用户库)',
100
+ marketSettings: '市场设置',
101
+ syncNow: '立即同步',
102
+ syncing: '同步中,可能需要一分钟…',
103
+ syncDoneUpdated: '同步完成,市场已更新',
104
+ syncDoneLatest: '已是最新版本',
105
+ firstCloneDone: '首次克隆完成',
106
+ repoUrlLabel: '仓库地址',
107
+ branchLabel: '分支',
108
+ repoDirLabel: '本地目录(稀疏检出 experts 子树)',
109
+ tokenLabel: '访问令牌(私有仓库需要)',
110
+ tokenConfigured: '已配置',
111
+ clearToken: '清除',
112
+ lastSyncLabel: '上次同步',
113
+ localCommitLabel: '本地版本',
114
+ remoteCommitLabel: '远程版本',
115
+ needsUpdateTag: '有更新',
116
+ autoSyncLabel: '每天自动同步',
117
+ syncOnStartupLabel: '启动时同步',
118
+ save: '保存',
119
+ saved: '设置已保存',
120
+ gitMissing: '未检测到 git',
121
+ never: '从未',
122
+ loadFailed: '加载失败',
123
+ noDescription: '暂无描述',
124
+ avatarLoadFailed: '头像加载失败',
125
+ pickExpert: '+专家',
126
+ pickExpertTitle: '选择一位专家,以该专家的身份执行本条任务',
127
+ }
128
+
129
+ const EN = {
130
+ title: 'Expert Market',
131
+ close: 'Close',
132
+ tabInstalled: 'Installed',
133
+ tabMarket: 'Market',
134
+ searchPlaceholder: 'Search experts by name, profession, description…',
135
+ installedEmpty: 'No experts in the user library yet. Browse the Market tab and install one.',
136
+ marketEmpty: 'Market is empty. Sync the market repo in settings.',
137
+ expertTypeAgent: 'Expert',
138
+ expertTypeTeam: 'Team',
139
+ sourceLabel: 'Source',
140
+ installedTag: 'Installed',
141
+ detail: 'Details',
142
+ install: 'Install',
143
+ installing: 'Installing…',
144
+ installedDone: 'Installed to the user library',
145
+ overwrite: 'Overwrite install',
146
+ remove: 'Delete',
147
+ removing: 'Deleting…',
148
+ removedDone: 'Removed from the user library',
149
+ deleteConfirm: 'Delete this expert from the user library?',
150
+ members: 'Team members',
151
+ lead: 'Lead',
152
+ member: 'Member',
153
+ agents: 'Role definitions (Agent MD)',
154
+ skills: 'Skills',
155
+ quickPrompts: 'Quick prompts',
156
+ initPrompt: 'Default opener',
157
+ viewAgentMd: 'View full role definition',
158
+ hideAgentMd: 'Collapse',
159
+ pluginJson: 'plugin.json',
160
+ dirLabel: 'Directory',
161
+ filesLabel: 'Files',
162
+ versionLabel: 'Version',
163
+ sourceReadonly: 'Read-only source (manage in NTD, or install into the user library)',
164
+ marketSettings: 'Market settings',
165
+ syncNow: 'Sync now',
166
+ syncing: 'Syncing, may take a minute…',
167
+ syncDoneUpdated: 'Sync complete, market updated',
168
+ syncDoneLatest: 'Already up to date',
169
+ firstCloneDone: 'First clone done',
170
+ repoUrlLabel: 'Repository URL',
171
+ branchLabel: 'Branch',
172
+ repoDirLabel: 'Local directory (sparse checkouts the experts subtree)',
173
+ tokenLabel: 'Access token (required for private repos)',
174
+ tokenConfigured: 'Configured',
175
+ clearToken: 'Clear',
176
+ lastSyncLabel: 'Last sync',
177
+ localCommitLabel: 'Local commit',
178
+ remoteCommitLabel: 'Remote commit',
179
+ needsUpdateTag: 'Update available',
180
+ autoSyncLabel: 'Auto sync daily',
181
+ syncOnStartupLabel: 'Sync on startup',
182
+ save: 'Save',
183
+ saved: 'Settings saved',
184
+ gitMissing: 'git not found',
185
+ never: 'Never',
186
+ loadFailed: 'Load failed',
187
+ noDescription: 'No description',
188
+ avatarLoadFailed: 'Avatar failed to load',
189
+ pickExpert: '+ Expert',
190
+ pickExpertTitle: 'Pick an expert to handle this message in their persona',
191
+ }
192
+
193
+ // ── Styles ───────────────────────────────────────────────────────────────
194
+
195
+ const STYLE = `
196
+ .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)}
197
+ .exp-toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
198
+ .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}
199
+ .exp-tab{border:0;background:transparent;color:var(--dsw-alias-label-secondary);padding:5px 14px;border-radius:8px;cursor:pointer;font:inherit}
200
+ .exp-tab[data-on="true"]{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary)}
201
+ .exp-search{flex:1;min-width:180px;max-width:420px}
202
+ .exp-count{color:var(--dsw-alias-label-tertiary);font-size:12px}
203
+ .exp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}
204
+ .exp-card{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;cursor:pointer;transition:border-color .15s}
205
+ .exp-card:hover{border-color:var(--dsw-alias-border-l3)}
206
+ .exp-card-head{display:flex;gap:10px;align-items:center}
207
+ .exp-avatar{width:44px;height:44px;border-radius:12px;object-fit:cover;background:var(--dsw-alias-bg-layer-2)}
208
+ .exp-avatar-fallback{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:22px;background:var(--dsw-alias-bg-layer-2)}
209
+ .exp-name{font-weight:600;font-size:14px;line-height:1.3}
210
+ .exp-profession{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:2px}
211
+ .exp-desc{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
212
+ .exp-tags{display:flex;gap:4px;flex-wrap:wrap}
213
+ .exp-tag{font-size:11px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);border-radius:6px;padding:2px 7px}
214
+ .exp-badges{display:flex;gap:6px;align-items:center;margin-left:auto}
215
+ .exp-badge{font-size:11px;border-radius:6px;padding:2px 7px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}
216
+ .exp-badge[data-kind="type"]{color:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}
217
+ .exp-badge[data-kind="installed"]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}
218
+ .exp-card-actions{display:flex;gap:8px;justify-content:flex-end}
219
+ .exp-btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:5px 12px;cursor:pointer;font:inherit;font-size:13px}
220
+ .exp-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}
221
+ .exp-btn[data-primary="true"]{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-inverted)}
222
+ .exp-btn[data-danger="true"]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}
223
+ .exp-btn:disabled{opacity:.5;cursor:default}
224
+ .exp-empty{color:var(--dsw-alias-label-tertiary);padding:36px 0;text-align:center}
225
+ .exp-modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:60}
226
+ .exp-modal{width:min(860px,92vw);max-height:86vh;overflow:auto;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:14px}
227
+ .exp-modal-head{display:flex;gap:14px;align-items:center}
228
+ .exp-modal-close{margin-left:auto}
229
+ .exp-section{display:flex;flex-direction:column;gap:6px}
230
+ .exp-section-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-tertiary);text-transform:uppercase;letter-spacing:.04em}
231
+ .exp-kv{display:flex;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary)}
232
+ .exp-kv b{color:var(--dsw-alias-label-primary);font-weight:500}
233
+ .exp-pre{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:12px;font-size:12px;line-height:1.55;overflow:auto;max-height:320px;white-space:pre-wrap;word-break:break-word;color:var(--dsw-alias-label-primary)}
234
+ .exp-member-grid{display:flex;gap:10px;flex-wrap:wrap}
235
+ .exp-member{display:flex;gap:8px;align-items:center;background:var(--dsw-alias-bg-layer-2);border-radius:10px;padding:8px 12px}
236
+ .exp-member-avatar{width:32px;height:32px;border-radius:8px;object-fit:cover;background:var(--dsw-alias-bg-layer-3)}
237
+ .exp-skill-row{display:flex;flex-direction:column;gap:2px;padding:8px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}
238
+ .exp-skill-row:last-child{border-bottom:0}
239
+ .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}
240
+ .exp-form-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
241
+ .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}
242
+ .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%}
243
+ .exp-status-line{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary)}
244
+ .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)}
245
+ .exp-checkline{display:flex;gap:6px;align-items:center;font-size:13px;color:var(--dsw-alias-label-secondary)}
246
+ .exp-checkline input{accent-color:var(--dsw-alias-brand-primary)}
247
+ .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}
248
+ .exp-chip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
249
+ `
250
+
251
+ // ── Helpers ──────────────────────────────────────────────────────────────
252
+
253
+ async function fetchJson(url, options) {
254
+ const res = await fetch(url, options)
255
+ const payload = await res.json().catch(() => ({}))
256
+ if (!res.ok) throw new Error(payload && payload.error ? payload.error : `HTTP ${res.status}`)
257
+ return payload
258
+ }
259
+
260
+ function formatSize(bytes) {
261
+ if (typeof bytes !== 'number' || Number.isNaN(bytes)) return ''
262
+ if (bytes < 1024) return `${bytes} B`
263
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
264
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
265
+ }
266
+
267
+ function formatTime(iso) {
268
+ if (typeof iso !== 'string' || iso === '') return ''
269
+ const d = new Date(iso)
270
+ if (Number.isNaN(d.getTime())) return iso
271
+ return d.toLocaleString()
272
+ }
273
+
274
+ function avatarUrl(name, source, member) {
275
+ const q = new URLSearchParams({ name, source: source || '' })
276
+ if (member) q.set('member', member)
277
+ return `${API}/avatar?${q.toString()}`
278
+ }
279
+
280
+ function matchExpert(e, lower) {
281
+ if (lower === '') return true
282
+ const hay = [e.name, e.displayName, e.profession, e.description, ...(e.tags || [])].join(' ').toLowerCase()
283
+ return hay.includes(lower)
284
+ }
285
+
286
+ // ── Composer integration: `expert` trigger source + +专家 button ─────────
287
+
288
+ /** Menu group title (slash.menu dictionaries key titles by source name). */
289
+ const EXPERT_SOURCE_NAME = 'expert'
290
+
291
+ /** Roster cache: candidates for the trigger menu + the chip-decoration lexicon. */
292
+ let expertRoster = null
293
+ let expertRosterAt = 0
294
+ const rosterListeners = new Set()
295
+ const ROSTER_TTL = 60_000
296
+
297
+ async function fetchRoster(force) {
298
+ if (!force && expertRoster !== null && Date.now() - expertRosterAt < ROSTER_TTL) return expertRoster
299
+ try {
300
+ const data = await fetchJson(API)
301
+ const installed = Array.isArray(data.installed) ? data.installed : []
302
+ const market = (Array.isArray(data.market) ? data.market : []).filter((e) => !e.installed)
303
+ expertRoster = [...installed, ...market].map((e) => ({
304
+ name: `expert-${e.name}`,
305
+ description: e.description || e.profession || '',
306
+ icon: e.expertType === 'team' ? '👥' : '🧑‍💼',
307
+ }))
308
+ expertRosterAt = Date.now()
309
+ for (const listener of [...rosterListeners]) { try { listener() } catch {} }
310
+ } catch { /* 菜单失败静默:候选组保持 pending/缺席 */ }
311
+ return expertRoster
312
+ }
313
+
314
+ /** InputTriggerSource(ui-skill 同构):pick 落 `/expert-<name> ` 字面量,宿主手势边界注入角色。 */
315
+ function makeExpertSource() {
316
+ return {
317
+ trigger: '/',
318
+ name: EXPERT_SOURCE_NAME,
319
+ order: 300,
320
+ async candidates(session, { query, signal }) {
321
+ const rows = (await fetchRoster()) || []
322
+ if (signal && signal.aborted) return []
323
+ return rows.filter((r) => r.name.startsWith(String(query || ''))).slice(0, 80)
324
+ },
325
+ warm() { void fetchRoster() },
326
+ lexicon() {
327
+ return expertRoster !== null ? expertRoster.map((r) => r.name) : undefined
328
+ },
329
+ subscribeLexicon(session, listener) {
330
+ rosterListeners.add(listener)
331
+ return () => { rosterListeners.delete(listener) }
332
+ },
333
+ onPick({ candidate }) {
334
+ return { text: `/${candidate.name} ` }
335
+ },
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Open one registered '/' source over a synthetic span appended at the draft
341
+ * end (host toggleCommandMenu 同款调用形状)。The standard kit exposes no
342
+ * caret, so the pick replaces a collapsed span at draft end — picks die
343
+ * quietly on span-CAS if the draft moved since the click.
344
+ */
345
+ function openTriggerSource(composerScope, sessionId, input, sourceName) {
346
+ const inputTriggers = composerScope && composerScope.inputTriggers
347
+ const sessions = composerScope && composerScope.sessions
348
+ if (!inputTriggers || !sessions) return false
349
+ let actx
350
+ try { actx = sessions.scope(sessionId) } catch { return false }
351
+ if (actx === undefined || actx === null) return false
352
+ let controller
353
+ try { controller = inputTriggers.sessionOf(actx) } catch { return false }
354
+ const draft = (input && input.draft) || ''
355
+ const at = draft.length
356
+ controller.toggleSource(sourceName, {
357
+ trigger: '/',
358
+ query: '',
359
+ quoted: false,
360
+ position: draft.trim() === '' ? 'leading' : 'inline',
361
+ span: { start: at, end: at, draftRev: (input && input.draftRev) || 0 },
362
+ })
363
+ return true
364
+ }
365
+
366
+ // ── Small components ─────────────────────────────────────────────────────
367
+
368
+ function Avatar({ expert, size }) {
369
+ const [failed, setFailed] = useState(false)
370
+ const box = size ?? 44
371
+ if (failed || !expert.hasAvatar) {
372
+ return h('div', { className: 'exp-avatar-fallback', style: { width: box, height: box } },
373
+ expert.expertType === 'team' ? '👥' : '🧑‍💼')
374
+ }
375
+ return h('img', {
376
+ className: 'exp-avatar',
377
+ src: avatarUrl(expert.name, expert.source),
378
+ style: { width: box, height: box },
379
+ onError: () => setFailed(true),
380
+ alt: expert.displayName || expert.name,
381
+ })
382
+ }
383
+
384
+ function Badge({ kind, children }) {
385
+ return h('span', { className: 'exp-badge', 'data-kind': kind }, children)
386
+ }
387
+
388
+ function ExpertCard({ row, t, onOpen, onInstall, onDelete, busy }) {
389
+ const installedHere = row.source === 'dsh'
390
+ return h('div', { className: 'exp-card', onClick: () => onOpen(row) },
391
+ h('div', { className: 'exp-card-head' },
392
+ h(Avatar, { expert: row }),
393
+ h('div', { style: { minWidth: 0 } },
394
+ h('div', { className: 'exp-name' }, row.displayName || row.name),
395
+ h('div', { className: 'exp-profession' }, row.profession || '')),
396
+ h('div', { className: 'exp-badges' },
397
+ Badge({ kind: 'type', children: row.expertType === 'team' ? t('expertTypeTeam') : t('expertTypeAgent') }),
398
+ row.installed ? Badge({ kind: 'installed', children: t('installedTag') }) : null)),
399
+ h('div', { className: 'exp-desc' }, row.description || t('noDescription')),
400
+ (row.tags || []).length > 0
401
+ ? h('div', { className: 'exp-tags' }, row.tags.slice(0, 4).map((tag, i) => h('span', { className: 'exp-tag', key: i }, tag)))
402
+ : null,
403
+ h('div', { className: 'exp-card-actions', onClick: (e) => e.stopPropagation() },
404
+ installedHere
405
+ ? h('button', {
406
+ className: 'exp-btn', 'data-danger': 'true', disabled: busy,
407
+ onClick: () => { if (window.confirm(t('deleteConfirm'))) onDelete(row) },
408
+ }, busy ? t('removing') : t('remove'))
409
+ : h('button', {
410
+ className: 'exp-btn', 'data-primary': 'true', disabled: busy,
411
+ onClick: () => onInstall(row),
412
+ }, busy ? t('installing') : t('install'))))
413
+ }
414
+
415
+ function PagedGrid({ items, render, pageSize = 120, grow = 240 }) {
416
+ const [shown, setShown] = useState(pageSize)
417
+ const sentinelRef = useRef(null)
418
+ useEffect(() => { setShown(pageSize) }, [items])
419
+ useEffect(() => {
420
+ const node = sentinelRef.current
421
+ if (!node || typeof IntersectionObserver === 'undefined') return
422
+ const io = new IntersectionObserver((entries) => {
423
+ if (entries.some((en) => en.isIntersecting)) setShown((n) => Math.min(n + grow, items.length))
424
+ })
425
+ io.observe(node)
426
+ return () => io.disconnect()
427
+ }, [items.length, shown < items.length])
428
+ return h('div', { className: 'exp-grid' },
429
+ items.slice(0, shown).map(render),
430
+ shown < items.length ? h('div', { ref: sentinelRef, style: { gridColumn: '1 / -1', textAlign: 'center', color: 'var(--dsw-alias-label-tertiary)', padding: 8 } }, '…') : null)
431
+ }
432
+
433
+ function MemberAvatar({ expertName, source, member, t }) {
434
+ const [failed, setFailed] = useState(false)
435
+ return member.avatar && !failed
436
+ ? h('img', { className: 'exp-member-avatar', src: avatarUrl(expertName, source, member.id), onError: () => setFailed(true), alt: member.nameZh || member.id })
437
+ : h('div', { className: 'exp-member-avatar', title: t('avatarLoadFailed') }, '👤')
438
+ }
439
+
440
+ function DetailModal({ name, source, t, onClose, onInstalled, onDeleted }) {
441
+ const [detail, setDetail] = useState(null)
442
+ const [error, setError] = useState('')
443
+ const [busy, setBusy] = useState(false)
444
+ const [agentMd, setAgentMd] = useState(null)
445
+ useEffect(() => {
446
+ let live = true
447
+ fetchJson(`${API}/detail?name=${encodeURIComponent(name)}${source ? `&source=${encodeURIComponent(source)}` : ''}`)
448
+ .then((d) => { if (live) setDetail(d) })
449
+ .catch((e) => { if (live) setError(String(e && e.message)) })
450
+ return () => { live = false }
451
+ }, [name, source])
452
+ const loadAgentMd = () => {
453
+ fetchJson(`${API}/agent-md?name=${encodeURIComponent(name)}${source ? `&source=${encodeURIComponent(source)}` : ''}`)
454
+ .then((r) => setAgentMd(r.content))
455
+ .catch((e) => setAgentMd(`[${e && e.message}]`))
456
+ }
457
+ const install = async (overwrite) => {
458
+ setBusy(true)
459
+ try {
460
+ await fetchJson(`${API}/install`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, source, overwrite }) })
461
+ onInstalled()
462
+ } catch (e) { setError(String(e && e.message)) }
463
+ setBusy(false)
464
+ }
465
+ const remove = async () => {
466
+ setBusy(true)
467
+ try {
468
+ await fetchJson(API, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) })
469
+ onDeleted()
470
+ } catch (e) { setError(String(e && e.message)) }
471
+ setBusy(false)
472
+ }
473
+ const kv = (label, value) => value ? h('div', { className: 'exp-kv' }, h('b', null, label), h('span', null, value)) : null
474
+ return h('div', { className: 'exp-modal-backdrop', onClick: (e) => { if (e.target === e.currentTarget) onClose() } },
475
+ h('div', { className: 'exp-modal' },
476
+ h('div', { className: 'exp-modal-head' },
477
+ detail ? h(Avatar, { expert: detail, size: 56 }) : null,
478
+ h('div', null,
479
+ h('div', { className: 'exp-name', style: { fontSize: 17 } }, detail ? (detail.displayNameZh || detail.name) : name),
480
+ detail ? h('div', { className: 'exp-profession' }, detail.professionZh || detail.professionEn || '') : null),
481
+ h('button', { className: 'exp-btn exp-modal-close', onClick: onClose }, t('close'))),
482
+ error !== '' ? h('div', { className: 'exp-empty' }, `${t('loadFailed')}: ${error}`) : null,
483
+ detail === null && error === '' ? h('div', { className: 'exp-empty' }, '…') : null,
484
+ detail !== null ? h(h.Fragment, null,
485
+ h('div', { className: 'exp-form-row' },
486
+ (detail.descZh || detail.descEn) ? h('div', { className: 'exp-desc', style: { WebkitLineClamp: 'unset' } }, detail.descZh || detail.descEn) : null),
487
+ h('div', { className: 'exp-status-line' },
488
+ kv(t('sourceLabel'), `${detail.sourceLabel} (${detail.source})`),
489
+ kv(t('versionLabel'), detail.version),
490
+ kv(t('filesLabel'), `${detail.fileCount} / ${formatSize(detail.totalSize)}`),
491
+ kv(t('dirLabel'), detail.dir)),
492
+ detail.readOnly ? h('div', { className: 'exp-kv' }, h('span', { style: { color: 'var(--dsw-alias-label-tertiary)' } }, t('sourceReadonly'))) : null,
493
+ (detail.quickPromptsZh || []).length > 0 ? h('div', { className: 'exp-section' },
494
+ h('div', { className: 'exp-section-title' }, t('quickPrompts')),
495
+ ...detail.quickPromptsZh.slice(0, 5).map((q, i) => h('div', { className: 'exp-kv', key: i }, '• ', q))) : null,
496
+ (detail.members || []).length > 0 ? h('div', { className: 'exp-section' },
497
+ h('div', { className: 'exp-section-title' }, t('members')),
498
+ h('div', { className: 'exp-member-grid' },
499
+ ...detail.members.map((m) => h('div', { className: 'exp-member', key: m.id },
500
+ h(MemberAvatar, { expertName: detail.name, source: detail.source, member: m, t }),
501
+ h('div', null,
502
+ h('div', { style: { fontSize: 13 } }, m.nameZh || m.nameEn || m.id),
503
+ h('div', { className: 'exp-profession' }, `${m.professionZh || m.professionEn || ''} · ${m.role === 'lead' ? t('lead') : t('member')}`)))))) : null,
504
+ (detail.skillMeta || []).length > 0 ? h('div', { className: 'exp-section' },
505
+ h('div', { className: 'exp-section-title' }, t('skills')),
506
+ ...detail.skillMeta.map((s) => h('div', { className: 'exp-skill-row', key: s.skillName },
507
+ h('div', { style: { fontSize: 13 } }, `${s.emoji ? s.emoji + ' ' : ''}${s.skillName}`),
508
+ h('div', { className: 'exp-profession' }, s.descriptionZh || s.descriptionEn || s.description || '')))) : null,
509
+ (detail.agentFiles || []).length > 0 ? h('div', { className: 'exp-section' },
510
+ h('div', { className: 'exp-section-title' }, t('agents')),
511
+ ...detail.agentFiles.map((a) => h('div', { className: 'exp-skill-row', key: a.relPath },
512
+ h('div', { style: { fontSize: 13 } }, `${a.emoji ? a.emoji + ' ' : ''}${a.name}${a.name === detail.leadAgentFile ? ' ★' : ''}`),
513
+ h('div', { className: 'exp-profession' }, a.description || '')))) : null,
514
+ h('div', { className: 'exp-form-row' },
515
+ h('button', { className: 'exp-btn', onClick: () => (agentMd === null ? loadAgentMd() : setAgentMd(null)) }, agentMd === null ? t('viewAgentMd') : t('hideAgentMd')),
516
+ detail.source !== 'dsh'
517
+ ? h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: busy, onClick: () => install(false) }, busy ? t('installing') : t('install'))
518
+ : h('button', { className: 'exp-btn', 'data-danger': 'true', disabled: busy, onClick: () => { if (window.confirm(t('deleteConfirm'))) remove() } }, busy ? t('removing') : t('remove')),
519
+ detail.source !== 'dsh' && detail.installed
520
+ ? h('button', { className: 'exp-btn', disabled: busy, onClick: () => install(true) }, t('overwrite'))
521
+ : null),
522
+ agentMd !== null ? h('pre', { className: 'exp-pre' }, agentMd) : null,
523
+ detail.plugin ? h('details', null,
524
+ h('summary', { style: { cursor: 'pointer', fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } }, t('pluginJson')),
525
+ h('pre', { className: 'exp-pre' }, JSON.stringify(detail.plugin, null, 2))) : null,
526
+ ) : null))
527
+ }
528
+
529
+ function MarketSettingsCard({ t, onToast, onSynced }) {
530
+ const [status, setStatus] = useState(null)
531
+ const [form, setForm] = useState(null)
532
+ const [busy, setBusy] = useState(false)
533
+ 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 }) })
534
+ useEffect(() => { load() }, [])
535
+ const sync = async () => {
536
+ setBusy(true)
537
+ try {
538
+ const r = await fetchJson(`${API}/market/sync`, { method: 'POST' })
539
+ onToast(r.isFirstClone ? t('firstCloneDone') : r.hasUpdates ? t('syncDoneUpdated') : t('syncDoneLatest'))
540
+ await load()
541
+ onSynced()
542
+ } catch (e) { onToast(String(e && e.message)) }
543
+ setBusy(false)
544
+ }
545
+ const save = async () => {
546
+ setBusy(true)
547
+ try {
548
+ const patch = { url: form.url, branch: form.branch, autoSync: !!form.autoSync, syncOnStartup: !!form.syncOnStartup }
549
+ const dirText = (form.repoDir || '').trim()
550
+ if (dirText !== '' && dirText !== (status && status.dir)) patch.repoDir = dirText
551
+ if (typeof form.token === 'string' && form.token !== '') patch.token = form.token
552
+ if (form.token === null) patch.token = null
553
+ await fetchJson(`${API}/market/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
554
+ setForm((f) => ({ ...f, token: '' }))
555
+ onToast(t('saved'))
556
+ await load()
557
+ } catch (e) { onToast(String(e && e.message)) }
558
+ setBusy(false)
559
+ }
560
+ const field = (label, value, onChange, type) => h('label', null, label,
561
+ h('input', { className: 'exp-input', type: type || 'text', value: value ?? '', onChange: (e) => onChange(e.target.value) }))
562
+ if (status === null) return null
563
+ return h('div', { className: 'exp-settings' },
564
+ h('div', { className: 'exp-status-line' },
565
+ h('b', null, t('marketSettings')),
566
+ h('span', null, `${t('repoDirLabel')}: ${status.dir}`),
567
+ h('span', null, `${t('lastSyncLabel')}: ${status.lastSyncAt ? formatTime(status.lastSyncAt) : t('never')}`),
568
+ status.localCommit ? h('span', null, `${t('localCommitLabel')}: ${String(status.localCommit).slice(0, 8)}`) : null,
569
+ status.remoteCommit ? h('span', null, `${t('remoteCommitLabel')}: ${String(status.remoteCommit).slice(0, 8)}`, status.needsUpdate ? Badge({ kind: 'type', children: t('needsUpdateTag') }) : null) : null,
570
+ !status.gitAvailable ? h('span', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('gitMissing')) : null,
571
+ status.sparsePaths ? h('span', null, `sparse: ${(status.sparsePaths || []).join(', ')}`) : null),
572
+ h('div', { className: 'exp-form-row' },
573
+ field(t('repoUrlLabel'), form.url, (v) => setForm({ ...form, url: v })),
574
+ field(t('branchLabel'), form.branch, (v) => setForm({ ...form, branch: v }))),
575
+ h('div', { className: 'exp-form-row' },
576
+ field(t('repoDirLabel'), form.repoDir, (v) => setForm({ ...form, repoDir: v })),
577
+ field(`${t('tokenLabel')}${status.hasToken ? ` (${t('tokenConfigured')})` : ''}`, form.token ?? '', (v) => setForm({ ...form, token: v }), 'password')),
578
+ h('div', { className: 'exp-form-row' },
579
+ h('label', { className: 'exp-checkline' },
580
+ h('input', { type: 'checkbox', checked: !!form.autoSync, onChange: (e) => setForm({ ...form, autoSync: e.target.checked }) }), t('autoSyncLabel')),
581
+ h('label', { className: 'exp-checkline' },
582
+ h('input', { type: 'checkbox', checked: !!form.syncOnStartup, onChange: (e) => setForm({ ...form, syncOnStartup: e.target.checked }) }), t('syncOnStartupLabel')),
583
+ h('span', { style: { flex: 1 } }),
584
+ status.hasToken ? h('button', { className: 'exp-btn', disabled: busy, onClick: () => setForm({ ...form, token: null }) }, t('clearToken')) : null,
585
+ h('button', { className: 'exp-btn', disabled: busy || status.syncing, onClick: sync }, busy || status.syncing ? t('syncing') : t('syncNow')),
586
+ h(prim('Button'), { onClick: save, disabled: busy }, t('save'))))
587
+ }
588
+
589
+ // ── Page ─────────────────────────────────────────────────────────────────
590
+
591
+ function ExpertsPage({ t, embedded }) {
592
+ const [tab, setTab] = useState('market')
593
+ const [data, setData] = useState(null)
594
+ const [error, setError] = useState('')
595
+ const [search, setSearch] = useState('')
596
+ const [selected, setSelected] = useState(null) // {name, source}
597
+ const [busyName, setBusyName] = useState(null)
598
+ const [toast, setToast] = useState(null)
599
+ const showToast = (text) => { setToast(text); setTimeout(() => setToast(null), 2600) }
600
+ const reload = () => fetchJson(API).then((d) => { setData(d); setError('') }).catch((e) => setError(String(e && e.message)))
601
+ useEffect(() => { reload() }, [])
602
+ const rows = useMemo(() => {
603
+ if (!data) return []
604
+ const lower = search.trim().toLowerCase()
605
+ const list = tab === 'installed' ? data.installed : data.market
606
+ return list.filter((e) => matchExpert(e, lower))
607
+ }, [data, tab, search])
608
+ const install = async (row) => {
609
+ setBusyName(row.name)
610
+ try {
611
+ await fetchJson(`${API}/install`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: row.name, source: row.source }) })
612
+ showToast(t('installedDone'))
613
+ await reload()
614
+ } catch (e) {
615
+ const msg = String(e && e.message)
616
+ if (msg.includes('already installed')) {
617
+ try {
618
+ await fetchJson(`${API}/install`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: row.name, source: row.source, overwrite: true }) })
619
+ showToast(t('installedDone'))
620
+ await reload()
621
+ } catch (e2) { showToast(String(e2 && e2.message)) }
622
+ } else showToast(msg)
623
+ }
624
+ setBusyName(null)
625
+ }
626
+ const remove = async (row) => {
627
+ setBusyName(row.name)
628
+ try {
629
+ await fetchJson(API, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: row.name }) })
630
+ showToast(t('removedDone'))
631
+ await reload()
632
+ } catch (e) { showToast(String(e && e.message)) }
633
+ setBusyName(null)
634
+ }
635
+ return h('div', { className: 'exp-page' },
636
+ h('div', { className: 'exp-toolbar' },
637
+ h('div', { className: 'exp-tabs' },
638
+ h('button', { className: 'exp-tab', 'data-on': tab === 'installed', onClick: () => setTab('installed') }, `${t('tabInstalled')}${data ? ` (${data.installed.length})` : ''}`),
639
+ h('button', { className: 'exp-tab', 'data-on': tab === 'market', onClick: () => setTab('market') }, `${t('tabMarket')}${data ? ` (${data.market.length})` : ''}`)),
640
+ h('input', { className: 'exp-input exp-search', placeholder: t('searchPlaceholder'), value: search, onChange: (e) => setSearch(e.target.value) }),
641
+ h('span', { className: 'exp-count' }, `${rows.length}`)),
642
+ tab === 'market' ? h(MarketSettingsCard, { t, onToast: showToast, onSynced: reload }) : null,
643
+ error !== '' ? h('div', { className: 'exp-empty' }, `${t('loadFailed')}: ${error}`) : null,
644
+ data !== null && rows.length === 0 ? h('div', { className: 'exp-empty' }, tab === 'installed' ? t('installedEmpty') : t('marketEmpty')) : null,
645
+ rows.length > 0
646
+ ? h(PagedGrid, {
647
+ items: rows,
648
+ render: (row) => h(ExpertCard, { key: `${row.source}/${row.name}`, row, t, busy: busyName === row.name, onOpen: (r) => setSelected({ name: r.name, source: r.source }), onInstall: install, onDelete: remove }),
649
+ })
650
+ : null,
651
+ selected !== null ? h(DetailModal, {
652
+ name: selected.name, source: selected.source, t, onClose: () => setSelected(null),
653
+ onInstalled: () => { setSelected(null); showToast(t('installedDone')); reload() },
654
+ onDeleted: () => { setSelected(null); showToast(t('removedDone')); reload() },
655
+ }) : null,
656
+ toast !== null ? h('div', { className: 'exp-toast' }, toast) : null)
657
+ }
658
+
659
+ // ── Plugin plane contract ────────────────────────────────────────────────
660
+
661
+ module.exports = {
662
+ name: CLIENT_NAME,
663
+ inject: ['slots', 'locale'],
664
+ __internals: {
665
+ NS, ZH, EN, matchExpert, formatSize, formatTime, avatarUrl,
666
+ EXPERT_SOURCE_NAME, makeExpertSource, openTriggerSource, fetchRoster,
667
+ },
668
+ /** Test/host helper: mount a standalone page into any container. */
669
+ __boot(container, opts = {}) {
670
+ ensureStyles()
671
+ let t = opts.t || ((key, vars) => {
672
+ let out = EN[key] ?? key
673
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
674
+ return out
675
+ })
676
+ const root = require('react-dom/client').createRoot(container)
677
+ root.render(h(ExpertsPage, { t, embedded: !!opts.embedded }))
678
+ return root
679
+ },
680
+ apply(ctx) {
681
+ let t = (key, vars) => {
682
+ let out = EN[key] ?? key
683
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
684
+ return out
685
+ }
686
+ try {
687
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
688
+ ctx.locale.register(NS, 'zh', ZH)
689
+ ctx.locale.register(NS, 'en', EN)
690
+ // 菜单组标题按源名走 slash.menu 命名空间;注册失败仅回退显示源名 'expert'
691
+ try {
692
+ ctx.locale.register('slash.menu', 'zh', { [EXPERT_SOURCE_NAME]: '专家' })
693
+ ctx.locale.register('slash.menu', 'en', { [EXPERT_SOURCE_NAME]: 'Expert' })
694
+ } catch {}
695
+ const bound = typeof ctx.locale.bind === 'function' ? ctx.locale.bind(NS) : null
696
+ if (bound) {
697
+ t = (key, vars) => {
698
+ let out = bound(key) || key
699
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
700
+ return out
701
+ }
702
+ }
703
+ }
704
+ } catch (e) { try { console.error('[experts-management] locale init:', e) } catch {} }
705
+ // Composer 集成走动态 inject(静态列服务会拖住插件激活;ui-commands 先例)。
706
+ // 服务缺席(未组合 ui-input-trigger)时按钮隐藏、触发源不注册,管理页不受影响。
707
+ let composerScope = null
708
+ try {
709
+ if (typeof ctx.inject === 'function') {
710
+ ctx.inject(['inputTriggers', 'sessions'], (scope) => {
711
+ composerScope = scope
712
+ if (scope && scope.inputTriggers && typeof scope.inputTriggers.registerSource === 'function') {
713
+ ctx.effect(() => scope.inputTriggers.registerSource(makeExpertSource()), 'experts-management: expert trigger source')
714
+ }
715
+ })
716
+ }
717
+ } catch (e) { try { console.error('[experts-management] composer inject:', e) } catch {} }
718
+ ctx.effect(() => {
719
+ try {
720
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
721
+ name: 'sidebar.footer.action',
722
+ id: CLIENT_NAME,
723
+ order: 60,
724
+ locale: NS,
725
+ label: () => t('title'),
726
+ inject: () => ({ t }),
727
+ }, function FooterSlot(apiProps) {
728
+ return h(FooterSlotComponent, { __t: t, wide: apiProps && apiProps.wide })
729
+ }))
730
+ } catch (e) { (globalThis.__expErrors = globalThis.__expErrors || []).push('footer:' + (e && e.message)); throw e }
731
+ }, 'experts-management: sidebar footer action')
732
+ ctx.effect(() => {
733
+ try {
734
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
735
+ name: 'settings.section',
736
+ id: CLIENT_NAME,
737
+ order: 91,
738
+ locale: NS,
739
+ label: () => t('title'),
740
+ inject: () => ({}),
741
+ }, function SettingsSectionSlot() {
742
+ return h(SettingsSlotComponent, { __t: t })
743
+ }))
744
+ } catch (e) { (globalThis.__expErrors = globalThis.__expErrors || []).push('settings:' + (e && e.message)); throw e }
745
+ }, 'experts-management: settings section')
746
+ ctx.effect(() => {
747
+ try {
748
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
749
+ name: 'conversation.input.left',
750
+ id: CLIENT_NAME,
751
+ order: 62,
752
+ locale: NS,
753
+ label: () => t('pickExpert'),
754
+ inject: () => ({ t }),
755
+ }, function ExpertButtonSlot(apiProps) {
756
+ return h(ComposerButtonSlot, {
757
+ __t: t, icon: '🧑‍💼', label: t('pickExpert'), title: t('pickExpertTitle'),
758
+ source: EXPERT_SOURCE_NAME, composerScopeRef: () => composerScope,
759
+ sessionId: apiProps && apiProps.sessionId, input: apiProps && apiProps.input,
760
+ })
761
+ }))
762
+ } catch (e) { (globalThis.__expErrors = globalThis.__expErrors || []).push('input.left:' + (e && e.message)); throw e }
763
+ }, 'experts-management: input left button')
764
+ },
765
+ }
766
+
767
+ /** Footer slot entry: the button, and — when open — the whole experts page
768
+ * portaled to <body> as a fullscreen overlay (same pattern as the skills
769
+ * market footer entry). */
770
+ function FooterSlotComponent(props) {
771
+ const t = props.__t
772
+ const [open, setOpen] = useState(false)
773
+ useEffect(ensureStyles, [])
774
+ if (!open) {
775
+ return h('button', { className: 'exp-btn', onClick: () => setOpen(true), title: t('title') }, t('title'))
776
+ }
777
+ const page = h(ExpertsPage, { t, embedded: false, onClose: () => setOpen(false) })
778
+ if (RDP && typeof RDP.createPortal === 'function') return RDP.createPortal(page, document.body)
779
+ return page
780
+ }
781
+
782
+ /** Composer tool-row button: opens one registered '/' source over the
783
+ * session's trigger controller. Hidden while the inputTriggers/sessions
784
+ * services are absent (plugin composed without the trigger pipeline). */
785
+ function ComposerButtonSlot(props) {
786
+ useEffect(ensureStyles, [])
787
+ const composerScope = props.composerScopeRef ? props.composerScopeRef() : null
788
+ const ready = !!(composerScope && composerScope.inputTriggers && composerScope.sessions && props.sessionId)
789
+ if (!ready) return null
790
+ return h('button', {
791
+ className: 'exp-chip',
792
+ title: props.title || props.label,
793
+ onClick: () => { openTriggerSource(composerScope, props.sessionId, props.input, props.source) },
794
+ }, `${props.icon || ''}${props.icon ? ' ' : ''}${props.label}`)
795
+ }
796
+
797
+ /** Settings section slot entry: render the page directly in the host tree. */
798
+ function SettingsSlotComponent(props) {
799
+ useEffect(ensureStyles, [])
800
+ return h(ExpertsPage, { t: props.__t, embedded: true })
801
+ }