@waterplus-ai/waterbuddy 0.1.38 → 0.1.78

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.
Files changed (71) hide show
  1. package/assets/experts/digital/it-assessment.jpg +0 -0
  2. package/assets/experts/digital/it-assessment.md +42 -0
  3. package/assets/experts/digital/smart-water-assistant.jpg +0 -0
  4. package/assets/experts/digital/smart-water-assistant.md +42 -0
  5. package/assets/experts/digital/smart-water-case-expert.jpg +0 -0
  6. package/assets/experts/digital/smart-water-case-expert.md +45 -0
  7. package/assets/experts/digital/water-industry-brain.jpg +0 -0
  8. package/assets/experts/digital/water-industry-brain.md +46 -0
  9. package/assets/experts/drainage/drainage-sewage-ops.jpg +0 -0
  10. package/assets/experts/drainage/drainage-sewage-ops.md +32 -0
  11. package/assets/experts/drainage/flood-control-pumping.jpg +0 -0
  12. package/assets/experts/drainage/flood-control-pumping.md +42 -0
  13. package/assets/experts/general/article-writing.jpg +0 -0
  14. package/assets/experts/general/article-writing.md +32 -0
  15. package/assets/experts/general/award-application.jpg +0 -0
  16. package/assets/experts/general/award-application.md +32 -0
  17. package/assets/experts/general/benchmarking.jpg +0 -0
  18. package/assets/experts/general/benchmarking.md +42 -0
  19. package/assets/experts/general/data-analysis.jpg +0 -0
  20. package/assets/experts/general/data-analysis.md +32 -0
  21. package/assets/experts/general/evaluation-assistant.jpg +0 -0
  22. package/assets/experts/general/evaluation-assistant.md +32 -0
  23. package/assets/experts/general/event-planning.jpg +0 -0
  24. package/assets/experts/general/event-planning.md +32 -0
  25. package/assets/experts/general/excel-formula.jpg +0 -0
  26. package/assets/experts/general/excel-formula.md +32 -0
  27. package/assets/experts/general/it-coding.jpg +0 -0
  28. package/assets/experts/general/it-coding.md +32 -0
  29. package/assets/experts/general/meeting-minutes.jpg +0 -0
  30. package/assets/experts/general/meeting-minutes.md +32 -0
  31. package/assets/experts/general/summarization.jpg +0 -0
  32. package/assets/experts/general/summarization.md +32 -0
  33. package/assets/experts/general/video-script.jpg +0 -0
  34. package/assets/experts/general/video-script.md +32 -0
  35. package/assets/experts/general/work-report.jpg +0 -0
  36. package/assets/experts/general/work-report.md +32 -0
  37. package/assets/experts/management/admin-party-affairs.jpg +0 -0
  38. package/assets/experts/management/admin-party-affairs.md +42 -0
  39. package/assets/experts/management/group-management-assistant.jpg +0 -0
  40. package/assets/experts/management/group-management-assistant.md +42 -0
  41. package/assets/experts/management/hr-performance.jpg +0 -0
  42. package/assets/experts/management/hr-performance.md +42 -0
  43. package/assets/experts/management/procurement-contract-risk.jpg +0 -0
  44. package/assets/experts/management/procurement-contract-risk.md +32 -0
  45. package/assets/experts/operations/customer-service.jpg +0 -0
  46. package/assets/experts/operations/customer-service.md +32 -0
  47. package/assets/experts/operations/metering-revenue.jpg +0 -0
  48. package/assets/experts/operations/metering-revenue.md +42 -0
  49. package/assets/experts/policy/smart-water-standards.jpg +0 -0
  50. package/assets/experts/policy/smart-water-standards.md +46 -0
  51. package/assets/experts/policy/water-pricing.jpg +0 -0
  52. package/assets/experts/policy/water-pricing.md +46 -0
  53. package/assets/experts/quality/plant-water-quality.jpg +0 -0
  54. package/assets/experts/quality/plant-water-quality.md +32 -0
  55. package/assets/experts/supply/construction-safety.jpg +0 -0
  56. package/assets/experts/supply/construction-safety.md +42 -0
  57. package/assets/experts/supply/energy-equipment-ops.jpg +0 -0
  58. package/assets/experts/supply/energy-equipment-ops.md +32 -0
  59. package/assets/experts/supply/inspection-repair.jpg +0 -0
  60. package/assets/experts/supply/inspection-repair.md +32 -0
  61. package/assets/experts/supply/network-asset-leakage.jpg +0 -0
  62. package/assets/experts/supply/network-asset-leakage.md +42 -0
  63. package/assets/experts/supply/water-source-dispatch.jpg +0 -0
  64. package/assets/experts/supply/water-source-dispatch.md +42 -0
  65. package/lib/client/index.js +1018 -16
  66. package/lib/host/expert-store.js +245 -0
  67. package/lib/host/expert-tools.js +416 -0
  68. package/lib/host/experts.js +318 -0
  69. package/lib/host/index.js +276 -2
  70. package/lib/host/llm-gateway.js +12 -3
  71. package/package.json +9 -4
