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