@weibaohui/experts-management 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/bundle.js CHANGED
@@ -17,6 +17,11 @@ window.__ModuleLoader__.load({
17
17
  *
18
18
  * ActionShareDialog props:
19
19
  * title / hint / rows: [[label, value], ...] / initialPrompt
20
+ * params: [{ key, label?, placeholder?, multiline?, value? }] — 可选;模板参数
21
+ * 输入区(idle 态渲染在 prompt 上方),值实时替换进 prompt 的 {{key}} 占位符
22
+ * completedView: ({ job, output, close, retry }) => node — 可选;完成态插槽,
23
+ * 提供后 job done 不再渲染默认「输出原文」,改由插槽全权负责(如解析 AI 输出
24
+ * 成可编辑表单 + 创建按钮),Dialog footer 同时置空,操作按钮由插槽自承
20
25
  * run: async (prompt) => { jobId } — 发起执行
21
26
  * poll: async (jobId) => { status, output, code }
22
27
  * labels: { copy, copied, run, running, done, failed, outputLabel, openSession, close }
@@ -37,8 +42,10 @@ window.__ModuleLoader__.load({
37
42
  var h = React.createElement
38
43
  var useState = React.useState
39
44
  var useEffect = React.useEffect
45
+ var useRef = React.useRef
40
46
  var doFetch = options.fetch || (typeof fetch !== 'undefined' ? fetch : null)
41
47
  var inputStyle = { width: '100%', minHeight: 190, resize: 'vertical', fontFamily: 'var(--dsw-font-family)', lineHeight: 1.6, fontSize: 12, background: 'var(--dsw-alias-bg-layer-2,transparent)', color: 'inherit', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3))', borderRadius: '8px', padding: '10px', boxSizing: 'border-box' }
48
+ var paramStyle = { width: '100%', fontFamily: 'var(--dsw-font-family)', lineHeight: 1.5, fontSize: 13, background: 'var(--dsw-alias-bg-layer-2,transparent)', color: 'inherit', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3))', borderRadius: '8px', padding: '6px 10px', boxSizing: 'border-box' }
42
49
  var btnStyle = { background: 'transparent', color: 'inherit', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3))', borderRadius: '8px', padding: '5px 12px', fontSize: 13, cursor: 'pointer', font: 'inherit' }
43
50
  // 主按钮亮暗跟随:与 skills-management .sk-btn-primary 同款 token 组合
44
51
  var primaryStyle = Object.assign({}, btnStyle, { background: 'var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4a7dff))', borderColor: 'transparent', color: 'var(--dsw-alias-label-primary-inverted,#fff)' })
@@ -47,8 +54,23 @@ window.__ModuleLoader__.load({
47
54
  var title = props.title
48
55
  var hint = props.hint
49
56
  var labels = props.labels || {}
57
+ // 模板参数定义([{key,label,placeholder,multiline,value}])→ 值表
58
+ var paramDefs = Array.isArray(props.params) ? props.params : []
59
+ var initialParamValues = {}
60
+ for (var pi = 0; pi < paramDefs.length; pi++) {
61
+ var def = paramDefs[pi]
62
+ initialParamValues[def.key] = def.value !== undefined && def.value !== null ? String(def.value) : ''
63
+ }
64
+ var _pv = useState(initialParamValues)
65
+ var paramValues = _pv[0]; var setParamValues = _pv[1]
50
66
  var _p = useState(props.initialPrompt || '')
51
67
  var prompt = _p[0]; var setPrompt = _p[1]
68
+ // 「上次自动生成的 prompt」ref 镜像:effect 里比较当前 prompt 是否等于它,
69
+ // 判断用户是否手动编辑过——未手改则参数/模板变化可安全覆盖,手改过则保留
70
+ // 手动编辑(ntd ActionButton 的 lastGenerated 同款规则)。旧 dirty 单标记
71
+ // 无法表达「手改后又想让参数替换生效」的场景,且要同时服务 initialPrompt
72
+ // 异步到位的跟随行为,故统一收敛到这一处比较。
73
+ var lastGeneratedRef = useRef(null)
52
74
  var _j = useState(null)
53
75
  var job = _j[0]; var setJob = _j[1]
54
76
  var _b = useState(false)
@@ -57,13 +79,14 @@ window.__ModuleLoader__.load({
57
79
  var copied = _c[0]; var setCopied = _c[1]
58
80
  var _e = useState('')
59
81
  var error = _e[0]; var setError = _e[1]
60
- var _d = useState(false)
61
- var dirty = _d[0]; var setDirty = _d[1]
62
82
 
63
- // initialPrompt 异步到位(如宿主先要下发真实路径)时跟随刷新;用户编辑过则不打断
83
+ // 参数值/模板变化 → 重新生成 prompt;仅当用户未手改时覆盖
64
84
  useEffect(function () {
65
- if (!dirty) setPrompt(props.initialPrompt || '')
66
- }, [props.initialPrompt])
85
+ var generated = substituteParams(props.initialPrompt || '', paramValues)
86
+ var userEdited = lastGeneratedRef.current !== null && prompt !== lastGeneratedRef.current
87
+ lastGeneratedRef.current = generated
88
+ if (!userEdited) setPrompt(generated)
89
+ }, [props.initialPrompt, paramValues])
67
90
 
68
91
  useEffect(function () {
69
92
  if (job === null || job.status !== 'running' || typeof props.poll !== 'function') return
@@ -75,6 +98,15 @@ window.__ModuleLoader__.load({
75
98
  return function () { clearInterval(timer) }
76
99
  }, [job !== null && job.jobId])
77
100
 
101
+ var setParam = function (key, value) {
102
+ setParamValues(function (prev) {
103
+ var next = {}
104
+ for (var k in prev) next[k] = prev[k]
105
+ next[key] = value
106
+ return next
107
+ })
108
+ }
109
+
78
110
  var doRun = function () {
79
111
  if (typeof props.run !== 'function') return
80
112
  setBusy(true); setError('')
@@ -90,6 +122,8 @@ window.__ModuleLoader__.load({
90
122
  }
91
123
  }
92
124
  var statusText = job === null ? '' : job.status === 'running' ? (labels.running || 'running') : job.status === 'done' ? (labels.done || 'done') : (labels.failed || 'failed') + (job.code != null ? ' (' + job.code + ')' : '')
125
+ // 完成态插槽:提供后 job done 由插槽全权渲染(footer 置空,操作按钮插槽自承)
126
+ var completedSlot = typeof props.completedView === 'function' && job !== null && job.status === 'done'
93
127
 
94
128
  return h('div', { onClick: function (e) { if (e.target === e.currentTarget && props.onClose) props.onClose() }, style: { position: 'fixed', inset: 0, zIndex: 2147483000, background: 'rgba(0,0,0,.45)', display: 'flex', alignItems: 'center', justifyContent: 'center' } },
95
129
  h('div', { style: { width: 'min(640px,92vw)', maxHeight: '86vh', overflow: 'auto', background: 'var(--dsw-alias-bg-layer-1,#fff)', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3))', borderRadius: '16px', padding: '20px', display: 'flex', flexDirection: 'column', gap: 12, color: 'var(--dsw-alias-label-primary,inherit)', font: 'var(--dsw-font-family,inherit)' } },
@@ -97,16 +131,27 @@ window.__ModuleLoader__.load({
97
131
  h('div', { style: { fontSize: 17, fontWeight: 600 } }, title || ''),
98
132
  h('button', { onClick: props.onClose, style: Object.assign({}, btnStyle, { marginLeft: 'auto', width: 28, height: 28, padding: 0, borderRadius: 28 }) }, '✕')),
99
133
  hint ? h('div', { style: { fontSize: 12, opacity: .7 } }, hint) : null,
134
+ // 模板参数输入区(idle 态;值实时替换进 prompt,位于 prompt 上方与 ntd 同布局)
135
+ paramDefs.length > 0 ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
136
+ paramDefs.map(function (d) {
137
+ return h('label', { key: d.key, style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, opacity: .85 } },
138
+ h('span', null, d.label || d.key),
139
+ d.multiline
140
+ ? h('textarea', { value: paramValues[d.key] || '', placeholder: d.placeholder || '', onChange: function (e) { setParam(d.key, e.target.value) }, spellCheck: false, style: Object.assign({}, paramStyle, { minHeight: 64, resize: 'vertical' }) })
141
+ : h('input', { value: paramValues[d.key] || '', placeholder: d.placeholder || '', onChange: function (e) { setParam(d.key, e.target.value) }, style: paramStyle }))
142
+ })) : null,
100
143
  (props.rows || []).length > 0 ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 13 } },
101
144
  props.rows.map(function (r, i) {
102
145
  return r[1] ? h('div', { key: i }, h('b', null, r[0] + ':'), h('span', null, r[1])) : null
103
146
  })) : null,
104
- h('textarea', { value: prompt, onChange: function (e) { setDirty(true); setPrompt(e.target.value) }, spellCheck: false, style: inputStyle }),
147
+ h('textarea', { value: prompt, onChange: function (e) { setPrompt(e.target.value) }, spellCheck: false, style: inputStyle }),
105
148
  error !== '' ? h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error,#c75050)' } }, error) : null,
106
- job !== null ? h('div', null,
107
- h('div', { style: { fontSize: 12, opacity: .7, margin: '4px 0' } }, (labels.outputLabel || 'Output') + ' · ' + statusText),
108
- h('pre', { style: { maxHeight: 220, margin: 0, overflow: 'auto', whiteSpace: 'pre-wrap', fontSize: 12, background: 'var(--dsw-alias-bg-layer-2,transparent)', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.2))', borderRadius: '8px', padding: '8px' } }, job.output || '…')) : null,
109
- h('div', { style: { display: 'flex', gap: 8 } },
149
+ completedSlot
150
+ ? props.completedView({ job: job, output: job.output || '', close: props.onClose, retry: doRun })
151
+ : (job !== null ? h('div', null,
152
+ h('div', { style: { fontSize: 12, opacity: .7, margin: '4px 0' } }, (labels.outputLabel || 'Output') + ' · ' + statusText),
153
+ h('pre', { style: { maxHeight: 220, margin: 0, overflow: 'auto', whiteSpace: 'pre-wrap', fontSize: 12, background: 'var(--dsw-alias-bg-layer-2,transparent)', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.2))', borderRadius: '8px', padding: '8px' } }, job.output || '…')) : null),
154
+ completedSlot ? null : h('div', { style: { display: 'flex', gap: 8 } },
110
155
  canOpenSession ? h('button', { onClick: openSession, style: btnStyle }, labels.openSession || 'Open chat') : null,
111
156
  h('button', { onClick: copy, style: btnStyle }, copied ? (labels.copied || 'Copied') : (labels.copy || 'Copy')),
112
157
  h('button', { onClick: doRun, disabled: busy || (job !== null && job.status === 'running'), style: primaryStyle }, job !== null && job.status === 'running' ? (labels.running || 'Running…') : (labels.run || 'Run')))))
@@ -190,7 +235,7 @@ window.__ModuleLoader__.load({
190
235
  tabMine: '我的',
191
236
  tabBuiltin: '内置',
192
237
  searchPlaceholder: '搜索专家名称、职业、描述…',
193
- mineEmpty: '用户库还没有专家。去「内置」页浏览并安装。',
238
+ mineEmpty: '用户库还没有专家。去「内置」页浏览安装,或点上方按钮 AI 创建。',
194
239
  builtinEmpty: '内置专家为空。请在设置中同步内置仓库。',
195
240
  expertTypeAgent: '专家',
196
241
  expertTypeTeam: '团队',
@@ -280,6 +325,24 @@ window.__ModuleLoader__.load({
280
325
  pickerEmpty: '没有匹配的专家',
281
326
  pickerTabAgents: '专家',
282
327
  pickerTabTeams: '专家团',
328
+ createExpertBtn: 'AI 创建专家',
329
+ createTeamBtn: 'AI 创建专家团',
330
+ createTitleAgent: 'AI 创建专家',
331
+ createTitleTeam: 'AI 创建专家团',
332
+ createHint: '描述你想要的专家,AI 生成 plugin.json 与角色定义;确认或修改后写入用户库。',
333
+ createParamDescription: '专家描述',
334
+ createParamPlaceholder: '一句话描述,如:精通 Rust 的后端架构师',
335
+ createParseFailed: 'AI 输出不符合约定格式(未找到代码块),可修改描述重试',
336
+ createReady: 'AI 已生成专家定义,确认无误后点击创建',
337
+ createNow: '创建',
338
+ creating: '创建中…',
339
+ createdDone: '已创建专家 {name}',
340
+ createInvalidJson: 'plugin.json 格式无效',
341
+ createTypeMismatch: '生成的 expertType 与所选类型不符',
342
+ createLeadMissing: 'teamInfo.leadAgent 未对齐任何成员文件',
343
+ createEmptyFile: '角色定义内容不能为空',
344
+ retry: '重试',
345
+ teamAgentFiles: '成员角色定义',
283
346
  }
284
347
 
285
348
  const EN = {
@@ -288,7 +351,7 @@ window.__ModuleLoader__.load({
288
351
  tabMine: 'Mine',
289
352
  tabBuiltin: 'Built-in',
290
353
  searchPlaceholder: 'Search experts by name, profession, description…',
291
- mineEmpty: 'No experts in the user library yet. Browse the Built-in tab and install one.',
354
+ mineEmpty: 'No experts in the user library yet. Browse the Built-in tab and install one, or create one with the buttons above.',
292
355
  builtinEmpty: 'Built-in experts are empty. Sync the built-in repo in settings.',
293
356
  expertTypeAgent: 'Expert',
294
357
  expertTypeTeam: 'Team',
@@ -378,6 +441,24 @@ window.__ModuleLoader__.load({
378
441
  pickerEmpty: 'No matching experts',
379
442
  pickerTabAgents: 'Experts',
380
443
  pickerTabTeams: 'Teams',
444
+ createExpertBtn: 'AI Create Expert',
445
+ createTeamBtn: 'AI Create Expert Team',
446
+ createTitleAgent: 'AI Create Expert',
447
+ createTitleTeam: 'AI Create Expert Team',
448
+ createHint: 'Describe the expert you want; AI generates plugin.json and role definitions. Review or edit, then save into the user library.',
449
+ createParamDescription: 'Expert description',
450
+ createParamPlaceholder: 'One sentence, e.g. a Rust backend architect',
451
+ createParseFailed: 'AI output does not follow the agreed format (code blocks not found) — edit the description and retry',
452
+ createReady: 'AI generated the expert definition — review and click Create',
453
+ createNow: 'Create',
454
+ creating: 'Creating…',
455
+ createdDone: 'Expert {name} created',
456
+ createInvalidJson: 'Invalid plugin.json',
457
+ createTypeMismatch: 'Generated expertType does not match the selected kind',
458
+ createLeadMissing: 'teamInfo.leadAgent does not match any member file',
459
+ createEmptyFile: 'Role definition content must not be empty',
460
+ retry: 'Retry',
461
+ teamAgentFiles: 'Member role definitions',
381
462
  }
382
463
 
383
464
  // ── Styles ───────────────────────────────────────────────────────────────
@@ -827,6 +908,106 @@ window.__ModuleLoader__.load({
827
908
  '- 全程与最终汇报都使用中文。',
828
909
  ].join('\n')
829
910
 
911
+ // ── AI 创建专家(ntd ExpertCreateModal 同款:一句话描述 → 围栏输出 → 预览确认 → 落盘)──
912
+
913
+ /** 创建型模板共用的输出纪律:除约定围栏外禁止出现任何代码围栏——
914
+ * 执行器输出是流式全文(含中间叙述与 [tool] 行),前端按围栏解析,多余围栏会污染提取。 */
915
+ const EXPERT_CREATE_OUTPUT_RULES = [
916
+ '## 输出格式(严格遵守)',
917
+ '- 只输出约定的代码块,代码块之外不要输出任何解释、标题或结论。',
918
+ '- 正文中不要出现任何其他代码围栏(```)。',
919
+ '- 所有展示文案(displayName/profession/displayDescription/tags/提示词)中英双语,正文内容用中文。',
920
+ '- 不要输出 avatar 字段(头像暂不支持,缺省即用默认图标)。',
921
+ ].join('\n')
922
+
923
+ const EXPERT_CREATE_CATEGORY_LIST = [
924
+ '01-ProductDesign(产品设计)', '02-Engineering(工程技术)', '03-GameSpatial(游戏与空间)',
925
+ '04-DataAI(数据与 AI)', '06-ContentCreative(内容与创意)', '08-FinanceInvestment(金融投资)',
926
+ '10-ProjectQuality(项目质量)', '11-SecurityCompliance(安全合规)', '12-IndustryConsultant(行业咨询)',
927
+ ].join('、')
928
+
929
+ const EXPERT_CREATE_PROMPT_AGENT = [
930
+ '你是专家系统设计师。根据用户的描述,生成一个完整的专家定义(ntd/WorkBuddy 格式:plugin.json + agent.md)。',
931
+ '',
932
+ '用户描述:{{description}}',
933
+ '',
934
+ '第一步:用 ```json 围栏输出完整 plugin.json,字段要求:',
935
+ '- name: 专家 ID,只允许小写字母/数字/连字符(如 rust-backend-architect),与描述语义相符',
936
+ '- version: "1.0.0";expertType: "agent"',
937
+ '- description: 一句话英文简介',
938
+ '- displayName / profession / displayDescription: {zh, en} 双语对象',
939
+ `- categoryId: 从这些值里选一个:${EXPERT_CREATE_CATEGORY_LIST}`,
940
+ '- tags: [{zh, en}],至少 3 个',
941
+ '- agentName: 与 name 相同;agents: ["./agents/<name>.md"]',
942
+ '- defaultInitPrompt: {zh, en};quickPrompts: [{zh, en}] 至少 2 条',
943
+ '',
944
+ '第二步:用 ```markdown 围栏输出完整 agent.md:',
945
+ '- 开头 YAML frontmatter:name(=plugin.json 的 agentName)、description、color(英文颜色词)、emoji、vibe',
946
+ '- 正文中文撰写,包含:身份与记忆、核心使命、专业技能、工作流程、约束规则等章节,内容专业、具体、可执行',
947
+ '',
948
+ EXPERT_CREATE_OUTPUT_RULES,
949
+ ].join('\n')
950
+
951
+ const EXPERT_CREATE_PROMPT_TEAM = [
952
+ '你是专家团队设计师。根据用户的描述,生成一个完整的专家团队定义(ntd/WorkBuddy 格式,expertType=team:一名负责人 + 若干成员,各带角色定义文件)。',
953
+ '',
954
+ '用户描述:{{description}}',
955
+ '',
956
+ '第一步:用 ```json 围栏输出完整 plugin.json,字段要求:',
957
+ '- name: 团队 ID,只允许小写字母/数字/连字符(如 fullstack-delivery-team)',
958
+ '- version: "1.0.0";expertType: "team"',
959
+ '- description: 一句话英文简介',
960
+ '- displayName / profession / displayDescription: 团队级 {zh, en} 双语对象',
961
+ `- categoryId: 从这些值里选一个:${EXPERT_CREATE_CATEGORY_LIST}`,
962
+ '- tags: [{zh, en}],至少 3 个',
963
+ '- 成员规模 3–6 人(含负责人),按描述合理分工,成员 id 全部为小写字母/数字/连字符',
964
+ '- agentName: 负责人成员 id',
965
+ '- teamInfo: { "leadAgent": "<负责人id>", "memberAgents": ["<成员id>", ...] }(不含负责人)',
966
+ '- agents: ["./agents/<id>.md", ...],负责人的文件必须排在第一个',
967
+ '- members: [{ "id", "name": {zh,en}, "displayName": {zh,en}, "profession": {zh,en}, "role": "lead"|"member" }],id 与 agents 文件一一对应',
968
+ '- defaultInitPrompt: {zh, en};quickPrompts: [{zh, en}] 至少 2 条',
969
+ '',
970
+ '第二步:逐成员输出角色定义——每个成员一个 ```markdown agents/<成员id>.md 围栏(围栏起始行必须带上该文件路径),负责人的块排第一:',
971
+ '- 每个文件开头 YAML frontmatter:name(=成员 id)、description、color(英文颜色词)、emoji、vibe',
972
+ '- 正文中文撰写:身份与记忆、核心使命、专业技能、工作流程、约束规则;写明在团队中的分工与交接关系(负责人负责任务拆分与汇总)',
973
+ '',
974
+ EXPERT_CREATE_OUTPUT_RULES,
975
+ ].join('\n')
976
+
977
+ /** 创建模板注册表:key = expertType,入口按钮只是预选条目的快捷方式。 */
978
+ const CREATE_TEMPLATES = {
979
+ agent: { key: 'agent', btnKey: 'createExpertBtn', titleKey: 'createTitleAgent', prompt: EXPERT_CREATE_PROMPT_AGENT },
980
+ team: { key: 'team', btnKey: 'createTeamBtn', titleKey: 'createTitleTeam', prompt: EXPERT_CREATE_PROMPT_TEAM },
981
+ }
982
+
983
+ /**
984
+ * 解析 AI 生成输出 → 创建载荷(纯函数,便于测试)。
985
+ * 执行器 output 是流式全文(含 [tool] 行与中间叙述),按围栏标签提取:
986
+ * agent 型取第一个 ```json + 第一个 ```markdown;team 型取 ```json +
987
+ * 全部带路径标注的 ```markdown agents/<id>.md(同名去重,按出现顺序)。
988
+ * 围栏缺失 → { ok:false, raw },UI 展示原文并允许重试。
989
+ */
990
+ function parseCreateResult(output, type) {
991
+ const text = String(output || '')
992
+ const jsonMatch = text.match(/```json[ \t]*\r?\n([\s\S]*?)```/)
993
+ if (!jsonMatch) return { ok: false, raw: text }
994
+ const pluginJson = jsonMatch[1].trim()
995
+ if (type === 'team') {
996
+ const files = []
997
+ const re = /```markdown[ \t]+([^\s`]+)[ \t]*\r?\n([\s\S]*?)```/g
998
+ let m
999
+ while ((m = re.exec(text)) !== null) {
1000
+ const file = m[1].trim()
1001
+ if (!files.some((f) => f.file === file)) files.push({ file, content: m[2].trim() })
1002
+ }
1003
+ if (files.length === 0) return { ok: false, raw: text }
1004
+ return { ok: true, pluginJson, files }
1005
+ }
1006
+ const mdMatch = text.match(/```markdown[ \t]*\r?\n([\s\S]*?)```/)
1007
+ if (!mdMatch) return { ok: false, raw: text }
1008
+ return { ok: true, pluginJson, agentMd: mdMatch[1].trim() }
1009
+ }
1010
+
830
1011
  function DetailModal({ name, source, t, onClose, onInstalled, onDeleted, onToast, onOpenSession }) {
831
1012
  const [detail, setDetail] = useState(null)
832
1013
  const [error, setError] = useState('')
@@ -1130,6 +1311,99 @@ window.__ModuleLoader__.load({
1130
1311
  h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: busy || status.syncing, onClick: sync }, busy || status.syncing ? t('syncing') : t('syncNow'))))))
1131
1312
  }
1132
1313
 
1314
+ // ── AI 创建专家:对话框(消费 kit 的 params + completedView 扩展)──────────
1315
+
1316
+ const CREATE_AREA_STYLE = { width: '100%', minHeight: '200px', fontFamily: 'ui-monospace,monospace', fontSize: '12px', lineHeight: 1.6, whiteSpace: 'pre-wrap', boxSizing: 'border-box', resize: 'vertical' }
1317
+
1318
+ /**
1319
+ * 创建完成态:解析执行器输出 → 可编辑预览(plugin.json + 角色定义)→ POST /api/create。
1320
+ * 解析失败展示原文 + 重试(ntd ExpertCreateCompleted 同款三态:可解析/不可解析/错误)。
1321
+ */
1322
+ function CreateCompleted({ t, type, ctx, onCreated }) {
1323
+ const parsed = useMemo(() => parseCreateResult(ctx.output, type), [ctx.output, type])
1324
+ const [pluginText, setPluginText] = useState(parsed.pluginJson || '')
1325
+ const [agentMd, setAgentMd] = useState(parsed.agentMd || '')
1326
+ const [files, setFiles] = useState(Array.isArray(parsed.files) ? parsed.files : [])
1327
+ const [creating, setCreating] = useState(false)
1328
+ const [error, setError] = useState('')
1329
+ if (!parsed.ok) {
1330
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
1331
+ h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, t('createParseFailed')),
1332
+ h('pre', { className: 'exp-pre', style: { maxHeight: 260 } }, parsed.raw),
1333
+ h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } },
1334
+ h('button', { className: 'exp-btn', onClick: ctx.close }, t('close')),
1335
+ h('button', { className: 'exp-btn', 'data-primary': 'true', onClick: ctx.retry }, t('retry'))))
1336
+ }
1337
+ let preview = null
1338
+ try { preview = JSON.parse(pluginText) } catch { preview = null }
1339
+ const leadFile = preview !== null && preview.teamInfo !== null && typeof preview.teamInfo === 'object' && typeof preview.teamInfo.leadAgent === 'string'
1340
+ ? `agents/${preview.teamInfo.leadAgent}.md` : ''
1341
+ const create = async () => {
1342
+ setError('')
1343
+ let plugin
1344
+ try { plugin = JSON.parse(pluginText) } catch { plugin = null }
1345
+ if (plugin === null || typeof plugin !== 'object' || Array.isArray(plugin)) { setError(t('createInvalidJson')); return }
1346
+ if (plugin.expertType !== type) { setError(t('createTypeMismatch')); return }
1347
+ const body = { pluginJson: pluginText }
1348
+ if (type === 'agent') {
1349
+ if (typeof agentMd !== 'string' || agentMd.trim() === '') { setError(t('createEmptyFile')); return }
1350
+ body.agentMd = agentMd
1351
+ } else {
1352
+ if (!Array.isArray(files) || files.length === 0) { setError(t('createEmptyFile')); return }
1353
+ for (const f of files) {
1354
+ if (typeof f.content !== 'string' || f.content.trim() === '') { setError(t('createEmptyFile')); return }
1355
+ }
1356
+ body.agents = files.map((f) => ({ file: f.file, content: f.content }))
1357
+ }
1358
+ setCreating(true)
1359
+ try {
1360
+ const r = await fetchJson(`${API}/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
1361
+ onCreated((r.created && r.created.name) || plugin.name)
1362
+ ctx.close()
1363
+ } catch (e) { setError(String(e && e.message)) } finally { setCreating(false) }
1364
+ }
1365
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
1366
+ h('div', { className: 'exp-checkline' }, '✅', t('createReady')),
1367
+ preview !== null ? h('div', { className: 'exp-kv' },
1368
+ h('b', null, preview.expertType === 'team' ? t('expertTypeTeam') : t('expertTypeAgent')),
1369
+ h('span', null, (preview.displayName && (preview.displayName.zh || preview.displayName.en)) || preview.name || '')) : null,
1370
+ h('div', null,
1371
+ h('div', { className: 'exp-section-title', style: { margin: '4px 0' } }, t('pluginJson')),
1372
+ h('textarea', { className: 'exp-input', value: pluginText, onChange: (e) => setPluginText(e.target.value), spellCheck: false, style: CREATE_AREA_STYLE })),
1373
+ type === 'agent'
1374
+ ? h('div', null,
1375
+ h('div', { className: 'exp-section-title', style: { margin: '4px 0' } }, t('agents')),
1376
+ h('textarea', { className: 'exp-input', value: agentMd, onChange: (e) => setAgentMd(e.target.value), spellCheck: false, style: CREATE_AREA_STYLE }))
1377
+ : h('div', null,
1378
+ h('div', { className: 'exp-section-title', style: { margin: '4px 0' } }, t('teamAgentFiles')),
1379
+ ...files.map((f, i) => h('div', { key: f.file, style: { marginBottom: 8 } },
1380
+ h('div', { className: 'exp-section-title', style: { margin: '4px 0' } }, `${f.file}${f.file !== '' && f.file === leadFile ? ' ★' : ''}`),
1381
+ h('textarea', { className: 'exp-input', value: f.content, onChange: (e) => setFiles(files.map((x, j) => (j === i ? { ...x, content: e.target.value } : x))), spellCheck: false, style: CREATE_AREA_STYLE })))),
1382
+ error !== '' ? h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, error) : null,
1383
+ h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 8, borderTop: '1px solid var(--dsw-alias-border-l2)' } },
1384
+ h('button', { className: 'exp-btn', onClick: ctx.close }, t('close')),
1385
+ h('button', { className: 'exp-btn', onClick: ctx.retry }, t('retry')),
1386
+ h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: creating, onClick: create }, creating ? t('creating') : t('createNow'))))
1387
+ }
1388
+
1389
+ /** 创建入口对话框:kit ActionShareDialog 的创建态用法(params 输入 + completedView 接管)。
1390
+ * 两个工具栏按钮各自预选模板条目(CREATE_TEMPLATES[type]),对话框管线与入口无关。 */
1391
+ function CreateExpertDialog({ t, type, onClose, onCreated, onOpenSession }) {
1392
+ const tpl = CREATE_TEMPLATES[type] || CREATE_TEMPLATES.agent
1393
+ return h(getShareDialogComponent(), {
1394
+ title: t(tpl.titleKey),
1395
+ hint: t('createHint'),
1396
+ params: [{ key: 'description', label: t('createParamDescription'), placeholder: t('createParamPlaceholder'), multiline: true }],
1397
+ initialPrompt: tpl.prompt,
1398
+ labels: { copy: t('copyPrompt'), copied: t('copied'), run: t('runBtn'), running: t('running'), done: t('runDone'), failed: t('runFailed'), outputLabel: t('outputLabel'), openSession: t('openChat') },
1399
+ run: (prompt) => fetchJson(`${API}/create/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt }) }),
1400
+ poll: (id) => fetchJson(`${API}/create/run?id=${encodeURIComponent(id)}`),
1401
+ completedView: (ctx) => h(CreateCompleted, { t, type, ctx, onCreated }),
1402
+ onOpenSession: (sessionId) => { onClose(); if (onOpenSession) onOpenSession(sessionId) },
1403
+ onClose,
1404
+ })
1405
+ }
1406
+
1133
1407
  // ── Page ─────────────────────────────────────────────────────────────────
1134
1408
 
1135
1409
  function ExpertsPage({ t, embedded, onClose }) {
@@ -1139,6 +1413,7 @@ window.__ModuleLoader__.load({
1139
1413
  const [search, setSearch] = useState('')
1140
1414
  const [selected, setSelected] = useState(null) // {name, source}
1141
1415
  const [settingsOpen, setSettingsOpen] = useState(false)
1416
+ const [createType, setCreateType] = useState(null) // null | 'agent' | 'team'
1142
1417
  const [busyName, setBusyName] = useState(null)
1143
1418
  const [toast, setToast] = useState(null)
1144
1419
  const showToast = (text) => { setToast(text); setTimeout(() => setToast(null), 2600) }
@@ -1188,6 +1463,8 @@ window.__ModuleLoader__.load({
1188
1463
  h('button', { className: 'exp-tab', 'data-on': tab === 'builtin', onClick: () => setTab('builtin') }, `${t('tabBuiltin')}${data ? ` (${data.builtin.length})` : ''}`)),
1189
1464
  h('input', { className: 'exp-input exp-search', placeholder: t('searchPlaceholder'), value: search, onChange: (e) => setSearch(e.target.value) }),
1190
1465
  h('span', { className: 'exp-count' }, `${rows.length}`),
1466
+ tab === 'mine' ? h('button', { className: 'exp-btn', 'data-primary': 'true', title: t('createHint'), onClick: () => setCreateType('agent') }, `⚡ ${t('createExpertBtn')}`) : null,
1467
+ tab === 'mine' ? h('button', { className: 'exp-btn', title: t('createHint'), onClick: () => setCreateType('team') }, `👥 ${t('createTeamBtn')}`) : null,
1191
1468
  tab === 'builtin' ? h('button', { className: 'exp-btn', title: t('builtinSettings'), onClick: () => setSettingsOpen(true) }, t('builtinSettings')) : null),
1192
1469
  error !== '' ? h('div', { className: 'exp-empty' }, `${t('loadFailed')}: ${error}`) : null,
1193
1470
  data !== null && rows.length === 0 ? h('div', { className: 'exp-empty' }, tab === 'mine' ? t('mineEmpty') : t('builtinEmpty')) : null,
@@ -1206,6 +1483,17 @@ window.__ModuleLoader__.load({
1206
1483
  settingsOpen ? h(BuiltinSettingsDialog, {
1207
1484
  t, onClose: () => setSettingsOpen(false), onToast: showToast, onSynced: reload,
1208
1485
  }) : null,
1486
+ createType !== null ? h(CreateExpertDialog, {
1487
+ t, type: createType,
1488
+ onClose: () => setCreateType(null),
1489
+ onCreated: (name) => {
1490
+ setCreateType(null)
1491
+ showToast(t('createdDone', { name }))
1492
+ reload()
1493
+ fetchRoster(true) // composer 候选立即见到新专家(60s TTL 缓存强制失效)
1494
+ },
1495
+ onOpenSession: (sessionId) => { try { sessionsApi.open(sessionId) } catch (e) { showToast(String(e && e.message)) } },
1496
+ }) : null,
1209
1497
  toast !== null ? h('div', { className: 'exp-toast' }, toast) : null)
1210
1498
  }
1211
1499
 
@@ -1218,6 +1506,7 @@ window.__ModuleLoader__.load({
1218
1506
  NS, ZH, EN, matchExpert, formatSize, formatTime, avatarUrl,
1219
1507
  EXPERT_SOURCE_NAME, makeExpertSource, openTriggerSource, fetchRoster,
1220
1508
  toRosterRows, insertComposerText, splitRosterByType, pickerRowMatch,
1509
+ parseCreateResult, CREATE_TEMPLATES, EXPERT_CREATE_PROMPT_AGENT, EXPERT_CREATE_PROMPT_TEAM,
1221
1510
  },
1222
1511
  /** Test/host helper: mount a standalone page into any container. */
1223
1512
  __boot(container, opts = {}) {
package/client/index.js CHANGED
@@ -72,7 +72,7 @@ const ZH = {
72
72
  tabMine: '我的',
73
73
  tabBuiltin: '内置',
74
74
  searchPlaceholder: '搜索专家名称、职业、描述…',
75
- mineEmpty: '用户库还没有专家。去「内置」页浏览并安装。',
75
+ mineEmpty: '用户库还没有专家。去「内置」页浏览安装,或点上方按钮 AI 创建。',
76
76
  builtinEmpty: '内置专家为空。请在设置中同步内置仓库。',
77
77
  expertTypeAgent: '专家',
78
78
  expertTypeTeam: '团队',
@@ -162,6 +162,24 @@ const ZH = {
162
162
  pickerEmpty: '没有匹配的专家',
163
163
  pickerTabAgents: '专家',
164
164
  pickerTabTeams: '专家团',
165
+ createExpertBtn: 'AI 创建专家',
166
+ createTeamBtn: 'AI 创建专家团',
167
+ createTitleAgent: 'AI 创建专家',
168
+ createTitleTeam: 'AI 创建专家团',
169
+ createHint: '描述你想要的专家,AI 生成 plugin.json 与角色定义;确认或修改后写入用户库。',
170
+ createParamDescription: '专家描述',
171
+ createParamPlaceholder: '一句话描述,如:精通 Rust 的后端架构师',
172
+ createParseFailed: 'AI 输出不符合约定格式(未找到代码块),可修改描述重试',
173
+ createReady: 'AI 已生成专家定义,确认无误后点击创建',
174
+ createNow: '创建',
175
+ creating: '创建中…',
176
+ createdDone: '已创建专家 {name}',
177
+ createInvalidJson: 'plugin.json 格式无效',
178
+ createTypeMismatch: '生成的 expertType 与所选类型不符',
179
+ createLeadMissing: 'teamInfo.leadAgent 未对齐任何成员文件',
180
+ createEmptyFile: '角色定义内容不能为空',
181
+ retry: '重试',
182
+ teamAgentFiles: '成员角色定义',
165
183
  }
166
184
 
167
185
  const EN = {
@@ -170,7 +188,7 @@ const EN = {
170
188
  tabMine: 'Mine',
171
189
  tabBuiltin: 'Built-in',
172
190
  searchPlaceholder: 'Search experts by name, profession, description…',
173
- mineEmpty: 'No experts in the user library yet. Browse the Built-in tab and install one.',
191
+ mineEmpty: 'No experts in the user library yet. Browse the Built-in tab and install one, or create one with the buttons above.',
174
192
  builtinEmpty: 'Built-in experts are empty. Sync the built-in repo in settings.',
175
193
  expertTypeAgent: 'Expert',
176
194
  expertTypeTeam: 'Team',
@@ -260,6 +278,24 @@ const EN = {
260
278
  pickerEmpty: 'No matching experts',
261
279
  pickerTabAgents: 'Experts',
262
280
  pickerTabTeams: 'Teams',
281
+ createExpertBtn: 'AI Create Expert',
282
+ createTeamBtn: 'AI Create Expert Team',
283
+ createTitleAgent: 'AI Create Expert',
284
+ createTitleTeam: 'AI Create Expert Team',
285
+ createHint: 'Describe the expert you want; AI generates plugin.json and role definitions. Review or edit, then save into the user library.',
286
+ createParamDescription: 'Expert description',
287
+ createParamPlaceholder: 'One sentence, e.g. a Rust backend architect',
288
+ createParseFailed: 'AI output does not follow the agreed format (code blocks not found) — edit the description and retry',
289
+ createReady: 'AI generated the expert definition — review and click Create',
290
+ createNow: 'Create',
291
+ creating: 'Creating…',
292
+ createdDone: 'Expert {name} created',
293
+ createInvalidJson: 'Invalid plugin.json',
294
+ createTypeMismatch: 'Generated expertType does not match the selected kind',
295
+ createLeadMissing: 'teamInfo.leadAgent does not match any member file',
296
+ createEmptyFile: 'Role definition content must not be empty',
297
+ retry: 'Retry',
298
+ teamAgentFiles: 'Member role definitions',
263
299
  }
264
300
 
265
301
  // ── Styles ───────────────────────────────────────────────────────────────
@@ -709,6 +745,106 @@ const EXPERT_SHARE_PROMPT = [
709
745
  '- 全程与最终汇报都使用中文。',
710
746
  ].join('\n')
711
747
 
748
+ // ── AI 创建专家(ntd ExpertCreateModal 同款:一句话描述 → 围栏输出 → 预览确认 → 落盘)──
749
+
750
+ /** 创建型模板共用的输出纪律:除约定围栏外禁止出现任何代码围栏——
751
+ * 执行器输出是流式全文(含中间叙述与 [tool] 行),前端按围栏解析,多余围栏会污染提取。 */
752
+ const EXPERT_CREATE_OUTPUT_RULES = [
753
+ '## 输出格式(严格遵守)',
754
+ '- 只输出约定的代码块,代码块之外不要输出任何解释、标题或结论。',
755
+ '- 正文中不要出现任何其他代码围栏(```)。',
756
+ '- 所有展示文案(displayName/profession/displayDescription/tags/提示词)中英双语,正文内容用中文。',
757
+ '- 不要输出 avatar 字段(头像暂不支持,缺省即用默认图标)。',
758
+ ].join('\n')
759
+
760
+ const EXPERT_CREATE_CATEGORY_LIST = [
761
+ '01-ProductDesign(产品设计)', '02-Engineering(工程技术)', '03-GameSpatial(游戏与空间)',
762
+ '04-DataAI(数据与 AI)', '06-ContentCreative(内容与创意)', '08-FinanceInvestment(金融投资)',
763
+ '10-ProjectQuality(项目质量)', '11-SecurityCompliance(安全合规)', '12-IndustryConsultant(行业咨询)',
764
+ ].join('、')
765
+
766
+ const EXPERT_CREATE_PROMPT_AGENT = [
767
+ '你是专家系统设计师。根据用户的描述,生成一个完整的专家定义(ntd/WorkBuddy 格式:plugin.json + agent.md)。',
768
+ '',
769
+ '用户描述:{{description}}',
770
+ '',
771
+ '第一步:用 ```json 围栏输出完整 plugin.json,字段要求:',
772
+ '- name: 专家 ID,只允许小写字母/数字/连字符(如 rust-backend-architect),与描述语义相符',
773
+ '- version: "1.0.0";expertType: "agent"',
774
+ '- description: 一句话英文简介',
775
+ '- displayName / profession / displayDescription: {zh, en} 双语对象',
776
+ `- categoryId: 从这些值里选一个:${EXPERT_CREATE_CATEGORY_LIST}`,
777
+ '- tags: [{zh, en}],至少 3 个',
778
+ '- agentName: 与 name 相同;agents: ["./agents/<name>.md"]',
779
+ '- defaultInitPrompt: {zh, en};quickPrompts: [{zh, en}] 至少 2 条',
780
+ '',
781
+ '第二步:用 ```markdown 围栏输出完整 agent.md:',
782
+ '- 开头 YAML frontmatter:name(=plugin.json 的 agentName)、description、color(英文颜色词)、emoji、vibe',
783
+ '- 正文中文撰写,包含:身份与记忆、核心使命、专业技能、工作流程、约束规则等章节,内容专业、具体、可执行',
784
+ '',
785
+ EXPERT_CREATE_OUTPUT_RULES,
786
+ ].join('\n')
787
+
788
+ const EXPERT_CREATE_PROMPT_TEAM = [
789
+ '你是专家团队设计师。根据用户的描述,生成一个完整的专家团队定义(ntd/WorkBuddy 格式,expertType=team:一名负责人 + 若干成员,各带角色定义文件)。',
790
+ '',
791
+ '用户描述:{{description}}',
792
+ '',
793
+ '第一步:用 ```json 围栏输出完整 plugin.json,字段要求:',
794
+ '- name: 团队 ID,只允许小写字母/数字/连字符(如 fullstack-delivery-team)',
795
+ '- version: "1.0.0";expertType: "team"',
796
+ '- description: 一句话英文简介',
797
+ '- displayName / profession / displayDescription: 团队级 {zh, en} 双语对象',
798
+ `- categoryId: 从这些值里选一个:${EXPERT_CREATE_CATEGORY_LIST}`,
799
+ '- tags: [{zh, en}],至少 3 个',
800
+ '- 成员规模 3–6 人(含负责人),按描述合理分工,成员 id 全部为小写字母/数字/连字符',
801
+ '- agentName: 负责人成员 id',
802
+ '- teamInfo: { "leadAgent": "<负责人id>", "memberAgents": ["<成员id>", ...] }(不含负责人)',
803
+ '- agents: ["./agents/<id>.md", ...],负责人的文件必须排在第一个',
804
+ '- members: [{ "id", "name": {zh,en}, "displayName": {zh,en}, "profession": {zh,en}, "role": "lead"|"member" }],id 与 agents 文件一一对应',
805
+ '- defaultInitPrompt: {zh, en};quickPrompts: [{zh, en}] 至少 2 条',
806
+ '',
807
+ '第二步:逐成员输出角色定义——每个成员一个 ```markdown agents/<成员id>.md 围栏(围栏起始行必须带上该文件路径),负责人的块排第一:',
808
+ '- 每个文件开头 YAML frontmatter:name(=成员 id)、description、color(英文颜色词)、emoji、vibe',
809
+ '- 正文中文撰写:身份与记忆、核心使命、专业技能、工作流程、约束规则;写明在团队中的分工与交接关系(负责人负责任务拆分与汇总)',
810
+ '',
811
+ EXPERT_CREATE_OUTPUT_RULES,
812
+ ].join('\n')
813
+
814
+ /** 创建模板注册表:key = expertType,入口按钮只是预选条目的快捷方式。 */
815
+ const CREATE_TEMPLATES = {
816
+ agent: { key: 'agent', btnKey: 'createExpertBtn', titleKey: 'createTitleAgent', prompt: EXPERT_CREATE_PROMPT_AGENT },
817
+ team: { key: 'team', btnKey: 'createTeamBtn', titleKey: 'createTitleTeam', prompt: EXPERT_CREATE_PROMPT_TEAM },
818
+ }
819
+
820
+ /**
821
+ * 解析 AI 生成输出 → 创建载荷(纯函数,便于测试)。
822
+ * 执行器 output 是流式全文(含 [tool] 行与中间叙述),按围栏标签提取:
823
+ * agent 型取第一个 ```json + 第一个 ```markdown;team 型取 ```json +
824
+ * 全部带路径标注的 ```markdown agents/<id>.md(同名去重,按出现顺序)。
825
+ * 围栏缺失 → { ok:false, raw },UI 展示原文并允许重试。
826
+ */
827
+ function parseCreateResult(output, type) {
828
+ const text = String(output || '')
829
+ const jsonMatch = text.match(/```json[ \t]*\r?\n([\s\S]*?)```/)
830
+ if (!jsonMatch) return { ok: false, raw: text }
831
+ const pluginJson = jsonMatch[1].trim()
832
+ if (type === 'team') {
833
+ const files = []
834
+ const re = /```markdown[ \t]+([^\s`]+)[ \t]*\r?\n([\s\S]*?)```/g
835
+ let m
836
+ while ((m = re.exec(text)) !== null) {
837
+ const file = m[1].trim()
838
+ if (!files.some((f) => f.file === file)) files.push({ file, content: m[2].trim() })
839
+ }
840
+ if (files.length === 0) return { ok: false, raw: text }
841
+ return { ok: true, pluginJson, files }
842
+ }
843
+ const mdMatch = text.match(/```markdown[ \t]*\r?\n([\s\S]*?)```/)
844
+ if (!mdMatch) return { ok: false, raw: text }
845
+ return { ok: true, pluginJson, agentMd: mdMatch[1].trim() }
846
+ }
847
+
712
848
  function DetailModal({ name, source, t, onClose, onInstalled, onDeleted, onToast, onOpenSession }) {
713
849
  const [detail, setDetail] = useState(null)
714
850
  const [error, setError] = useState('')
@@ -1012,6 +1148,99 @@ function BuiltinSettingsDialog({ t, onClose, onToast, onSynced }) {
1012
1148
  h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: busy || status.syncing, onClick: sync }, busy || status.syncing ? t('syncing') : t('syncNow'))))))
1013
1149
  }
1014
1150
 
1151
+ // ── AI 创建专家:对话框(消费 kit 的 params + completedView 扩展)──────────
1152
+
1153
+ const CREATE_AREA_STYLE = { width: '100%', minHeight: '200px', fontFamily: 'ui-monospace,monospace', fontSize: '12px', lineHeight: 1.6, whiteSpace: 'pre-wrap', boxSizing: 'border-box', resize: 'vertical' }
1154
+
1155
+ /**
1156
+ * 创建完成态:解析执行器输出 → 可编辑预览(plugin.json + 角色定义)→ POST /api/create。
1157
+ * 解析失败展示原文 + 重试(ntd ExpertCreateCompleted 同款三态:可解析/不可解析/错误)。
1158
+ */
1159
+ function CreateCompleted({ t, type, ctx, onCreated }) {
1160
+ const parsed = useMemo(() => parseCreateResult(ctx.output, type), [ctx.output, type])
1161
+ const [pluginText, setPluginText] = useState(parsed.pluginJson || '')
1162
+ const [agentMd, setAgentMd] = useState(parsed.agentMd || '')
1163
+ const [files, setFiles] = useState(Array.isArray(parsed.files) ? parsed.files : [])
1164
+ const [creating, setCreating] = useState(false)
1165
+ const [error, setError] = useState('')
1166
+ if (!parsed.ok) {
1167
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
1168
+ h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, t('createParseFailed')),
1169
+ h('pre', { className: 'exp-pre', style: { maxHeight: 260 } }, parsed.raw),
1170
+ h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } },
1171
+ h('button', { className: 'exp-btn', onClick: ctx.close }, t('close')),
1172
+ h('button', { className: 'exp-btn', 'data-primary': 'true', onClick: ctx.retry }, t('retry'))))
1173
+ }
1174
+ let preview = null
1175
+ try { preview = JSON.parse(pluginText) } catch { preview = null }
1176
+ const leadFile = preview !== null && preview.teamInfo !== null && typeof preview.teamInfo === 'object' && typeof preview.teamInfo.leadAgent === 'string'
1177
+ ? `agents/${preview.teamInfo.leadAgent}.md` : ''
1178
+ const create = async () => {
1179
+ setError('')
1180
+ let plugin
1181
+ try { plugin = JSON.parse(pluginText) } catch { plugin = null }
1182
+ if (plugin === null || typeof plugin !== 'object' || Array.isArray(plugin)) { setError(t('createInvalidJson')); return }
1183
+ if (plugin.expertType !== type) { setError(t('createTypeMismatch')); return }
1184
+ const body = { pluginJson: pluginText }
1185
+ if (type === 'agent') {
1186
+ if (typeof agentMd !== 'string' || agentMd.trim() === '') { setError(t('createEmptyFile')); return }
1187
+ body.agentMd = agentMd
1188
+ } else {
1189
+ if (!Array.isArray(files) || files.length === 0) { setError(t('createEmptyFile')); return }
1190
+ for (const f of files) {
1191
+ if (typeof f.content !== 'string' || f.content.trim() === '') { setError(t('createEmptyFile')); return }
1192
+ }
1193
+ body.agents = files.map((f) => ({ file: f.file, content: f.content }))
1194
+ }
1195
+ setCreating(true)
1196
+ try {
1197
+ const r = await fetchJson(`${API}/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
1198
+ onCreated((r.created && r.created.name) || plugin.name)
1199
+ ctx.close()
1200
+ } catch (e) { setError(String(e && e.message)) } finally { setCreating(false) }
1201
+ }
1202
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
1203
+ h('div', { className: 'exp-checkline' }, '✅', t('createReady')),
1204
+ preview !== null ? h('div', { className: 'exp-kv' },
1205
+ h('b', null, preview.expertType === 'team' ? t('expertTypeTeam') : t('expertTypeAgent')),
1206
+ h('span', null, (preview.displayName && (preview.displayName.zh || preview.displayName.en)) || preview.name || '')) : null,
1207
+ h('div', null,
1208
+ h('div', { className: 'exp-section-title', style: { margin: '4px 0' } }, t('pluginJson')),
1209
+ h('textarea', { className: 'exp-input', value: pluginText, onChange: (e) => setPluginText(e.target.value), spellCheck: false, style: CREATE_AREA_STYLE })),
1210
+ type === 'agent'
1211
+ ? h('div', null,
1212
+ h('div', { className: 'exp-section-title', style: { margin: '4px 0' } }, t('agents')),
1213
+ h('textarea', { className: 'exp-input', value: agentMd, onChange: (e) => setAgentMd(e.target.value), spellCheck: false, style: CREATE_AREA_STYLE }))
1214
+ : h('div', null,
1215
+ h('div', { className: 'exp-section-title', style: { margin: '4px 0' } }, t('teamAgentFiles')),
1216
+ ...files.map((f, i) => h('div', { key: f.file, style: { marginBottom: 8 } },
1217
+ h('div', { className: 'exp-section-title', style: { margin: '4px 0' } }, `${f.file}${f.file !== '' && f.file === leadFile ? ' ★' : ''}`),
1218
+ h('textarea', { className: 'exp-input', value: f.content, onChange: (e) => setFiles(files.map((x, j) => (j === i ? { ...x, content: e.target.value } : x))), spellCheck: false, style: CREATE_AREA_STYLE })))),
1219
+ error !== '' ? h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, error) : null,
1220
+ h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 8, borderTop: '1px solid var(--dsw-alias-border-l2)' } },
1221
+ h('button', { className: 'exp-btn', onClick: ctx.close }, t('close')),
1222
+ h('button', { className: 'exp-btn', onClick: ctx.retry }, t('retry')),
1223
+ h('button', { className: 'exp-btn', 'data-primary': 'true', disabled: creating, onClick: create }, creating ? t('creating') : t('createNow'))))
1224
+ }
1225
+
1226
+ /** 创建入口对话框:kit ActionShareDialog 的创建态用法(params 输入 + completedView 接管)。
1227
+ * 两个工具栏按钮各自预选模板条目(CREATE_TEMPLATES[type]),对话框管线与入口无关。 */
1228
+ function CreateExpertDialog({ t, type, onClose, onCreated, onOpenSession }) {
1229
+ const tpl = CREATE_TEMPLATES[type] || CREATE_TEMPLATES.agent
1230
+ return h(getShareDialogComponent(), {
1231
+ title: t(tpl.titleKey),
1232
+ hint: t('createHint'),
1233
+ params: [{ key: 'description', label: t('createParamDescription'), placeholder: t('createParamPlaceholder'), multiline: true }],
1234
+ initialPrompt: tpl.prompt,
1235
+ labels: { copy: t('copyPrompt'), copied: t('copied'), run: t('runBtn'), running: t('running'), done: t('runDone'), failed: t('runFailed'), outputLabel: t('outputLabel'), openSession: t('openChat') },
1236
+ run: (prompt) => fetchJson(`${API}/create/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt }) }),
1237
+ poll: (id) => fetchJson(`${API}/create/run?id=${encodeURIComponent(id)}`),
1238
+ completedView: (ctx) => h(CreateCompleted, { t, type, ctx, onCreated }),
1239
+ onOpenSession: (sessionId) => { onClose(); if (onOpenSession) onOpenSession(sessionId) },
1240
+ onClose,
1241
+ })
1242
+ }
1243
+
1015
1244
  // ── Page ─────────────────────────────────────────────────────────────────
1016
1245
 
1017
1246
  function ExpertsPage({ t, embedded, onClose }) {
@@ -1021,6 +1250,7 @@ function ExpertsPage({ t, embedded, onClose }) {
1021
1250
  const [search, setSearch] = useState('')
1022
1251
  const [selected, setSelected] = useState(null) // {name, source}
1023
1252
  const [settingsOpen, setSettingsOpen] = useState(false)
1253
+ const [createType, setCreateType] = useState(null) // null | 'agent' | 'team'
1024
1254
  const [busyName, setBusyName] = useState(null)
1025
1255
  const [toast, setToast] = useState(null)
1026
1256
  const showToast = (text) => { setToast(text); setTimeout(() => setToast(null), 2600) }
@@ -1070,6 +1300,8 @@ function ExpertsPage({ t, embedded, onClose }) {
1070
1300
  h('button', { className: 'exp-tab', 'data-on': tab === 'builtin', onClick: () => setTab('builtin') }, `${t('tabBuiltin')}${data ? ` (${data.builtin.length})` : ''}`)),
1071
1301
  h('input', { className: 'exp-input exp-search', placeholder: t('searchPlaceholder'), value: search, onChange: (e) => setSearch(e.target.value) }),
1072
1302
  h('span', { className: 'exp-count' }, `${rows.length}`),
1303
+ tab === 'mine' ? h('button', { className: 'exp-btn', 'data-primary': 'true', title: t('createHint'), onClick: () => setCreateType('agent') }, `⚡ ${t('createExpertBtn')}`) : null,
1304
+ tab === 'mine' ? h('button', { className: 'exp-btn', title: t('createHint'), onClick: () => setCreateType('team') }, `👥 ${t('createTeamBtn')}`) : null,
1073
1305
  tab === 'builtin' ? h('button', { className: 'exp-btn', title: t('builtinSettings'), onClick: () => setSettingsOpen(true) }, t('builtinSettings')) : null),
1074
1306
  error !== '' ? h('div', { className: 'exp-empty' }, `${t('loadFailed')}: ${error}`) : null,
1075
1307
  data !== null && rows.length === 0 ? h('div', { className: 'exp-empty' }, tab === 'mine' ? t('mineEmpty') : t('builtinEmpty')) : null,
@@ -1088,6 +1320,17 @@ function ExpertsPage({ t, embedded, onClose }) {
1088
1320
  settingsOpen ? h(BuiltinSettingsDialog, {
1089
1321
  t, onClose: () => setSettingsOpen(false), onToast: showToast, onSynced: reload,
1090
1322
  }) : null,
1323
+ createType !== null ? h(CreateExpertDialog, {
1324
+ t, type: createType,
1325
+ onClose: () => setCreateType(null),
1326
+ onCreated: (name) => {
1327
+ setCreateType(null)
1328
+ showToast(t('createdDone', { name }))
1329
+ reload()
1330
+ fetchRoster(true) // composer 候选立即见到新专家(60s TTL 缓存强制失效)
1331
+ },
1332
+ onOpenSession: (sessionId) => { try { sessionsApi.open(sessionId) } catch (e) { showToast(String(e && e.message)) } },
1333
+ }) : null,
1091
1334
  toast !== null ? h('div', { className: 'exp-toast' }, toast) : null)
1092
1335
  }
1093
1336
 
@@ -1100,6 +1343,7 @@ module.exports = {
1100
1343
  NS, ZH, EN, matchExpert, formatSize, formatTime, avatarUrl,
1101
1344
  EXPERT_SOURCE_NAME, makeExpertSource, openTriggerSource, fetchRoster,
1102
1345
  toRosterRows, insertComposerText, splitRosterByType, pickerRowMatch,
1346
+ parseCreateResult, CREATE_TEMPLATES, EXPERT_CREATE_PROMPT_AGENT, EXPERT_CREATE_PROMPT_TEAM,
1103
1347
  },
1104
1348
  /** Test/host helper: mount a standalone page into any container. */
1105
1349
  __boot(container, opts = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weibaohui/experts-management",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "dsh 插件 · 专家管理:管理 ntd 格式的专家与专家团队(plugin.json + Agent MD + 技能集),浏览/安装 50+ 内置专家市场;每个专家注册为「仅用户可调用」的技能,在对话输入框用 /expert-名称 即可以该专家的身份执行任务(宿主确定性注入角色定义与技能清单,不占用模型目录 token)。",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -44,7 +44,7 @@
44
44
  "node": ">=22.5"
45
45
  },
46
46
  "dependencies": {
47
- "@weibaohui/dsh-plugin-kit": "^0.2.0",
47
+ "@weibaohui/dsh-plugin-kit": "^0.3.0",
48
48
  "yaml": "^2.9.0"
49
49
  },
50
50
  "devDependencies": {
package/src/index.js CHANGED
@@ -343,12 +343,12 @@ function buildExpertPrompt(agentMdBody, skillsText, expert) {
343
343
 
344
344
  // ── Shared route helpers ─────────────────────────────────────────────────
345
345
 
346
- function readJsonBody(req) {
346
+ function readJsonBody(req, cap = MAX_BODY_BYTES) {
347
347
  return new Promise((fulfil, reject) => {
348
348
  let size = 0, chunks = []
349
349
  req.on('data', (chunk) => {
350
350
  size += chunk.length
351
- if (size > MAX_BODY_BYTES) { reject(new Error('request body too large')); req.destroy(); return }
351
+ if (size > cap) { reject(new Error(`request body too large (cap ${cap} bytes)`)); req.destroy(); return }
352
352
  chunks.push(chunk)
353
353
  })
354
354
  req.on('end', () => {
@@ -552,6 +552,11 @@ async function locateEditable(locateExpert, name) {
552
552
  }
553
553
 
554
554
  const MD_MAX_CHARS = 512 * 1024
555
+ // 创建端点(v0.5):plugin.json + 团队全员 agent md 一起进一个 JSON body,
556
+ // 64KB 的默认上限装不下多人团队,放宽到与头像上传同档
557
+ const CREATE_BODY_MAX_BYTES = 8 * 1024 * 1024
558
+ // 创建端点里 agents/<file>.md 的文件名白名单(含 agentName 兜底校验共用)
559
+ const AGENT_FILE_RE = /^agents\/[A-Za-z0-9][A-Za-z0-9._-]*\.md$/
555
560
 
556
561
  // ── Module export ────────────────────────────────────────────────────────
557
562
 
@@ -1121,6 +1126,101 @@ module.exports = {
1121
1126
  return
1122
1127
  }
1123
1128
 
1129
+ // ── 创建端点(v0.5):AI 生成 → 预览确认 → 写入 dsh 用户库 ──
1130
+ // 与 share/run 共用 job 通道(createShareRunJob 进程内执行器),
1131
+ // cwd 固定为用户库根(生成类动作无需读盘,仅要一个存在的目录)
1132
+
1133
+ // POST /experts-management/api/create/run {prompt} → 起生成任务
1134
+ if (req.method === 'POST' && apiPath.endsWith('/experts-management/api/create/run')) {
1135
+ const body = await readJsonBody(req)
1136
+ if (typeof body.prompt !== 'string' || body.prompt.trim() === '') { sendJson(res, 400, { error: 'body must provide prompt' }); return }
1137
+ await fsP.mkdir(installedDir, { recursive: true })
1138
+ const binary = process.env.EXPERTS_DSH_BIN || 'dsh'
1139
+ const job = createShareRunJob({ binary, prompt: body.prompt, dir: installedDir, jobs: shareRunJobs, logger: ctx.logger, services: shareServices })
1140
+ sendJson(res, 202, { jobId: job.id, status: job.status })
1141
+ return
1142
+ }
1143
+
1144
+ // GET /experts-management/api/create/run?id= → 生成任务状态/输出
1145
+ if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/create/run')) {
1146
+ const id = query.get('id') || ''
1147
+ const job = shareRunJobs.get(id)
1148
+ if (job === undefined) { sendJson(res, 404, { error: 'job not found' }); return }
1149
+ sendJson(res, 200, { ...job, output: job.output.slice(-32 * 1024) })
1150
+ return
1151
+ }
1152
+
1153
+ // POST /experts-management/api/create {pluginJson, agentMd?} | {pluginJson, agents?: [{file, content}]}
1154
+ // agent 型:pluginJson + 单个 agentMd;team 型:pluginJson + 逐成员 agents[]。
1155
+ // 全量先验后写:任一校验失败不落盘;写失败清理半成品目录(ntd create_expert 同款)。
1156
+ if (req.method === 'POST' && apiPath.endsWith('/experts-management/api/create')) {
1157
+ const body = await readJsonBody(req, CREATE_BODY_MAX_BYTES)
1158
+ let plugin
1159
+ try { plugin = JSON.parse(typeof body.pluginJson === 'string' ? body.pluginJson : '') } catch { throw new Error('pluginJson is not valid JSON') }
1160
+ if (plugin === null || typeof plugin !== 'object' || Array.isArray(plugin)) throw new Error('pluginJson must be a JSON object')
1161
+ const name = typeof plugin.name === 'string' ? plugin.name : ''
1162
+ // 注册表硬约束:非 kebab 名的专家会被 skill provider 静默跳过(/expert- 手势永远出不来),必须在创建时就拒绝
1163
+ if (!KEBAB_NAME_RE.test(name)) throw new Error(`expert name must be lowercase kebab-case, got '${name}' (registers the /expert-${name || '?'} gesture)`)
1164
+ if (plugin.expertType !== 'agent' && plugin.expertType !== 'team') throw new Error("expertType must be 'agent' or 'team'")
1165
+ // provider 对空描述的专家同样静默跳过(describe() 为空不入目录)
1166
+ const hasText = (v) => typeof v === 'string' && v.trim() !== ''
1167
+ const describeOk = hasText(plugin.description)
1168
+ || (plugin.profession !== null && typeof plugin.profession === 'object' && (hasText(plugin.profession.zh) || hasText(plugin.profession.en)))
1169
+ || (plugin.displayDescription !== null && typeof plugin.displayDescription === 'object' && (hasText(plugin.displayDescription.zh) || hasText(plugin.displayDescription.en)))
1170
+ if (!describeOk) throw new Error('expert needs a non-empty description/profession/displayDescription (the skill registry skips empty ones)')
1171
+
1172
+ const target = join(installedDir, name)
1173
+ const exists = await fsP.stat(target).then(() => true).catch(() => false)
1174
+ if (exists) throw new Error(`expert '${name}' already exists in the dsh library`)
1175
+
1176
+ const files = []
1177
+ if (plugin.expertType === 'agent') {
1178
+ const agentMd = typeof body.agentMd === 'string' ? body.agentMd : ''
1179
+ if (agentMd.trim() === '') throw new Error('agentMd must be a non-empty string')
1180
+ if (agentMd.length > MD_MAX_CHARS) throw new Error(`agentMd exceeds ${MD_MAX_CHARS} chars`)
1181
+ const agentName = hasText(plugin.agentName) ? plugin.agentName : name
1182
+ if (!AGENT_FILE_RE.test(`agents/${agentName}.md`)) throw new Error(`agentName must be plain ascii id, got '${agentName}'`)
1183
+ plugin.agentName = agentName
1184
+ plugin.agents = [`./agents/${agentName}.md`]
1185
+ files.push({ rel: `agents/${agentName}.md`, content: agentMd })
1186
+ } else {
1187
+ const list = Array.isArray(body.agents) ? body.agents : []
1188
+ if (list.length === 0) throw new Error('agents must be a non-empty array of {file, content}')
1189
+ if (list.length > 20) throw new Error('agents supports at most 20 member files')
1190
+ const agentRels = []
1191
+ const stems = new Set()
1192
+ const seenFiles = new Set()
1193
+ for (const item of list) {
1194
+ const file = typeof item === 'object' && item !== null && typeof item.file === 'string' ? item.file : ''
1195
+ if (!AGENT_FILE_RE.test(file)) throw new Error(`invalid agent file name: '${file}' (expected agents/<id>.md)`)
1196
+ if (seenFiles.has(file)) throw new Error(`duplicate agent file: '${file}'`)
1197
+ seenFiles.add(file)
1198
+ const content = typeof item.content === 'string' ? item.content : ''
1199
+ if (content.trim() === '') throw new Error(`agent file '${file}' content must be non-empty`)
1200
+ if (content.length > MD_MAX_CHARS) throw new Error(`agent file '${file}' exceeds ${MD_MAX_CHARS} chars`)
1201
+ stems.add(basename(file).replace(/\.md$/, ''))
1202
+ agentRels.push(`./${file}`)
1203
+ files.push({ rel: file, content })
1204
+ }
1205
+ const lead = plugin.teamInfo !== null && typeof plugin.teamInfo === 'object' && typeof plugin.teamInfo.leadAgent === 'string' ? plugin.teamInfo.leadAgent : ''
1206
+ if (!stems.has(lead)) throw new Error(`teamInfo.leadAgent '${lead || '(missing)'}' must match one of the agents files`)
1207
+ plugin.agentName = lead
1208
+ plugin.agents = agentRels
1209
+ }
1210
+
1211
+ try {
1212
+ await fsP.mkdir(join(target, '.codebuddy-plugin'), { recursive: true })
1213
+ await atomicWriteJs(join(target, PLUGIN_JSON_REL), JSON.stringify(plugin, null, 2))
1214
+ for (const f of files) await atomicWriteJs(join(target, f.rel), f.content)
1215
+ } catch (e) {
1216
+ await fsP.rm(target, { recursive: true, force: true }).catch(() => {})
1217
+ throw e
1218
+ }
1219
+ invalidate()
1220
+ sendJson(res, 201, { created: { name, expertType: plugin.expertType, dir: displayPath(target), agents: files.map((f) => f.rel) } })
1221
+ return
1222
+ }
1223
+
1124
1224
  // GET /experts-management/api/builtin/status
1125
1225
  if (req.method === 'GET' && apiPath.endsWith('/experts-management/api/builtin/status')) {
1126
1226
  await builtinStateLoaded