@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.
- package/README.md +51 -0
- package/client/bundle.js +815 -0
- package/client/index.js +801 -0
- package/cordis.patch.yml +13 -0
- package/package.json +63 -0
- package/src/index.js +949 -0
package/client/bundle.js
ADDED
|
@@ -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
|
+
})
|