@waterplus-ai/waterbuddy 0.1.78 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -3
- package/assets/experts/teams/smart-water-delivery-team/.codebuddy-plugin/plugin.json +19 -0
- package/assets/experts/teams/smart-water-delivery-team/agents/lead.md +10 -0
- package/assets/experts/teams/software-development-team/.codebuddy-plugin/plugin.json +57 -0
- package/assets/experts/teams/software-development-team/agents/gstack-designer.md +209 -0
- package/assets/experts/teams/software-development-team/agents/gstack-investigator.md +208 -0
- package/assets/experts/teams/software-development-team/agents/gstack-lead.md +264 -0
- package/assets/experts/teams/software-development-team/agents/gstack-product-reviewer.md +516 -0
- package/assets/experts/teams/software-development-team/agents/gstack-qa-lead.md +187 -0
- package/assets/experts/teams/software-development-team/agents/gstack-security-officer.md +408 -0
- package/assets/experts/teams/software-development-team/avatars/gstack-designer.svg +1 -0
- package/assets/experts/teams/software-development-team/avatars/gstack-investigator.svg +1 -0
- package/assets/experts/teams/software-development-team/avatars/gstack-lead.svg +1 -0
- package/assets/experts/teams/software-development-team/avatars/gstack-product-reviewer.svg +1 -0
- package/assets/experts/teams/software-development-team/avatars/gstack-qa-lead.svg +1 -0
- package/assets/experts/teams/software-development-team/avatars/gstack-security-officer.svg +1 -0
- package/assets/experts/teams/software-development-team/avatars/team.svg +1 -0
- package/assets/experts/teams/software-development-team/skills/design-html/SKILL.md +35 -0
- package/assets/experts/teams/software-development-team/skills/qa/SKILL.md +44 -0
- package/assets/experts/teams/software-development-team/skills/review/SKILL.md +43 -0
- package/assets/experts/teams/water-operations-team/.codebuddy-plugin/plugin.json +19 -0
- package/assets/experts/teams/water-operations-team/agents/lead.md +9 -0
- package/lib/client/index.js +296 -120
- package/lib/host/expert-store.js +1 -1
- package/lib/host/expert-tools.js +156 -25
- package/lib/host/experts.js +511 -26
- package/lib/host/index.js +51 -22
- package/lib/host/llm-gateway.js +1 -1
- package/package.json +3 -3
package/lib/host/experts.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 专家名册:把旧版 Markdown persona 与 ntd 目录包统一读成可召唤的专家目录。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 旧版 persona 仍支持 `assets/experts/<分区>/<slug>.md`;新目录包使用
|
|
5
|
+
* `.codebuddy-plugin/plugin.json` 与 `agents/*.md`。每份 persona 的正文是**完整的系统提示词**,
|
|
6
|
+
* 所以启动时只读 frontmatter/元数据做目录,正文留到真正召唤时再读。
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
+
* 旧版文件格式(frontmatter + 正文,正文即系统提示词):
|
|
8
9
|
*
|
|
9
10
|
* ---
|
|
10
11
|
* name: 供水管网水力建模专家
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
* 这样解析器不需要引入 YAML 依赖——本插件是零依赖的纯 JS 包。
|
|
19
20
|
*/
|
|
20
21
|
import fs from 'node:fs/promises'
|
|
22
|
+
import os from 'node:os'
|
|
21
23
|
import path from 'node:path'
|
|
22
24
|
import { fileURLToPath } from 'node:url'
|
|
23
25
|
|
|
@@ -124,6 +126,42 @@ export function parseExpertFrontmatter(text) {
|
|
|
124
126
|
return {}
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
function parseFrontmatterCollections(text) {
|
|
130
|
+
const lines = stripBom(String(text)).split(/\r?\n/)
|
|
131
|
+
if (lines[0]?.trim() !== '---') return {}
|
|
132
|
+
const collections = {}
|
|
133
|
+
let currentKey = ''
|
|
134
|
+
let currentItem
|
|
135
|
+
for (const line of lines.slice(1)) {
|
|
136
|
+
if (line.trim() === '---') break
|
|
137
|
+
const section = line.match(/^([A-Za-z][\w-]*):\s*$/)
|
|
138
|
+
if (section) {
|
|
139
|
+
currentKey = section[1]
|
|
140
|
+
currentItem = undefined
|
|
141
|
+
collections[currentKey] = []
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
const item = line.match(/^\s+-\s+(.*)$/)
|
|
145
|
+
if (item && currentKey) {
|
|
146
|
+
const value = item[1].trim()
|
|
147
|
+
if (value.includes(':')) {
|
|
148
|
+
const separator = value.indexOf(':')
|
|
149
|
+
currentItem = { [value.slice(0, separator).trim()]: value.slice(separator + 1).trim() }
|
|
150
|
+
collections[currentKey].push(currentItem)
|
|
151
|
+
} else {
|
|
152
|
+
currentItem = value
|
|
153
|
+
collections[currentKey].push(value)
|
|
154
|
+
}
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
const property = line.match(/^\s{4,}([A-Za-z][\w-]*):\s*(.*)$/)
|
|
158
|
+
if (property && currentItem && typeof currentItem === 'object') {
|
|
159
|
+
currentItem[property[1]] = property[2].trim()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return collections
|
|
163
|
+
}
|
|
164
|
+
|
|
127
165
|
/** 去掉 frontmatter,返回正文。 */
|
|
128
166
|
export function expertBody(text) {
|
|
129
167
|
const lines = stripBom(String(text)).split(/\r?\n/)
|
|
@@ -134,6 +172,141 @@ export function expertBody(text) {
|
|
|
134
172
|
return ''
|
|
135
173
|
}
|
|
136
174
|
|
|
175
|
+
const NTD_PLUGIN_DIR = '.codebuddy-plugin'
|
|
176
|
+
const NTD_PLUGIN_FILE = 'plugin.json'
|
|
177
|
+
|
|
178
|
+
async function readJsonFile(filePath) {
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(await fs.readFile(filePath, 'utf8'))
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error.code === 'ENOENT') return undefined
|
|
183
|
+
throw new Error(`EXPERT_PLUGIN_INVALID: ${filePath} 无法解析 plugin.json`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function ntdSlug(values) {
|
|
188
|
+
for (const value of values) {
|
|
189
|
+
const slug = String(value || '').trim().toLowerCase()
|
|
190
|
+
if (EXPERT_PATH_SEGMENT_PATTERN.test(slug)) return slug
|
|
191
|
+
}
|
|
192
|
+
return ''
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function ntdLocalized(value) {
|
|
196
|
+
if (value && typeof value === 'object') {
|
|
197
|
+
return String(value.zh || value.en || Object.values(value)[0] || '').trim()
|
|
198
|
+
}
|
|
199
|
+
return String(value || '').trim()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function ntdMember(member) {
|
|
203
|
+
if (typeof member === 'string') return { slug: member, name: member, avatar: '' }
|
|
204
|
+
if (!member || typeof member !== 'object') return undefined
|
|
205
|
+
const slug = String(member.slug || member.id || ntdLocalized(member.name) || '').trim()
|
|
206
|
+
if (!slug) return undefined
|
|
207
|
+
return {
|
|
208
|
+
slug,
|
|
209
|
+
name: ntdLocalized(member.displayName || member.name || slug),
|
|
210
|
+
avatar: String(member.avatar || '').trim(),
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function loadNtdEntry(directory, pluginPath) {
|
|
215
|
+
const plugin = await readJsonFile(pluginPath)
|
|
216
|
+
if (!plugin || typeof plugin !== 'object') return undefined
|
|
217
|
+
const isTeam = String(plugin.expertType || '').toLowerCase() === 'team'
|
|
218
|
+
const fallback = path.basename(directory)
|
|
219
|
+
const configuredSlug = plugin.slug || plugin.name
|
|
220
|
+
const slug = ntdSlug([configuredSlug || fallback])
|
|
221
|
+
if (!slug) throw new Error(`EXPERT_SLUG_INVALID: ${directory} 的 plugin.json name 不合法`)
|
|
222
|
+
const agentsDir = path.join(directory, 'agents')
|
|
223
|
+
let agentFiles = []
|
|
224
|
+
try { agentFiles = (await fs.readdir(agentsDir)).filter(file => file.endsWith('.md')).sort() } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
225
|
+
const teamInfo = plugin.teamInfo && typeof plugin.teamInfo === 'object' ? plugin.teamInfo : {}
|
|
226
|
+
const declaredAgents = Array.isArray(plugin.agents) ? plugin.agents : []
|
|
227
|
+
const leadRef = isTeam
|
|
228
|
+
? String(teamInfo.leadAgent || teamInfo.leader || teamInfo.lead || plugin.agentName || agentFiles[0] || declaredAgents[0] || '').trim()
|
|
229
|
+
: String(plugin.agent || plugin.agentName || agentFiles[0] || declaredAgents[0] || '').trim()
|
|
230
|
+
const leadPath = leadRef.includes('/') ? leadRef : path.join('agents', leadRef.endsWith('.md') ? leadRef : `${leadRef}.md`)
|
|
231
|
+
const leadFile = leadRef
|
|
232
|
+
? path.resolve(directory, leadPath)
|
|
233
|
+
: undefined
|
|
234
|
+
if (!leadFile || !leadFile.startsWith(path.resolve(directory) + path.sep)) throw new Error(`EXPERT_AGENT_INVALID: ${slug} 的负责人路径不合法`)
|
|
235
|
+
let leadFields = {}
|
|
236
|
+
try {
|
|
237
|
+
leadFields = parseExpertFrontmatter(await readFrontmatterHead(leadFile))
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error.code === 'ENOENT') throw new Error(`EXPERT_AGENT_MISSING: ${slug} 找不到负责人文件 ${leadRef}`)
|
|
240
|
+
throw error
|
|
241
|
+
}
|
|
242
|
+
const displayName = ntdLocalized(plugin.displayName || plugin.title || leadFields.displayName || leadFields.name || (plugin.name && !EXPERT_PATH_SEGMENT_PATTERN.test(String(plugin.name).trim()) ? plugin.name : slug))
|
|
243
|
+
const description = ntdLocalized(plugin.description || plugin.displayDescription || plugin.summary || leadFields.description || '水务行业专家')
|
|
244
|
+
if (!displayName || !description) throw new Error(`EXPERT_FRONTMATTER_INCOMPLETE: ${directory} 缺少 displayName 或 description`)
|
|
245
|
+
const declaredMembers = Array.isArray(teamInfo.members) && teamInfo.members.length > 0
|
|
246
|
+
? teamInfo.members
|
|
247
|
+
: Array.isArray(plugin.members)
|
|
248
|
+
? [...plugin.members, ...(Array.isArray(teamInfo.memberAgents)
|
|
249
|
+
? teamInfo.memberAgents
|
|
250
|
+
.filter(member => !plugin.members.some(item => item && (item.id === member || item.slug === member)))
|
|
251
|
+
.map(member => ({ id: member }))
|
|
252
|
+
: [])]
|
|
253
|
+
: Array.isArray(teamInfo.memberAgents)
|
|
254
|
+
? teamInfo.memberAgents
|
|
255
|
+
: []
|
|
256
|
+
const members = isTeam ? declaredMembers.map(ntdMember).filter(Boolean) : []
|
|
257
|
+
return {
|
|
258
|
+
slug,
|
|
259
|
+
name: displayName,
|
|
260
|
+
description,
|
|
261
|
+
version: String(plugin.version || '').trim(),
|
|
262
|
+
profession: ntdLocalized(plugin.profession || ''),
|
|
263
|
+
displayName: ntdLocalized(plugin.displayName || ''),
|
|
264
|
+
displayDescription: ntdLocalized(plugin.displayDescription || ''),
|
|
265
|
+
defaultInitPrompt: ntdLocalized(plugin.defaultInitPrompt || ''),
|
|
266
|
+
quickPrompts: Array.isArray(plugin.quickPrompts) ? plugin.quickPrompts.map(item => ntdLocalized(item)).filter(Boolean) : [],
|
|
267
|
+
tags: Array.isArray(plugin.tags)
|
|
268
|
+
? plugin.tags.map(item => ({
|
|
269
|
+
zh: ntdLocalized(item && typeof item === 'object' ? item.zh : item),
|
|
270
|
+
en: ntdLocalized(item && typeof item === 'object' ? item.en : item),
|
|
271
|
+
})).filter(item => item.zh || item.en)
|
|
272
|
+
: [],
|
|
273
|
+
categoryId: String(plugin.categoryId || '').trim(),
|
|
274
|
+
agentName: String(plugin.agentName || plugin.agent_name || '').trim(),
|
|
275
|
+
leadAgent: String(teamInfo.leadAgent || teamInfo.leader || '').trim(),
|
|
276
|
+
memberAgents: Array.isArray(teamInfo.memberAgents) ? teamInfo.memberAgents.map(String) : [],
|
|
277
|
+
emoji: String(plugin.emoji || leadFields.emoji || (isTeam ? '👥' : '🧠')).trim(),
|
|
278
|
+
avatar: String(plugin.avatar || leadFields.avatar || '').trim(),
|
|
279
|
+
filePath: leadFile,
|
|
280
|
+
custom: false,
|
|
281
|
+
source: 'ntd',
|
|
282
|
+
kind: isTeam ? 'team' : 'expert',
|
|
283
|
+
expertType: isTeam ? 'team' : 'expert',
|
|
284
|
+
members,
|
|
285
|
+
skills: Array.isArray(plugin.skills) ? plugin.skills : [],
|
|
286
|
+
pluginPath,
|
|
287
|
+
assetDir: directory,
|
|
288
|
+
plugin,
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function walkNtdEntries(directory, add, division, depth = 0) {
|
|
293
|
+
if (depth > 4) return
|
|
294
|
+
let entries
|
|
295
|
+
try { entries = await fs.readdir(directory, { withFileTypes: true }) } catch (error) { if (error.code === 'ENOENT') return; throw error }
|
|
296
|
+
for (const entry of entries) {
|
|
297
|
+
if (!entry.isDirectory()) continue
|
|
298
|
+
const child = path.join(directory, entry.name)
|
|
299
|
+
const pluginPath = path.join(child, NTD_PLUGIN_DIR, NTD_PLUGIN_FILE)
|
|
300
|
+
const plugin = await loadNtdEntry(child, pluginPath)
|
|
301
|
+
if (plugin) {
|
|
302
|
+
if (plugin.kind !== 'team') plugin.division = division || 'general'
|
|
303
|
+
add(plugin)
|
|
304
|
+
} else {
|
|
305
|
+
await walkNtdEntries(child, add, division || entry.name, depth + 1)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
137
310
|
/**
|
|
138
311
|
* 扫描名册目录,只读 frontmatter 建立目录。
|
|
139
312
|
*
|
|
@@ -141,41 +314,66 @@ export function expertBody(text) {
|
|
|
141
314
|
* 专家在分区内按 slug 排序,保证同一次构建的目录顺序稳定。
|
|
142
315
|
*
|
|
143
316
|
* @param rootDir - 名册根目录,默认 {@link DEFAULT_EXPERTS_DIR}。
|
|
144
|
-
* @returns `{ divisions, bySlug }`;`divisions` 是 `[{ slug, name, experts }]`。
|
|
317
|
+
* @returns `{ divisions, teams, bySlug }`;`divisions` 是 `[{ slug, name, experts }]`。
|
|
145
318
|
* @throws 当文件名不合法、缺必填字段或 slug 重复时——名册是自己发布的资产,
|
|
146
319
|
* 坏文件必须显式失败,不能悄悄少一个专家。
|
|
147
320
|
*/
|
|
148
321
|
export async function loadExpertCatalog(rootDir = DEFAULT_EXPERTS_DIR) {
|
|
149
322
|
const divisions = []
|
|
150
323
|
const bySlug = new Map()
|
|
324
|
+
const teams = []
|
|
325
|
+
const seenNtdPlugins = new Set()
|
|
326
|
+
const byDivision = new Map()
|
|
327
|
+
const add = expert => {
|
|
328
|
+
if (bySlug.has(expert.slug)) throw new Error(`EXPERT_SLUG_DUPLICATE: ${expert.slug} 在名册中重复`)
|
|
329
|
+
bySlug.set(expert.slug, expert)
|
|
330
|
+
if (expert.kind === 'team') {
|
|
331
|
+
teams.push(expert)
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
const divisionSlug = expert.division || 'general'
|
|
335
|
+
let division = byDivision.get(divisionSlug)
|
|
336
|
+
if (!division) {
|
|
337
|
+
division = { slug: divisionSlug, name: EXPERT_DIVISIONS[divisionSlug] || divisionSlug, experts: [] }
|
|
338
|
+
byDivision.set(divisionSlug, division)
|
|
339
|
+
divisions.push(division)
|
|
340
|
+
}
|
|
341
|
+
division.experts.push(expert)
|
|
342
|
+
}
|
|
151
343
|
let divisionEntries = []
|
|
152
344
|
try {
|
|
153
345
|
divisionEntries = await fs.readdir(rootDir, { withFileTypes: true })
|
|
154
346
|
} catch (error) {
|
|
155
|
-
if (error.code === 'ENOENT') return { divisions, bySlug }
|
|
347
|
+
if (error.code === 'ENOENT') return { divisions, teams, bySlug }
|
|
156
348
|
throw error
|
|
157
349
|
}
|
|
158
350
|
|
|
159
351
|
for (const entry of divisionEntries) {
|
|
160
352
|
if (!entry.isDirectory() || !EXPERT_PATH_SEGMENT_PATTERN.test(entry.name)) continue
|
|
161
353
|
const divisionSlug = entry.name
|
|
162
|
-
const
|
|
163
|
-
const
|
|
354
|
+
const divisionDir = path.join(rootDir, divisionSlug)
|
|
355
|
+
const ntdPlugin = await loadNtdEntry(divisionDir, path.join(divisionDir, NTD_PLUGIN_DIR, NTD_PLUGIN_FILE))
|
|
356
|
+
if (ntdPlugin) {
|
|
357
|
+
seenNtdPlugins.add(ntdPlugin.pluginPath)
|
|
358
|
+
if (ntdPlugin.kind !== 'team') ntdPlugin.division = divisionSlug
|
|
359
|
+
add(ntdPlugin)
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
const files = (await fs.readdir(divisionDir)).filter(name => name.endsWith('.md')).sort()
|
|
164
363
|
for (const file of files) {
|
|
165
364
|
const slug = file.slice(0, -3)
|
|
166
365
|
if (!EXPERT_PATH_SEGMENT_PATTERN.test(slug)) {
|
|
167
366
|
throw new Error(`EXPERT_SLUG_INVALID: ${divisionSlug}/${file} 只允许小写字母数字与连字符`)
|
|
168
367
|
}
|
|
169
368
|
const filePath = path.join(rootDir, divisionSlug, file)
|
|
170
|
-
const
|
|
369
|
+
const header = await readFrontmatterHead(filePath)
|
|
370
|
+
const fields = parseExpertFrontmatter(header)
|
|
371
|
+
const collections = parseFrontmatterCollections(header)
|
|
171
372
|
const name = (fields.name || '').trim()
|
|
172
373
|
const description = (fields.description || '').trim()
|
|
173
374
|
if (!name || !description) {
|
|
174
375
|
throw new Error(`EXPERT_FRONTMATTER_INCOMPLETE: ${divisionSlug}/${file} 缺少 name 或 description`)
|
|
175
376
|
}
|
|
176
|
-
if (bySlug.has(slug)) {
|
|
177
|
-
throw new Error(`EXPERT_SLUG_DUPLICATE: ${slug} 在名册中重复`)
|
|
178
|
-
}
|
|
179
377
|
if (fields.division && fields.division !== divisionSlug) {
|
|
180
378
|
throw new Error(
|
|
181
379
|
`EXPERT_DIVISION_MISMATCH: ${divisionSlug}/${file} 的 division=${fields.division} 与目录不一致`,
|
|
@@ -186,26 +384,46 @@ export async function loadExpertCatalog(rootDir = DEFAULT_EXPERTS_DIR) {
|
|
|
186
384
|
division: divisionSlug,
|
|
187
385
|
name,
|
|
188
386
|
description,
|
|
387
|
+
version: String(fields.version || '').trim(),
|
|
388
|
+
profession: String(fields.profession || '').trim(),
|
|
389
|
+
displayName: String(fields.displayName || '').trim(),
|
|
390
|
+
displayDescription: String(fields.displayDescription || '').trim(),
|
|
391
|
+
defaultInitPrompt: String(fields.defaultInitPrompt || '').trim(),
|
|
392
|
+
quickPrompts: String(fields.quickPrompts || '').split('|').map(item => item.trim()).filter(Boolean),
|
|
393
|
+
tags: String(fields.tags || '').split('|').map(item => item.trim()).filter(Boolean).map(item => ({ zh: item, en: item })),
|
|
394
|
+
categoryId: String(fields.categoryId || '').trim(),
|
|
395
|
+
frontmatter: fields,
|
|
396
|
+
knowledgeBases: Array.isArray(collections.knowledgeBases) ? collections.knowledgeBases : [],
|
|
189
397
|
emoji: (fields.emoji || '').trim(),
|
|
190
398
|
// 头像图片:http(s) URL 直接用;否则当作与本 md 同目录的文件名,
|
|
191
399
|
// 由 `/api/waterbuddy/experts/avatar` 按需吐出来(不塞进名册快照)。
|
|
192
400
|
avatar: (fields.avatar || '').trim(),
|
|
193
401
|
filePath,
|
|
194
402
|
custom: false,
|
|
403
|
+
source: 'legacy',
|
|
404
|
+
kind: 'expert',
|
|
405
|
+
expertType: 'expert',
|
|
406
|
+
members: [],
|
|
195
407
|
}
|
|
196
|
-
|
|
197
|
-
bySlug.set(slug, expert)
|
|
198
|
-
}
|
|
199
|
-
if (experts.length > 0) {
|
|
200
|
-
divisions.push({
|
|
201
|
-
slug: divisionSlug,
|
|
202
|
-
name: EXPERT_DIVISIONS[divisionSlug] || divisionSlug,
|
|
203
|
-
experts,
|
|
204
|
-
})
|
|
408
|
+
add(expert)
|
|
205
409
|
}
|
|
206
410
|
}
|
|
207
411
|
|
|
208
|
-
|
|
412
|
+
await walkNtdEntries(rootDir, expert => {
|
|
413
|
+
if (seenNtdPlugins.has(expert.pluginPath)) return
|
|
414
|
+
seenNtdPlugins.add(expert.pluginPath)
|
|
415
|
+
add(expert)
|
|
416
|
+
}, undefined)
|
|
417
|
+
// NTD 团队只保存成员引用;等所有旧版与 NTD 专家都入册后再解析,避免目录顺序影响结果。
|
|
418
|
+
for (const team of teams) {
|
|
419
|
+
team.memberExperts = team.members.map(member => bySlug.get(member.slug)).filter(Boolean)
|
|
420
|
+
}
|
|
421
|
+
// 声明顺序优先,未知分区稳定排在最后;避免 readdir 顺序改变设置页和 @ 菜单。
|
|
422
|
+
const divisionOrder = new Map(Object.keys(EXPERT_DIVISIONS).map((slug, index) => [slug, index]))
|
|
423
|
+
divisions.sort((left, right) => (divisionOrder.get(left.slug) ?? Number.MAX_SAFE_INTEGER) - (divisionOrder.get(right.slug) ?? Number.MAX_SAFE_INTEGER) || left.slug.localeCompare(right.slug))
|
|
424
|
+
for (const division of divisions) division.experts.sort((left, right) => left.slug.localeCompare(right.slug))
|
|
425
|
+
teams.sort((left, right) => left.slug.localeCompare(right.slug))
|
|
426
|
+
return { divisions, teams, bySlug }
|
|
209
427
|
}
|
|
210
428
|
|
|
211
429
|
/**
|
|
@@ -223,6 +441,178 @@ export async function readExpertBody(expert) {
|
|
|
223
441
|
return expertBody(await fs.readFile(expert.filePath, 'utf8'))
|
|
224
442
|
}
|
|
225
443
|
|
|
444
|
+
async function countExpertFilesAndSize(target) {
|
|
445
|
+
let fileCount = 0
|
|
446
|
+
let totalSize = 0
|
|
447
|
+
const walk = async current => {
|
|
448
|
+
const stat = await fs.stat(current).catch(() => undefined)
|
|
449
|
+
if (!stat) return
|
|
450
|
+
if (stat.isFile()) {
|
|
451
|
+
fileCount += 1
|
|
452
|
+
totalSize += stat.size
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
if (!stat.isDirectory()) return
|
|
456
|
+
const entries = await fs.readdir(current, { withFileTypes: true })
|
|
457
|
+
for (const entry of entries) {
|
|
458
|
+
if (entry.name === '.git') continue
|
|
459
|
+
await walk(path.join(current, entry.name))
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
await walk(target)
|
|
463
|
+
return { fileCount, totalSize }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function displayExpertPath(target) {
|
|
467
|
+
const absolute = path.resolve(target)
|
|
468
|
+
const home = os.homedir()
|
|
469
|
+
if (absolute === home) return '~'
|
|
470
|
+
if (absolute.startsWith(home + path.sep)) return '~' + absolute.slice(home.length)
|
|
471
|
+
const relative = path.relative(process.cwd(), absolute)
|
|
472
|
+
return relative && !relative.startsWith('..') ? relative : absolute
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function detailAgentName(filePath) {
|
|
476
|
+
return path.basename(filePath).replace(/\.md$/i, '')
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function detailAgentMatches(agent, wanted) {
|
|
480
|
+
if (!wanted) return false
|
|
481
|
+
const normalized = String(wanted).replace(/^\.\//, '')
|
|
482
|
+
return agent.name === wanted
|
|
483
|
+
|| agent.relPath.replace(/^\.\//, '') === normalized
|
|
484
|
+
|| detailAgentName(agent.relPath) === detailAgentName(normalized)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function readAgentDetails(expert) {
|
|
488
|
+
if (expert.custom && !expert.filePath) return []
|
|
489
|
+
if (expert.source !== 'ntd') {
|
|
490
|
+
const relPath = expert.filePath ? path.basename(expert.filePath) : `${expert.slug}.md`
|
|
491
|
+
return [{
|
|
492
|
+
name: expert.name || expert.slug,
|
|
493
|
+
description: expert.description || '',
|
|
494
|
+
emoji: expert.emoji || '',
|
|
495
|
+
relPath,
|
|
496
|
+
role: 'lead',
|
|
497
|
+
}]
|
|
498
|
+
}
|
|
499
|
+
const root = expert.assetDir
|
|
500
|
+
if (!root) return []
|
|
501
|
+
let relativePaths = Array.isArray(expert.plugin?.agents) ? expert.plugin.agents.map(String) : []
|
|
502
|
+
if (relativePaths.length === 0) {
|
|
503
|
+
try {
|
|
504
|
+
relativePaths = (await fs.readdir(path.join(root, 'agents'), { withFileTypes: true }))
|
|
505
|
+
.filter(entry => entry.isFile() && entry.name.endsWith('.md'))
|
|
506
|
+
.map(entry => `./agents/${entry.name}`)
|
|
507
|
+
.sort()
|
|
508
|
+
} catch {
|
|
509
|
+
relativePaths = []
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const details = []
|
|
513
|
+
for (const relPath of relativePaths) {
|
|
514
|
+
const filePath = path.resolve(root, relPath)
|
|
515
|
+
if (!(filePath === path.resolve(root) || filePath.startsWith(path.resolve(root) + path.sep))) continue
|
|
516
|
+
const fields = await readFrontmatterHead(filePath).then(parseExpertFrontmatter).catch(() => ({}))
|
|
517
|
+
const fileName = detailAgentName(filePath)
|
|
518
|
+
const member = (expert.members || []).find(item => detailAgentMatches({ name: item.slug, relPath: fileName }, fileName) || item.slug === fileName)
|
|
519
|
+
details.push({
|
|
520
|
+
name: String(fields.name || fields.displayName || fileName).trim(),
|
|
521
|
+
description: String(fields.description || '').trim(),
|
|
522
|
+
emoji: String(fields.emoji || '').trim(),
|
|
523
|
+
relPath,
|
|
524
|
+
role: member?.role || (detailAgentMatches({ name: fileName, relPath }, expert.leadAgent || expert.agentName) ? 'lead' : 'member'),
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
return details
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function readSkillDetails(expert) {
|
|
531
|
+
const skills = Array.isArray(expert.plugin?.skills) ? expert.plugin.skills : (expert.skills || [])
|
|
532
|
+
if (!Array.isArray(skills) || skills.length === 0) return []
|
|
533
|
+
const root = expert.assetDir
|
|
534
|
+
const details = []
|
|
535
|
+
for (const item of skills) {
|
|
536
|
+
const raw = typeof item === 'string' ? item : item?.name || item?.path || ''
|
|
537
|
+
const relPath = String(raw || '').trim()
|
|
538
|
+
if (!relPath) continue
|
|
539
|
+
if (!root) {
|
|
540
|
+
details.push({ skillName: relPath, description: '', descriptionZh: '', descriptionEn: '' })
|
|
541
|
+
continue
|
|
542
|
+
}
|
|
543
|
+
const skillDir = path.resolve(root, relPath)
|
|
544
|
+
if (!(skillDir === path.resolve(root) || skillDir.startsWith(path.resolve(root) + path.sep))) continue
|
|
545
|
+
const skillPath = path.join(skillDir, 'SKILL.md')
|
|
546
|
+
const fields = await readFrontmatterHead(skillPath).then(parseExpertFrontmatter).catch(() => ({}))
|
|
547
|
+
const skillName = String(fields.name || path.basename(skillDir)).trim()
|
|
548
|
+
details.push({
|
|
549
|
+
skillName,
|
|
550
|
+
description: String(fields.description || '').trim(),
|
|
551
|
+
descriptionZh: String(fields.description_zh || fields.description || '').trim(),
|
|
552
|
+
descriptionEn: String(fields.description_en || '').trim(),
|
|
553
|
+
version: String(fields.version || '').trim(),
|
|
554
|
+
emoji: String(fields.emoji || '').trim(),
|
|
555
|
+
relPath,
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
return details
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* 读取设置页详情弹窗所需的完整元数据。
|
|
563
|
+
*
|
|
564
|
+
* 名册快照保持轻量;只有用户打开详情时才读取目录统计、agent 摘要、技能摘要和 plugin.json。
|
|
565
|
+
*/
|
|
566
|
+
export async function readExpertDetails(expert) {
|
|
567
|
+
const root = expert.assetDir || expert.filePath || ''
|
|
568
|
+
const stats = root ? await countExpertFilesAndSize(root) : { fileCount: 0, totalSize: 0 }
|
|
569
|
+
const agents = await readAgentDetails(expert)
|
|
570
|
+
const pluginMembers = Array.isArray(expert.plugin?.members) ? expert.plugin.members : []
|
|
571
|
+
const members = (expert.members || []).map(member => {
|
|
572
|
+
const metadata = pluginMembers.find(item => item && (item.id === member.slug || item.slug === member.slug))
|
|
573
|
+
return {
|
|
574
|
+
...member,
|
|
575
|
+
profession: metadata ? ntdLocalized(metadata.profession || metadata.roleName || '') : '',
|
|
576
|
+
role: metadata?.role === 'lead' ? 'lead' : (member.slug === (expert.leadAgent || expert.agentName) ? 'lead' : 'member'),
|
|
577
|
+
}
|
|
578
|
+
})
|
|
579
|
+
const pluginJson = expert.source === 'ntd'
|
|
580
|
+
? (expert.plugin || {})
|
|
581
|
+
: {
|
|
582
|
+
name: expert.slug,
|
|
583
|
+
version: expert.version || '1.0.0',
|
|
584
|
+
expertType: 'agent',
|
|
585
|
+
displayName: { zh: expert.name },
|
|
586
|
+
profession: { zh: expert.profession || expert.name },
|
|
587
|
+
displayDescription: { zh: expert.description },
|
|
588
|
+
agentName: expert.slug,
|
|
589
|
+
...(expert.frontmatter || {}),
|
|
590
|
+
}
|
|
591
|
+
const leadAgent = expert.leadAgent || expert.agentName || agents[0]?.name || ''
|
|
592
|
+
const leadAgentFile = agents.find(agent => detailAgentMatches(agent, leadAgent))?.relPath || agents[0]?.relPath || ''
|
|
593
|
+
const quickPrompts = Array.isArray(expert.quickPrompts) ? expert.quickPrompts : []
|
|
594
|
+
return {
|
|
595
|
+
source: expert.custom ? 'user' : 'builtin',
|
|
596
|
+
sourceLabel: expert.custom ? '自定义' : '内置',
|
|
597
|
+
readOnly: expert.custom !== true,
|
|
598
|
+
version: expert.version || pluginJson.version || '1.0.0',
|
|
599
|
+
profession: expert.profession || ntdLocalized(pluginJson.profession || ''),
|
|
600
|
+
defaultInitPrompt: expert.defaultInitPrompt || ntdLocalized(pluginJson.defaultInitPrompt || ''),
|
|
601
|
+
quickPrompts,
|
|
602
|
+
tags: expert.tags || [],
|
|
603
|
+
knowledgeBases: expert.knowledgeBases || (Array.isArray(pluginJson.knowledgeBases) ? pluginJson.knowledgeBases : []),
|
|
604
|
+
categoryId: expert.categoryId || String(pluginJson.categoryId || '').trim(),
|
|
605
|
+
dir: displayExpertPath(root),
|
|
606
|
+
fileCount: stats.fileCount,
|
|
607
|
+
totalSize: stats.totalSize,
|
|
608
|
+
members,
|
|
609
|
+
agentFiles: agents,
|
|
610
|
+
leadAgentFile,
|
|
611
|
+
skillMeta: await readSkillDetails(expert),
|
|
612
|
+
pluginJson,
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
226
616
|
/**
|
|
227
617
|
* 把自定义专家并进内置名册,产出一份新名册(不改原对象)。
|
|
228
618
|
*
|
|
@@ -237,6 +627,7 @@ export function mergeCustomExperts(catalog, customExperts) {
|
|
|
237
627
|
if (!Array.isArray(customExperts) || customExperts.length === 0) return catalog
|
|
238
628
|
const bySlug = new Map(catalog.bySlug)
|
|
239
629
|
const divisions = catalog.divisions.map(division => ({ ...division, experts: [...division.experts] }))
|
|
630
|
+
const teams = [...(catalog.teams || [])]
|
|
240
631
|
const byDivision = new Map(divisions.map(division => [division.slug, division]))
|
|
241
632
|
for (const custom of customExperts) {
|
|
242
633
|
if (!custom || bySlug.has(custom.slug)) continue
|
|
@@ -249,6 +640,10 @@ export function mergeCustomExperts(catalog, customExperts) {
|
|
|
249
640
|
avatar: custom.avatar || '',
|
|
250
641
|
body: custom.prompt,
|
|
251
642
|
custom: true,
|
|
643
|
+
source: 'user',
|
|
644
|
+
kind: 'expert',
|
|
645
|
+
expertType: 'expert',
|
|
646
|
+
members: [],
|
|
252
647
|
}
|
|
253
648
|
bySlug.set(expert.slug, expert)
|
|
254
649
|
const division = byDivision.get(expert.division)
|
|
@@ -264,7 +659,7 @@ export function mergeCustomExperts(catalog, customExperts) {
|
|
|
264
659
|
divisions.push(created)
|
|
265
660
|
}
|
|
266
661
|
}
|
|
267
|
-
return { divisions, bySlug }
|
|
662
|
+
return { divisions, teams, bySlug }
|
|
268
663
|
}
|
|
269
664
|
|
|
270
665
|
/**
|
|
@@ -298,7 +693,86 @@ export const EXPERT_OUTPUT_RULES = [
|
|
|
298
693
|
*/
|
|
299
694
|
export async function readExpertPersona(expert) {
|
|
300
695
|
const body = await readExpertBody(expert)
|
|
301
|
-
|
|
696
|
+
const teamContext = expert.kind === 'team'
|
|
697
|
+
? [
|
|
698
|
+
'## 专家团协作上下文',
|
|
699
|
+
'',
|
|
700
|
+
'你是一个专家团的负责人。以下成员是可协同的专业角色;请在最终交付中综合成员视角,',
|
|
701
|
+
'不要虚构未提供的成员结论,也不要把内部协作过程写进给用户的成品。',
|
|
702
|
+
...(expert.members || []).map(member => `- ${member.name || member.slug}(${member.slug})`),
|
|
703
|
+
'',
|
|
704
|
+
].join('\n')
|
|
705
|
+
: ''
|
|
706
|
+
return sanitizePersona(`${EXPERT_OUTPUT_RULES}\n${teamContext}${body}`)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function normalizeAgentReference(value) {
|
|
710
|
+
return String(value || '')
|
|
711
|
+
.replaceAll('\\', '/')
|
|
712
|
+
.replace(/^\.\//, '')
|
|
713
|
+
.replace(/^agents\//, '')
|
|
714
|
+
.replace(/\.md$/i, '')
|
|
715
|
+
.trim()
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function teamMemberMetadata(expert, memberId) {
|
|
719
|
+
const wanted = normalizeAgentReference(memberId)
|
|
720
|
+
return (expert.members || []).find(member => normalizeAgentReference(member.slug) === wanted)
|
|
721
|
+
|| (expert.members || []).find(member => normalizeAgentReference(member.name) === wanted)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function resolveTeamMemberFile(expert, memberId) {
|
|
725
|
+
const wanted = normalizeAgentReference(memberId)
|
|
726
|
+
if (!wanted || !expert?.assetDir) return undefined
|
|
727
|
+
const root = path.resolve(expert.assetDir)
|
|
728
|
+
const candidates = []
|
|
729
|
+
if (expert.filePath) candidates.push(expert.filePath)
|
|
730
|
+
const configuredAgents = Array.isArray(expert.plugin?.agents) ? expert.plugin.agents : []
|
|
731
|
+
for (const configured of configuredAgents) {
|
|
732
|
+
const relative = String(configured || '').trim()
|
|
733
|
+
if (relative) candidates.push(path.resolve(root, relative))
|
|
734
|
+
}
|
|
735
|
+
for (const candidate of candidates) {
|
|
736
|
+
const absolute = path.resolve(candidate)
|
|
737
|
+
if (!(absolute === root || absolute.startsWith(root + path.sep))) continue
|
|
738
|
+
const relative = path.relative(root, absolute)
|
|
739
|
+
if (normalizeAgentReference(relative) !== wanted && normalizeAgentReference(path.basename(absolute)) !== wanted) continue
|
|
740
|
+
try {
|
|
741
|
+
const stat = await fs.stat(absolute)
|
|
742
|
+
if (stat.isFile()) return absolute
|
|
743
|
+
} catch (error) {
|
|
744
|
+
if (error.code !== 'ENOENT') throw error
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return undefined
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* 读取专家团中某一成员的完整 persona。
|
|
752
|
+
*
|
|
753
|
+
* 成员可以是 NTD 包内的 `agents/*.md`,也可以是旧名册中由团队引用的专家。
|
|
754
|
+
* 这个 helper 只给 Host 的 fan-out 使用;详情页仍然读取未消毒的原文。
|
|
755
|
+
*/
|
|
756
|
+
export async function readExpertMemberPersona(expert, memberId) {
|
|
757
|
+
if (expert?.kind !== 'team') return readExpertPersona(expert)
|
|
758
|
+
const member = teamMemberMetadata(expert, memberId)
|
|
759
|
+
const localFile = await resolveTeamMemberFile(expert, memberId)
|
|
760
|
+
let body
|
|
761
|
+
if (localFile) {
|
|
762
|
+
body = expertBody(await fs.readFile(localFile, 'utf8'))
|
|
763
|
+
} else {
|
|
764
|
+
const referenced = (expert.memberExperts || []).find(item => normalizeAgentReference(item.slug) === normalizeAgentReference(memberId))
|
|
765
|
+
if (!referenced) throw new Error(`EXPERT_MEMBER_PERSONA_MISSING: ${expert.slug}/${memberId}`)
|
|
766
|
+
body = await readExpertBody(referenced)
|
|
767
|
+
}
|
|
768
|
+
if (!body) throw new Error(`EXPERT_MEMBER_PERSONA_EMPTY: ${expert.slug}/${memberId}`)
|
|
769
|
+
const teamContext = [
|
|
770
|
+
'## 专家团协作上下文',
|
|
771
|
+
'',
|
|
772
|
+
`你是“${expert.name}”中的${member?.name || memberId}。只输出自己的专业判断,供负责人汇总;不要虚构其他成员结论。`,
|
|
773
|
+
'',
|
|
774
|
+
].join('\n')
|
|
775
|
+
return sanitizePersona(`${EXPERT_OUTPUT_RULES}\n${teamContext}${body}`)
|
|
302
776
|
}
|
|
303
777
|
|
|
304
778
|
/**
|
|
@@ -313,6 +787,17 @@ export function resolveExpertAvatar(expert) {
|
|
|
313
787
|
if (!raw) return undefined
|
|
314
788
|
if (/^https?:\/\//i.test(raw)) return { kind: 'remote', url: raw }
|
|
315
789
|
if (raw.includes('..') || raw.startsWith('/')) return undefined
|
|
316
|
-
if (!expert.filePath) return undefined
|
|
317
|
-
|
|
790
|
+
if (!expert.filePath && !expert.assetDir) return undefined
|
|
791
|
+
const baseDir = expert.assetDir || path.dirname(expert.filePath)
|
|
792
|
+
return { kind: 'file', path: path.join(baseDir, raw) }
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export function resolveExpertMemberAvatar(expert, member) {
|
|
796
|
+
const raw = String(member?.avatar || '').trim()
|
|
797
|
+
if (!raw) return undefined
|
|
798
|
+
if (/^https?:\/\//i.test(raw)) return { kind: 'remote', url: raw }
|
|
799
|
+
if (raw.includes('..') || raw.startsWith('/')) return undefined
|
|
800
|
+
if (!expert?.filePath && !expert?.assetDir) return undefined
|
|
801
|
+
const baseDir = expert.assetDir || path.dirname(expert.filePath)
|
|
802
|
+
return { kind: 'file', path: path.join(baseDir, raw) }
|
|
318
803
|
}
|