@@ -0,0 +1,318 @@
1
+ /**
2
+ * 专家名册:把 `assets/experts/<分区>/<slug>.md` 读成可召唤的专家目录。
3
+ *
4
+ * 每份 persona 的正文是**完整的系统提示词**(十几 KB 量级),所以启动时只读
5
+ * frontmatter 做目录,正文留到真正召唤时再读(见 `readExpertPersona`)。
6
+ *
7
+ * 文件格式(frontmatter + 正文,正文即系统提示词):
8
+ *
9
+ * ---
10
+ * name: 供水管网水力建模专家
11
+ * description: 一句话说明这位专家解决什么问题
12
+ * emoji: 🚰
13
+ * division: supply
14
+ * ---
15
+ * # 正文,会作为 persona 注入子代理
16
+ *
17
+ * frontmatter 只支持单行 `key: value`(值可用单/双引号包裹),不支持嵌套与多行,
18
+ * 这样解析器不需要引入 YAML 依赖——本插件是零依赖的纯 JS 包。
19
+ */
20
+ import fs from 'node:fs/promises'
21
+ import path from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+
24
+ /** 分区 slug → 展示名。新增分区只改这一处。 */
25
+ export const EXPERT_DIVISIONS = {
26
+ supply: '供水',
27
+ drainage: '排水',
28
+ quality: '水质',
29
+ digital: '数字化',
30
+ policy: '政策标准',
31
+ operations: '运营服务',
32
+ management: '集团管理',
33
+ general: '通用办公',
34
+ }
35
+
36
+ /**
37
+ * 分区目录名与 persona slug 只允许这种形状:小写字母/数字,用连字符分隔。
38
+ * `..`、`/`、盘符、大小写混排一律不合法,因此拿 slug 拼路径不会越出名册目录。
39
+ */
40
+ export const EXPERT_PATH_SEGMENT_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
41
+
42
+ /** frontmatter 的起始读取量与上限:正常一两百字节就够,异常文件也不至于读爆内存。 */
43
+ const FRONTMATTER_HEAD_BYTES = 1024
44
+ const FRONTMATTER_MAX_BYTES = 64 * 1024
45
+
46
+ /** 内置名册目录,与本模块同包分发。 */
47
+ export const DEFAULT_EXPERTS_DIR = fileURLToPath(new URL('../../assets/experts', import.meta.url))
48
+
49
+ /** 去掉 BOM:带 BOM 时行首匹配不到 `---`,整份 frontmatter 都会读不出来。 */
50
+ export function stripBom(text) {
51
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
52
+ }
53
+
54
+ /**
55
+ * 消毒 persona,使其能安全穿过系统提示词的严格 `{{…}}` 插值。
56
+ *
57
+ * 提示词装配对每个 section 跑严格插值,正文里只要出现完整花括号组就会抛错;
58
+ * 这里在相邻的两个 `{` 之间插一个零宽空格(U+200B),插值器就看不见 `{{` 了,
59
+ * 而视觉上完全不变。三连 `{{{` 需要插两处,`replace` 的全局匹配天然覆盖。
60
+ *
61
+ * @param text - persona 正文。
62
+ * @returns 插值安全的等长(零宽字符会让码点变长,显示长度不变)文本。
63
+ */
64
+ export function sanitizePersona(text) {
65
+ return String(text).replace(/\{(?=\{)/g, '{\u200B')
66
+ }
67
+
68
+ /**
69
+ * 读文件头,直到出现 frontmatter 结束标记或到达上限。
70
+ *
71
+ * 只读头部是为了让「几百份 persona 的目录」不必把正文全读进内存。
72
+ * @param filePath - persona 文件绝对路径。
73
+ * @returns 文件头部文本(可能不含结束标记,由调用方判定)。
74
+ */
75
+ async function readFrontmatterHead(filePath) {
76
+ const handle = await fs.open(filePath, 'r')
77
+ try {
78
+ const chunks = []
79
+ let total = 0
80
+ let offset = 0
81
+ while (total < FRONTMATTER_MAX_BYTES) {
82
+ const size = Math.min(FRONTMATTER_HEAD_BYTES, FRONTMATTER_MAX_BYTES - total)
83
+ const buffer = Buffer.allocUnsafe(size)
84
+ const { bytesRead } = await handle.read(buffer, 0, size, offset)
85
+ if (bytesRead === 0) break
86
+ chunks.push(buffer.subarray(0, bytesRead))
87
+ total += bytesRead
88
+ offset += bytesRead
89
+ // 结束标记必须在头部内:读到就停,避免把正文读进来。
90
+ if (Buffer.concat(chunks).toString('utf8').includes('\n---')) break
91
+ }
92
+ return Buffer.concat(chunks).toString('utf8')
93
+ } finally {
94
+ await handle.close()
95
+ }
96
+ }
97
+
98
+ /**
99
+ * 解析 persona 的 frontmatter。
100
+ *
101
+ * @param text - 文件文本(至少包含 frontmatter)。
102
+ * @returns 键值映射;没有 frontmatter 时返回空对象。
103
+ */
104
+ export function parseExpertFrontmatter(text) {
105
+ const lines = stripBom(String(text)).split(/\r?\n/)
106
+ if (lines[0]?.trim() !== '---') return {}
107
+ const fields = {}
108
+ for (let index = 1; index < lines.length; index += 1) {
109
+ const line = lines[index]
110
+ if (line.trim() === '---') return fields
111
+ const separator = line.indexOf(':')
112
+ if (separator <= 0) continue
113
+ const key = line.slice(0, separator).trim()
114
+ let value = line.slice(separator + 1).trim()
115
+ if (
116
+ value.length >= 2
117
+ && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
118
+ ) {
119
+ value = value.slice(1, -1)
120
+ }
121
+ if (key) fields[key] = value
122
+ }
123
+ // 没有闭合的 `---`:frontmatter 不完整,按"没有"处理,由调用方报缺字段。
124
+ return {}
125
+ }
126
+
127
+ /** 去掉 frontmatter,返回正文。 */
128
+ export function expertBody(text) {
129
+ const lines = stripBom(String(text)).split(/\r?\n/)
130
+ if (lines[0]?.trim() !== '---') return String(text).trim()
131
+ for (let index = 1; index < lines.length; index += 1) {
132
+ if (lines[index].trim() === '---') return lines.slice(index + 1).join('\n').trim()
133
+ }
134
+ return ''
135
+ }
136
+
137
+ /**
138
+ * 扫描名册目录,只读 frontmatter 建立目录。
139
+ *
140
+ * 分区顺序按 `EXPERT_DIVISIONS` 的声明顺序(而非 `readdir` 的返回顺序),
141
+ * 专家在分区内按 slug 排序,保证同一次构建的目录顺序稳定。
142
+ *
143
+ * @param rootDir - 名册根目录,默认 {@link DEFAULT_EXPERTS_DIR}。
144
+ * @returns `{ divisions, bySlug }`;`divisions` 是 `[{ slug, name, experts }]`。
145
+ * @throws 当文件名不合法、缺必填字段或 slug 重复时——名册是自己发布的资产,
146
+ * 坏文件必须显式失败,不能悄悄少一个专家。
147
+ */
148
+ export async function loadExpertCatalog(rootDir = DEFAULT_EXPERTS_DIR) {
149
+ const divisions = []
150
+ const bySlug = new Map()
151
+ let divisionEntries = []
152
+ try {
153
+ divisionEntries = await fs.readdir(rootDir, { withFileTypes: true })
154
+ } catch (error) {
155
+ if (error.code === 'ENOENT') return { divisions, bySlug }
156
+ throw error
157
+ }
158
+
159
+ for (const entry of divisionEntries) {
160
+ if (!entry.isDirectory() || !EXPERT_PATH_SEGMENT_PATTERN.test(entry.name)) continue
161
+ const divisionSlug = entry.name
162
+ const experts = []
163
+ const files = (await fs.readdir(path.join(rootDir, divisionSlug))).filter(name => name.endsWith('.md')).sort()
164
+ for (const file of files) {
165
+ const slug = file.slice(0, -3)
166
+ if (!EXPERT_PATH_SEGMENT_PATTERN.test(slug)) {
167
+ throw new Error(`EXPERT_SLUG_INVALID: ${divisionSlug}/${file} 只允许小写字母数字与连字符`)
168
+ }
169
+ const filePath = path.join(rootDir, divisionSlug, file)
170
+ const fields = parseExpertFrontmatter(await readFrontmatterHead(filePath))
171
+ const name = (fields.name || '').trim()
172
+ const description = (fields.description || '').trim()
173
+ if (!name || !description) {
174
+ throw new Error(`EXPERT_FRONTMATTER_INCOMPLETE: ${divisionSlug}/${file} 缺少 name 或 description`)
175
+ }
176
+ if (bySlug.has(slug)) {
177
+ throw new Error(`EXPERT_SLUG_DUPLICATE: ${slug} 在名册中重复`)
178
+ }
179
+ if (fields.division && fields.division !== divisionSlug) {
180
+ throw new Error(
181
+ `EXPERT_DIVISION_MISMATCH: ${divisionSlug}/${file} 的 division=${fields.division} 与目录不一致`,
182
+ )
183
+ }
184
+ const expert = {
185
+ slug,
186
+ division: divisionSlug,
187
+ name,
188
+ description,
189
+ emoji: (fields.emoji || '').trim(),
190
+ // 头像图片:http(s) URL 直接用;否则当作与本 md 同目录的文件名,
191
+ // 由 `/api/waterbuddy/experts/avatar` 按需吐出来(不塞进名册快照)。
192
+ avatar: (fields.avatar || '').trim(),
193
+ filePath,
194
+ custom: false,
195
+ }
196
+ experts.push(expert)
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
+ })
205
+ }
206
+ }
207
+
208
+ return { divisions, bySlug }
209
+ }
210
+
211
+ /**
212
+ * 读取一位专家的原始正文(未消毒),供界面展示。
213
+ *
214
+ * 展示用原文,注入子代理用 {@link readExpertPersona}——后者插了零宽空格,虽然肉眼不可见,
215
+ * 但不该混进界面里被用户复制走的文本。
216
+ *
217
+ * @param expert - {@link loadExpertCatalog} 产出的条目。
218
+ * @returns persona 正文。
219
+ */
220
+ export async function readExpertBody(expert) {
221
+ // 自定义专家的正文来自 settings,没有文件可读。
222
+ if (typeof expert.body === 'string') return expert.body
223
+ return expertBody(await fs.readFile(expert.filePath, 'utf8'))
224
+ }
225
+
226
+ /**
227
+ * 把自定义专家并进内置名册,产出一份新名册(不改原对象)。
228
+ *
229
+ * slug 冲突时**内置优先**:内置专家的 slug 是自己发布的资产,不能让一条自定义数据把它顶掉,
230
+ * 否则删掉自定义项后重启会换回内置内容,行为难以解释。
231
+ *
232
+ * @param catalog - 内置名册。
233
+ * @param customExperts - `{ slug, division, name, description, emoji, prompt }` 数组。
234
+ * @returns 合并后的名册(结构与 {@link loadExpertCatalog} 一致)。
235
+ */
236
+ export function mergeCustomExperts(catalog, customExperts) {
237
+ if (!Array.isArray(customExperts) || customExperts.length === 0) return catalog
238
+ const bySlug = new Map(catalog.bySlug)
239
+ const divisions = catalog.divisions.map(division => ({ ...division, experts: [...division.experts] }))
240
+ const byDivision = new Map(divisions.map(division => [division.slug, division]))
241
+ for (const custom of customExperts) {
242
+ if (!custom || bySlug.has(custom.slug)) continue
243
+ const expert = {
244
+ slug: custom.slug,
245
+ division: custom.division,
246
+ name: custom.name,
247
+ description: custom.description,
248
+ emoji: custom.emoji,
249
+ avatar: custom.avatar || '',
250
+ body: custom.prompt,
251
+ custom: true,
252
+ }
253
+ bySlug.set(expert.slug, expert)
254
+ const division = byDivision.get(expert.division)
255
+ if (division) {
256
+ division.experts.push(expert)
257
+ } else {
258
+ const created = {
259
+ slug: expert.division,
260
+ name: EXPERT_DIVISIONS[expert.division] || expert.division,
261
+ experts: [expert],
262
+ }
263
+ byDivision.set(expert.division, created)
264
+ divisions.push(created)
265
+ }
266
+ }
267
+ return { divisions, bySlug }
268
+ }
269
+
270
+ /**
271
+ * 所有专家共用的**交付纪律**,注入 persona 时前置。
272
+ *
273
+ * 模型天然会把"检索过程"和"内部存疑"一起写进产出:实测用户拿到的成品文档里出现过
274
+ * `getThemeTree` / `searchPolicies` / `modelId=11` 这类内部细节,以及"这段是 AI 扩写的"
275
+ * "某接口鉴权失败"这类不该给业务方看的判断。这些属于过程与内部信息,不是交付物。
276
+ * 统一在这里前置一次,避免 32 份 persona 各写一遍、也避免新加专家时漏掉。
277
+ */
278
+ export const EXPERT_OUTPUT_RULES = [
279
+ '## 交付纪律(对所有产出生效)',
280
+ '',
281
+ '- 产出是给**业务用户**看的。**不要**出现工具名、接口名、参数名、模型 ID、知识库 ID、',
282
+ ' 文件路径、命令,或任何检索/调用步骤(例如 getThemeTree、searchPolicies、getCaseDetail、',
283
+ ' listKbNodesTree、modelId=11、kbIds 一律不写)。',
284
+ '- **不要**把内部判断写进产出(例如"这段是 AI 扩写的""某个接口鉴权失败""未能核验")。',
285
+ ' 需要提示可靠性时,只用一句业务语言,例如"部分数据来自公开报道,建议以官方发布为准"。',
286
+ ' 技术性的说明留在**对话回复**里,不要写进交付的文件。',
287
+ '- 引用来源写**业务名称**(如"水务加案例库""住建部 2025 年典型案例名单"),不写工具名。',
288
+ '- 产出要能直接拿去用:先给结论与依据;不要过程日志,也不要复述收到的任务。',
289
+ '- 检索不到或不确定的内容,宁可不写,也不要用推测补全。',
290
+ '',
291
+ ].join('\n')
292
+
293
+ /**
294
+ * 读取一位专家的完整 persona 正文(已消毒),供注入子代理。
295
+ *
296
+ * @param expert - {@link loadExpertCatalog} 产出的条目。
297
+ * @returns 可直接作为 `persona` 传入的文本。
298
+ */
299
+ export async function readExpertPersona(expert) {
300
+ const body = await readExpertBody(expert)
301
+ return sanitizePersona(`${EXPERT_OUTPUT_RULES}\n${body}`)
302
+ }
303
+
304
+ /**
305
+ * 解析一位专家的头像图片路径。
306
+ *
307
+ * @param expert - 名册条目。
308
+ * @returns `{ kind: 'remote', url }` 表示直接用外链;`{ kind: 'file', path }` 表示本地文件
309
+ * (相对路径相对该专家的 md 所在目录);`undefined` 表示没有配头像。
310
+ */
311
+ export function resolveExpertAvatar(expert) {
312
+ const raw = String(expert?.avatar || '').trim()
313
+ if (!raw) return undefined
314
+ if (/^https?:\/\//i.test(raw)) return { kind: 'remote', url: raw }
315
+ if (raw.includes('..') || raw.startsWith('/')) return undefined
316
+ if (!expert.filePath) return undefined
317
+ return { kind: 'file', path: path.join(path.dirname(expert.filePath), raw) }
318
+ }
package/lib/host/index.js CHANGED
@@ -3,6 +3,9 @@ import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { WATERPLUS_PERSONA } from '../shared/persona.js'
6
+ import { createExpertStore, isExpertEnabled } from './expert-store.js'
7
+ import { createExpertToolDefinitions } from './expert-tools.js'
8
+ import { EXPERT_DIVISIONS, loadExpertCatalog, mergeCustomExperts, readExpertBody, resolveExpertAvatar } from './experts.js'
6
9
  import { bindLlmCredential, describeLlmCredential, ensureLlmCredential, formatBalanceText, isGatewayActive, readActiveProvider, refreshLlmQuota, restoreDeviceLlmCredential, switchDefaultProviderWithRetry } from './llm-gateway.js'
7
10
 
8
11
  const WATERPLUS_ICON = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
@@ -380,12 +383,278 @@ export async function bootstrapLlmCredential(ctx, config) {
380
383
  return llmStatus || null
381
384
  }
382
385
 
383
- export const inject = ['webServer', 'systemPrompt', 'credentials', 'settings']
386
+ /**
387
+ * 安全读取宿主服务:未注入时 cordis 的访问可能抛错而不是返回 undefined,
388
+ * 而专家是**附加能力**,不能因为拿不到服务就把整个插件挡在启动之外。
389
+ * @param ctx - 插件上下文。
390
+ * @param name - 服务名。
391
+ * @returns 服务实例,取不到时返回 `undefined`。
392
+ */
393
+ function readService(ctx, name) {
394
+ try {
395
+ return ctx?.[name]
396
+ } catch {
397
+ return undefined
398
+ }
399
+ }
400
+
401
+ /**
402
+ * 专家模式的系统提示词段。
403
+ *
404
+ * 只列分区与数量,不列具体专家——名册可能很大,逐个列会把提示词撑爆;
405
+ * 模型需要时自己调 `list_experts`。
406
+ * @param catalog - 已加载的名册。
407
+ * @returns 提示词文本(不含 `{{`,避免插值抛错)。
408
+ */
409
+ function expertPromptSection(catalog) {
410
+ const divisions = catalog.divisions
411
+ .map(division => `- ${division.slug}(${division.name}):${division.experts.length} 位`)
412
+ .join('\n')
413
+ return [
414
+ '## 专家召唤模式',
415
+ '',
416
+ '本会话有一份水务行业专家名册,专家是可召唤的子代理(各自带该领域的系统提示词)。',
417
+ '',
418
+ '**关键:用户在输入框里选中的专家会以「引用 chip」的形式出现在消息里,显示为 @专家名;'
419
+ + '消息中剩下的文字就是交给这位专家的任务。** 看到这种 @引用时,按名字直接召唤该专家'
420
+ + '(先 `list_experts` 确认可用,再 `summon_expert`),**不要**把它当成文件路径、工作区引用'
421
+ + '或需要检索的文档——那会白白翻一遍工作区。',
422
+ '',
423
+ '工具用法:',
424
+ '- `list_experts`:不传参数只回分区与数量;传 division 才展开该分区的专家',
425
+ '- `summon_expert(expert, task)`:召唤一位专家,task 里写清背景、已知条件、要交付的东西',
426
+ '- `summon_experts`:并行召唤多位(最多 8 位),适合需要多领域视角的任务',
427
+ '',
428
+ '用户没有指定专家、但任务明显需要某个领域的深度分析时,你也可以主动召唤。',
429
+ '',
430
+ `名册共 ${catalog.bySlug.size} 位专家,分 ${catalog.divisions.length} 个方向:`,
431
+ divisions,
432
+ '',
433
+ '专家返回的是独立分析,仍需你结合上下文判断后再交付给用户。',
434
+ ].join('\n')
435
+ }
436
+
437
+ /**
438
+ * 专家名册的只读路由,供设置页浏览。
439
+ *
440
+ * 用 `?slug=` 取单个专家的正文,而不是再开一条路径:一条 GET 不值得引入路径解析。
441
+ * 展示返回**未消毒**的原文(见 `readExpertBody`),零宽空格不该出现在用户能复制的文本里。
442
+ *
443
+ * @param catalog - 名册 Promise,与工具共用同一份。
444
+ * @returns 路由定义数组。
445
+ */
446
+ /** 插件自身版本号,供设置页展示(单一出处:package.json)。 */
447
+ let cachedPluginVersion
448
+ function pluginVersion() {
449
+ if (cachedPluginVersion === undefined) {
450
+ try {
451
+ cachedPluginVersion = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || ''
452
+ } catch {
453
+ cachedPluginVersion = ''
454
+ }
455
+ }
456
+ return cachedPluginVersion
457
+ }
458
+
459
+ function expertRoutes(snapshot, store) {
460
+ /** 把一位专家压成界面需要的形状(含当前启用状态与是否自定义)。 */
461
+ const brief = (expert, state) => ({
462
+ slug: expert.slug,
463
+ name: expert.name,
464
+ emoji: expert.emoji,
465
+ description: expert.description,
466
+ division: expert.division,
467
+ divisionName: EXPERT_DIVISIONS[expert.division] || expert.division,
468
+ custom: expert.custom === true,
469
+ enabled: isExpertEnabled(state, expert.slug),
470
+ // 外链直接用;本地图片走我们的取图路由(按需读、带缓存,不塞进快照)。
471
+ avatar: expert.avatar
472
+ ? (resolveExpertAvatar(expert)?.kind === 'remote'
473
+ ? expert.avatar
474
+ : '/api/waterbuddy/experts/avatar?slug=' + encodeURIComponent(expert.slug))
475
+ : '',
476
+ })
477
+
478
+ /** 统一把异常收敛成 JSON:带错误码的校验失败返回 400,其余 502。 */
479
+ const guard = handler => async (req, res) => {
480
+ try {
481
+ await handler(req, res)
482
+ } catch (error) {
483
+ const code = /^(EXPERT_[A-Z_]+):/.exec(error.message)?.[1]
484
+ json(res, code ? 400 : 502, { error: error.message })
485
+ }
486
+ }
487
+
488
+ const readBody = async req => {
489
+ try {
490
+ return await readJsonBody(req)
491
+ } catch {
492
+ throw new Error('EXPERT_BODY_INVALID: 请求参数格式不正确')
493
+ }
494
+ }
495
+
496
+ return [
497
+ {
498
+ kind: 'exact',
499
+ path: '/api/waterbuddy/experts',
500
+ handler: guard(async (req, res) => {
501
+ if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' })
502
+ const slug = new URL(req.url, 'http://127.0.0.1').searchParams.get('slug')
503
+ const { catalog, state } = await snapshot()
504
+ if (slug) {
505
+ const expert = catalog.bySlug.get(slug)
506
+ if (!expert) return json(res, 404, { error: 'expert-not-found' })
507
+ // 展示返回未消毒的原文:零宽空格不该出现在用户能复制走的文本里。
508
+ return json(res, 200, { expert: brief(expert, state), body: await readExpertBody(expert) })
509
+ }
510
+ const experts = [...catalog.bySlug.values()].map(expert => brief(expert, state))
511
+ json(res, 200, {
512
+ experts,
513
+ divisions: catalog.divisions.map(division => ({
514
+ slug: division.slug,
515
+ name: division.name,
516
+ count: division.experts.length,
517
+ })),
518
+ enabledCount: experts.filter(expert => expert.enabled).length,
519
+ revision: state.revision,
520
+ // 版本号由宿主下发:客户端不该自己硬编码一份会漂移的副本。
521
+ version: pluginVersion(),
522
+ })
523
+ }),
524
+ },
525
+ {
526
+ kind: 'exact',
527
+ path: '/api/waterbuddy/experts/avatar',
528
+ handler: guard(async (req, res) => {
529
+ if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' })
530
+ const slug = new URL(req.url, 'http://127.0.0.1').searchParams.get('slug') || ''
531
+ const { catalog } = await snapshot()
532
+ const expert = catalog.bySlug.get(slug)
533
+ const avatar = expert ? resolveExpertAvatar(expert) : undefined
534
+ if (!avatar) return json(res, 404, { error: 'avatar-not-found' })
535
+ if (avatar.kind === 'remote') return json(res, 400, { error: 'avatar-is-remote' })
536
+ const extension = (avatar.path.match(/\.[a-z0-9]+$/i) || [''])[0].toLowerCase()
537
+ const types = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml' }
538
+ const type = types[extension]
539
+ if (!type) return json(res, 415, { error: 'avatar-type-unsupported' })
540
+ let bytes
541
+ try { bytes = await fs.promises.readFile(avatar.path) } catch { return json(res, 404, { error: 'avatar-not-found' }) }
542
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'public, max-age=3600' })
543
+ res.end(bytes)
544
+ }),
545
+ },
546
+ {
547
+ kind: 'exact',
548
+ path: '/api/waterbuddy/experts/enabled',
549
+ handler: guard(async (req, res) => {
550
+ if (req.method !== 'POST') return json(res, 405, { error: 'method-not-allowed' })
551
+ const body = await readBody(req)
552
+ await store.replaceEnabled(body.enabled, body.expectedRevision)
553
+ const { catalog, state } = await snapshot()
554
+ const experts = [...catalog.bySlug.values()].map(expert => brief(expert, state))
555
+ json(res, 200, {
556
+ experts,
557
+ enabledCount: experts.filter(expert => expert.enabled).length,
558
+ revision: state.revision,
559
+ })
560
+ }),
561
+ },
562
+ {
563
+ kind: 'exact',
564
+ path: '/api/waterbuddy/experts/custom',
565
+ handler: guard(async (req, res) => {
566
+ if (req.method !== 'POST') return json(res, 405, { error: 'method-not-allowed' })
567
+ const body = await readBody(req)
568
+ const state = await store.saveCustom(body.expert, body.expectedRevision)
569
+ const { catalog } = await snapshot()
570
+ const experts = [...catalog.bySlug.values()].map(expert => brief(expert, state))
571
+ json(res, 200, {
572
+ experts,
573
+ enabledCount: experts.filter(expert => expert.enabled).length,
574
+ revision: state.revision,
575
+ })
576
+ }),
577
+ },
578
+ {
579
+ kind: 'exact',
580
+ path: '/api/waterbuddy/experts/custom/delete',
581
+ handler: guard(async (req, res) => {
582
+ if (req.method !== 'POST') return json(res, 405, { error: 'method-not-allowed' })
583
+ const body = await readBody(req)
584
+ const state = await store.deleteCustom(String(body.slug || ''), body.expectedRevision)
585
+ const { catalog } = await snapshot()
586
+ const experts = [...catalog.bySlug.values()].map(expert => brief(expert, state))
587
+ json(res, 200, {
588
+ experts,
589
+ enabledCount: experts.filter(expert => expert.enabled).length,
590
+ revision: state.revision,
591
+ })
592
+ }),
593
+ },
594
+ ]
595
+ }
596
+
597
+ function setupExpertTools(ctx, config, { getCatalog, getEnabled }) {
598
+ const tools = readService(ctx, 'tools')
599
+ const subagents = readService(ctx, 'subagents')
600
+ if (!tools?.register || !subagents) {
601
+ console.warn('[WaterBuddy] experts disabled: host exposes no tools/subagents service')
602
+ return []
603
+ }
604
+
605
+ // 名册就绪前提示词段先留空,下一轮请求就有了。
606
+ let loaded
607
+ void getCatalog().then(value => { loaded = value }).catch(() => {})
608
+
609
+ const disposers = []
610
+ try {
611
+ for (const definition of createExpertToolDefinitions({
612
+ getCatalog,
613
+ getEnabled,
614
+ config,
615
+ subagents,
616
+ })) {
617
+ disposers.push(tools.register(definition))
618
+ }
619
+ disposers.push(ctx.systemPrompt.section({
620
+ name: 'waterplus:experts',
621
+ order: 110,
622
+ text: context => {
623
+ // 专家分身自己不该再被引导去召唤专家(与工具侧的 toolFilter 双保险)。
624
+ if (context?.agent?.session?.header?.parentSession !== undefined) return ''
625
+ return loaded ? expertPromptSection(loaded) : ''
626
+ },
627
+ }))
628
+ } catch (error) {
629
+ console.warn('[WaterBuddy] expert tools registration failed:', error.message)
630
+ for (const dispose of disposers) {
631
+ try { dispose() } catch { /* 已失败,回滚本身不再抛 */ }
632
+ }
633
+ return []
634
+ }
635
+ return disposers
636
+ }
637
+
638
+ export const inject = ['webServer', 'systemPrompt', 'credentials', 'settings', 'tools', 'subagents']
384
639
  export { normalizeAvatar, setUpstreamTransport }
