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