385
640
 
386
641
  export function apply(ctx, config) {
387
642
  readPersistedAuth()
388
643
  ctx.effect(() => {
644
+ // 内置名册读一次(异步、失败只记日志);自定义专家与启用状态存在 settings 里,
645
+ // 每次快照时合并,所以用户在设置页改完立刻生效,不需要重启。
646
+ const builtinCatalog = loadExpertCatalog(config?.expertsDir || undefined)
647
+ builtinCatalog.catch(error => console.warn('[WaterBuddy] expert catalog failed to load:', error.message))
648
+ // 状态存在 $DSH_HOME/waterbuddy-experts.json(`expertsStateFile` 可覆盖,测试用临时文件)。
649
+ // 名册加载完成后记下全部 slug,供 store 判断白名单是否来自上一代名册。
650
+ let knownExpertSlugs
651
+ const expertStore = createExpertStore({ file: config?.expertsStateFile, knownSlugs: () => knownExpertSlugs })
652
+ const expertSnapshot = async () => {
653
+ const state = expertStore.read()
654
+ const catalog = mergeCustomExperts(await builtinCatalog, state.customExperts)
655
+ knownExpertSlugs = new Set(catalog.bySlug.keys())
656
+ return { catalog, state }
657
+ }
389
658
  const routes = [
390
659
  { kind: 'exact', path: '/waterplus-mark.png', handler: (_req, res) => { res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'public, max-age=3600' }); res.end(WATERPLUS_ICON) } },
391
660
  { kind: 'exact', path: '/manifest.webmanifest', handler: (_req, res) => { res.writeHead(200, { 'content-type': 'application/manifest+json; charset=utf-8', 'cache-control': 'public, max-age=3600' }); res.end(MANIFEST) } },
@@ -422,16 +691,21 @@ export function apply(ctx, config) {
422
691
  { kind: 'exact', path: '/api/waterbuddy/llm/refresh', handler: async (req, res) => { if (req.method !== 'POST') return json(res, 405, { error: 'method-not-allowed' }); try { json(res, 200, { llm: await bootstrapLlmCredential(ctx, config) }) } catch (error) { json(res, 502, { error: error.message }) } } },
423
692
  { kind: 'exact', path: '/api/waterbuddy/logout', handler: async (_req, res) => { currentUser = undefined; for (const session of sessions.values()) delete session.token; clearPersistedAuth(); if (llmLogoutHook) { try { await withTimeout(Promise.resolve().then(() => llmLogoutHook()), LLM_SWAP_TIMEOUT_MS, 'llm-unbind') } catch (error) { console.warn('[WaterBuddy] llm restore after logout failed:', error.message) } } json(res, 200, { ok: true }) } },
424
693
  { kind: 'prefix', path: '/api/waterbuddy/mcp', handler: async (req, res) => { try { await proxyMcp(req, res) } catch (error) { json(res, 502, { error: error.message }) } } },
694
+ ...expertRoutes(expertSnapshot, expertStore),
425
695
  ]
426
696
  const disposers = routes.map(route => ctx.webServer.register(route))
427
697
  const disposeTitle = ctx.webServer.tapIndex(html => html.replace(/<title>[^<]*<\/title>/i, '<title>WaterBuddy</title>').replace(/<link[^>]+rel=["']icon["'][^>]*>/i, '<link rel="icon" type="image/png" href="/waterplus-mark.png" />'))
428
698
  const disposePersona = ctx.systemPrompt.section({ name: 'waterplus:persona', order: 100, text: WATERPLUS_PERSONA })
699
+ const expertDisposers = setupExpertTools(ctx, config, {
700
+ getCatalog: async () => (await expertSnapshot()).catalog,
701
+ getEnabled: () => expertStore.read().enabled,
702
+ })
429
703
  // 尽力而为:宿主没有 credentials 服务(例如测试替身)时直接跳过,不发网络请求。
430
704
  if (ctx.credentials?.set) {
431
705
  llmLoginHook = token => bindLlmCredential(ctx, token, { config })
432
706
  llmLogoutHook = () => restoreDeviceLlmCredential(ctx, { config })
433
707
  void bootstrapLlmCredential(ctx, config)
434
708
  }
435
- return () => { for (const dispose of disposers) dispose(); disposeTitle(); disposePersona(); sessions.clear(); currentUser = undefined; persistedAuth = undefined }
709
+ return () => { for (const dispose of disposers) dispose(); disposeTitle(); disposePersona(); for (const dispose of expertDisposers) dispose(); sessions.clear(); currentUser = undefined; persistedAuth = undefined }
436
710
  }, 'waterbuddy: identity and auth')
437
711
  }
@@ -26,7 +26,7 @@ export const LOW_BALANCE_HINT = 0
26
26
 
27
27
  /**
28
28
  * 额度以美元记账(sub2api 的 `user.balance` 单位,见 SUB2API-TOKEN-QUOTA-PLAN §19.1),
29
- * 但产品按人民币向用户交代额度,所以**只折算展示文本**:`balance` 字段保持美元,
29
+ * 但产品按**积分**向用户交代额度,所以**只折算展示文本**:`balance` 字段保持美元,
30
30
  * `lowBalance` / `exhausted` 的阈值比较依赖美元口径,不能被折算污染。
31
31
  *
32
32
  * 默认汇率取 DeepSeek 官方两套价目表的隐含汇率(Flash $0.15↔¥1 = 6.67;
@@ -34,17 +34,26 @@ export const LOW_BALANCE_HINT = 0
34
34
  */
35
35
  export const DEFAULT_USD_TO_CNY_RATE = 6.8
36
36
 
37
+ /** 积分口径:1 元 = 100 积分(产品定义)。 */
38
+ export const POINTS_PER_YUAN = 100
39
+
37
40
  /** 展示汇率:插件 config 优先,缺失或非法时回落默认值。 */
38
41
  export function resolveUsdToCnyRate(config) {
39
42
  const rate = Number(config?.balanceUsdToCnyRate)
40
43
  return Number.isFinite(rate) && rate > 0 ? rate : DEFAULT_USD_TO_CNY_RATE
41
44
  }
42
45
 
43
- /** 把美元余额折算成人民币展示文本。只用于展示,不参与任何计费或阈值判断。 */
46
+ /**
47
+ * 把美元余额折算成积分展示文本:美元 → 人民币 → ×`POINTS_PER_YUAN`,取整。
48
+ *
49
+ * 只用于展示,不参与任何计费或阈值判断。取整会带来不足 1 积分(< ¥0.01)的误差,
50
+ * 显示上不可见;额度是否耗尽仍由 `exhausted` 决定,不靠这里显示成 0 来判断。
51
+ */
44
52
  export function formatBalanceText(balanceUsd, config) {
45
53
  const amount = Number(balanceUsd)
46
54
  const usd = Number.isFinite(amount) ? amount : 0
47
- return `¥${(usd * resolveUsdToCnyRate(config)).toFixed(2)}`
55
+ const points = Math.round(usd * resolveUsdToCnyRate(config) * POINTS_PER_YUAN)
56
+ return `${points} 积分`
48
57
  }
49
58
 
50
59
  const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/