@waterplus-ai/waterbuddy 0.1.21 → 0.1.34

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.
@@ -415,9 +415,10 @@ window.__ModuleLoader__.load({
415
415
  }
416
416
 
417
417
  function UserAction({ wide }) {
418
- const { useEffect, useState } = require('react')
418
+ const { useEffect, useRef, useState } = require('react')
419
419
  const [open, setOpen] = useState(false)
420
420
  const [user, setUser] = useState(null)
421
+ const [llm, setLlm] = useState(null)
421
422
  const [session, setSession] = useState({ qrCode: '', status: '', error: '', loading: false })
422
423
 
423
424
  useEffect(() => {
@@ -428,6 +429,21 @@ window.__ModuleLoader__.load({
428
429
  return () => { cancelled = true }
429
430
  }, [])
430
431
 
432
+ // 额度轮询:依赖 user,登录/登出后会立刻重新取值(Host 此时已换过 key)。
433
+ useEffect(() => {
434
+ let cancelled = false
435
+ const load = async () => {
436
+ try {
437
+ const response = await fetch('/api/waterbuddy/llm/status')
438
+ const body = await response.json()
439
+ if (!cancelled) setLlm(body.llm || null)
440
+ } catch (_) {}
441
+ }
442
+ load()
443
+ const timer = setInterval(load, 15000)
444
+ return () => { cancelled = true; clearInterval(timer) }
445
+ }, [user])
446
+
431
447
  const startLogin = async () => {
432
448
  setSession({ qrCode: '', status: '二维码加载中', error: '', loading: true })
433
449
  try {
@@ -469,9 +485,39 @@ window.__ModuleLoader__.load({
469
485
  }, [open, session.sessionId, user])
470
486
 
471
487
  const openPanel = () => { setOpen(true); if (!user && !session.sessionId) startLogin() }
488
+ // 额度耗尽是**主动**提示:模型调用由 Harness 自己发起,插件看不到那次 403
489
+ // (harness 还会把它归类成「API 密钥无效」,文案完全误导),只能靠额度轮询
490
+ // 自己发现并把登录面板推给用户。
491
+ //
492
+ // 必须走 openPanel 而不是裸 setOpen:裸开会得到一个「二维码暂不可用」的空面板,
493
+ // 因为扫码会话根本没发起(实测踩过)。
494
+ const exhaustedNotified = useRef(false)
495
+ useEffect(() => {
496
+ if (!llm || llm.managesQuota !== true || user) {
497
+ exhaustedNotified.current = false
498
+ return
499
+ }
500
+ if (llm.exhausted === true && !exhaustedNotified.current) {
501
+ exhaustedNotified.current = true
502
+ openPanel()
503
+ return
504
+ }
505
+ if (llm.exhausted !== true) exhaustedNotified.current = false
506
+ }, [llm, user])
472
507
  const logout = async () => { await fetch('/api/waterbuddy/logout', { method: 'POST' }); setUser(null); setOpen(false); setSession({ qrCode: '', status: '', error: '', loading: false }) }
473
508
  const name = userName(user)
474
509
  const buttonStyle = { borderRadius: 10, padding: '9px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer' }
510
+ // 余额单位是 sub2api 的美元,不是人民币(见 SUB2API-TOKEN-QUOTA-PLAN §2)。
511
+ const balanceText = `$${Number(llm?.balance || 0).toFixed(2)}`
512
+ // 用户把默认模型换成别人的提供方时,消耗的不是我们的额度,一律不展示额度。
513
+ const managesQuota = llm?.managesQuota === true
514
+ const exhausted = managesQuota && llm?.exhausted === true
515
+ // 未登录时展示试用额度,登录后展示账号额度;耗尽一律优先提示
516
+ const subtitle = !managesQuota
517
+ ? (user ? '水务之窗' : '扫码或账号登录')
518
+ : exhausted
519
+ ? '额度已用完'
520
+ : (user ? `额度 ${balanceText}` : `试用额度 ${balanceText}`)
475
521
 
476
522
  function userPanel() {
477
523
  const account = user?.loginId || (user?.id ? `${String(user.id).slice(0, 8)}…` : '水务之窗账号')
@@ -495,6 +541,10 @@ window.__ModuleLoader__.load({
495
541
  jsx('small', { style: { color: '#8a94a6', fontSize: 11 }, children: '身份' }),
496
542
  jsx('div', { style: { marginTop: 4, color: '#334155', fontSize: 13, fontWeight: 600 }, children: isAdmin ? '管理员' : '内部成员' }),
497
543
  ] }),
544
+ managesQuota ? jsxs('div', { style: { padding: '12px 14px', borderRadius: 12, background: exhausted ? '#fff7ed' : '#f5f8fc' }, children: [
545
+ jsx('small', { style: { color: '#8a94a6', fontSize: 11 }, children: '剩余额度' }),
546
+ jsx('div', { style: { marginTop: 4, color: exhausted ? '#b45309' : '#334155', fontSize: 13, fontWeight: 600 }, children: balanceText }),
547
+ ] }) : null,
498
548
  ] }),
499
549
  jsx('button', { type: 'button', onClick: logout, style: { ...buttonStyle, width: '100%', marginTop: 18, border: '1px solid #d9e1ec', background: '#fff', color: '#475569' }, children: '退出登录' }),
500
550
  ] }),
@@ -508,7 +558,7 @@ window.__ModuleLoader__.load({
508
558
  onClick: openPanel,
509
559
  title: user ? name : '登录 WaterBuddy',
510
560
  style: { display: 'flex', alignItems: 'center', gap: 8, width: wide ? 'auto' : 40, maxWidth: wide ? 150 : 40, minHeight: 38, padding: wide ? '5px 9px 5px 6px' : 3, border: user ? '1px solid #e1eafa' : '1px solid transparent', borderRadius: 12, background: user ? '#f5f8ff' : 'transparent', color: 'var(--dsw-text-secondary, #586174)', cursor: 'pointer', textAlign: 'left', transition: 'background .15s ease, border-color .15s ease' },
511
- children: [user ? jsx(UserAvatar, { user, size: 28 }) : jsx('img', { src: SSO.image, alt: SSO.title, style: { width: 28, height: 28, borderRadius: 8, objectFit: 'contain', padding: 2, boxSizing: 'border-box', flex: '0 0 auto', background: '#fff', border: '1px solid #dce9fb' } }), wide ? jsxs('span', { style: { minWidth: 0, display: 'grid', gap: 1 }, children: [jsx('strong', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12.5, lineHeight: '16px', color: 'var(--dsw-text-primary, #1f2937)' }, children: user ? name : SSO.title }), jsx('small', { style: { color: '#8a94a6', fontSize: 10.5, lineHeight: '14px' }, children: user ? '水务之窗' : '扫码或账号登录' })] }) : null],
561
+ children: [user ? jsx(UserAvatar, { user, size: 28 }) : jsx('img', { src: SSO.image, alt: SSO.title, style: { width: 28, height: 28, borderRadius: 8, objectFit: 'contain', padding: 2, boxSizing: 'border-box', flex: '0 0 auto', background: '#fff', border: '1px solid #dce9fb' } }), wide ? jsxs('span', { style: { minWidth: 0, display: 'grid', gap: 1 }, children: [jsx('strong', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12.5, lineHeight: '16px', color: 'var(--dsw-text-primary, #1f2937)' }, children: user ? name : SSO.title }), jsx('small', { style: { color: exhausted ? '#b45309' : '#8a94a6', fontSize: 10.5, lineHeight: '14px' }, children: subtitle })] }) : null],
512
562
  }),
513
563
  open ? jsx('div', { style: { position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15, 23, 42, .42)', backdropFilter: 'blur(5px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }, onClick: event => { if (event.target === event.currentTarget) setOpen(false) }, children: jsx('div', { style: { width: 420, maxWidth: 'calc(100vw - 32px)', borderRadius: 22, background: '#fff', overflow: 'hidden', boxShadow: '0 24px 80px rgba(15,23,42,.28)' }, children: user ? userPanel() : jsxs('div', { children: [
514
564
  jsxs('div', { style: { position: 'relative', padding: '22px 26px 0', textAlign: 'center' }, children: [
@@ -516,6 +566,7 @@ window.__ModuleLoader__.load({
516
566
  jsx('img', { src: SSO.image, alt: SSO.title, style: { display: 'block', width: 52, height: 52, objectFit: 'contain', margin: '0 auto' } }),
517
567
  jsx('h3', { style: { margin: '10px 0 4px', color: '#162033', fontSize: 21 }, children: 'WaterBuddy' }),
518
568
  jsx('p', { style: { margin: 0, color: '#7b8799', fontSize: 12.5 }, children: '登录后同步水务之窗账号与知识库权限' }),
569
+ exhausted ? jsx('div', { style: { margin: '14px 26px 0', padding: '10px 12px', borderRadius: 10, background: '#fff7ed', border: '1px solid #fed7aa', color: '#b45309', fontSize: 12.5, lineHeight: 1.5 }, children: '试用额度已用完,登录后即可继续使用(额度升为账号档)' }) : null,
519
570
  ] }),
520
571
  jsx(LoginPanel, { session, onLogin: nextUser => setUser(nextUser || null), onRestart: startLogin, onClose: () => setOpen(false) }),
521
572
  ] }) }) }) : null,
package/lib/host/index.js CHANGED
@@ -3,6 +3,7 @@ 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 { bindLlmCredential, describeLlmCredential, ensureLlmCredential, isGatewayActive, readActiveProvider, refreshLlmQuota, restoreDeviceLlmCredential, switchDefaultProviderWithRetry } from './llm-gateway.js'
6
7
 
7
8
  const WATERPLUS_ICON = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
8
9
  const CLIENT_ID = '2CC7F925-07AE-4AB9-9F17-75B7378D5890'
@@ -14,6 +15,9 @@ const sessions = new Map()
14
15
  let persistedAuth
15
16
  let currentUser
16
17
  let upstreamTransport
18
+ let llmStatus
19
+ let llmLoginHook
20
+ let llmLogoutHook
17
21
 
18
22
  /**
19
23
  * Test seam only. Production always uses `https.request`; regression tests install a
@@ -23,6 +27,24 @@ function setUpstreamTransport(transport) {
23
27
  upstreamTransport = transport
24
28
  }
25
29
 
30
+ /**
31
+ * 给「登录后换 key」这类收尾动作加一个上限。
32
+ *
33
+ * 它不该让登录失败,但也不该无限拖住登录响应——客户端拿到登录成功的那一刻
34
+ * 就该显示账号档余额,所以这里等一小段而不是纯后台跑。
35
+ */
36
+ function withTimeout(promise, ms, label) {
37
+ return new Promise((resolve, reject) => {
38
+ const timer = setTimeout(() => reject(new Error(`${label}-timeout`)), ms)
39
+ promise.then(
40
+ value => { clearTimeout(timer); resolve(value) },
41
+ error => { clearTimeout(timer); reject(error) },
42
+ )
43
+ })
44
+ }
45
+
46
+ const LLM_SWAP_TIMEOUT_MS = 5000
47
+
26
48
  function dshHome() {
27
49
  return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
28
50
  }
@@ -159,6 +181,16 @@ async function finalizeLogin(token) {
159
181
  currentUser = user
160
182
  persistedAuth = { token, user }
161
183
  savePersistedAuth(persistedAuth)
184
+ // 登录即换账号档 key(额度升档)。**等它完成再返回**:客户端拿到登录成功就会
185
+ // 立刻查余额,若此时 key 还没换,读到的还是设备档(实测会显示 $0.00 且误判耗尽)。
186
+ // best-effort 不变——失败或超时只告警,绝不让登录失败。
187
+ if (llmLoginHook) {
188
+ try {
189
+ await withTimeout(Promise.resolve().then(() => llmLoginHook(token)), LLM_SWAP_TIMEOUT_MS, 'llm-bind')
190
+ } catch (error) {
191
+ console.warn('[WaterBuddy] llm bind after login failed:', error.message)
192
+ }
193
+ }
162
194
  return { token, user }
163
195
  }
164
196
 
@@ -306,10 +338,31 @@ async function proxyAvatar(req, res) {
306
338
  res.end()
307
339
  }
308
340
 
309
- export const inject = ['webServer', 'systemPrompt']
341
+ /**
342
+ * 领取设备凭证并写入 harness 凭据存储,再把默认提供方指向 waterplus-ai。
343
+ *
344
+ * 全流程尽力而为:网关不可达时插件其余功能照常,只是模型不可用。
345
+ */
346
+ export async function bootstrapLlmCredential(ctx, config) {
347
+ try {
348
+ const { deviceId, credential } = await ensureLlmCredential(ctx, { config })
349
+ llmStatus = describeLlmCredential(credential, deviceId)
350
+ try {
351
+ await switchDefaultProviderWithRetry(ctx)
352
+ } catch (error) {
353
+ console.warn('[WaterBuddy] default provider not switched:', error.message)
354
+ }
355
+ } catch (error) {
356
+ console.warn('[WaterBuddy] LLM credential bootstrap failed:', error.message)
357
+ llmStatus = { ...(llmStatus || {}), error: error.message }
358
+ }
359
+ return llmStatus || null
360
+ }
361
+
362
+ export const inject = ['webServer', 'systemPrompt', 'credentials', 'settings']
310
363
  export { normalizeAvatar, setUpstreamTransport }
311
364
 
312
- export function apply(ctx) {
365
+ export function apply(ctx, config) {
313
366
  readPersistedAuth()
314
367
  ctx.effect(() => {
315
368
  const routes = [
@@ -324,12 +377,38 @@ export function apply(ctx) {
324
377
  { kind: 'exact', path: '/api/waterbuddy/auth/password/change', handler: (req, res) => handleAuthAction(req, res, changePassword) },
325
378
  { kind: 'exact', path: '/api/waterbuddy/user', handler: (_req, res) => json(res, 200, { user: currentUser || null }) },
326
379
  { kind: 'exact', path: '/api/waterbuddy/avatar', handler: async (req, res) => { try { await proxyAvatar(req, res) } catch (error) { json(res, 502, { error: error.message }) } } },
327
- { kind: 'exact', path: '/api/waterbuddy/logout', handler: (_req, res) => { currentUser = undefined; for (const session of sessions.values()) delete session.token; clearPersistedAuth(); json(res, 200, { ok: true }) } },
380
+ { kind: 'exact', path: '/api/waterbuddy/llm/status', handler: async (_req, res) => {
381
+ try {
382
+ const token = persistedAuth?.token
383
+ const { quota } = await refreshLlmQuota({ config, token })
384
+ if (quota) {
385
+ // 余额是权威读数,每次刷新都覆盖;subjectType 跟随当前是否登录。
386
+ llmStatus = { ...(llmStatus || {}), ...quota, subjectType: token ? 'user' : 'device', isAnonymous: !token }
387
+ }
388
+ } catch (error) {
389
+ // 额度查询失败沿用上次读数,不打扰用户
390
+ }
391
+ // 用别人的模型时额度不归我们管,客户端据此隐藏额度展示。
392
+ const activeProvider = readActiveProvider(ctx)
393
+ json(res, 200, {
394
+ llm: llmStatus
395
+ ? { ...llmStatus, activeProvider, managesQuota: isGatewayActive(ctx) }
396
+ : { activeProvider, managesQuota: isGatewayActive(ctx) },
397
+ })
398
+ } },
399
+ { 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 }) } } },
400
+ { 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 }) } },
328
401
  { kind: 'prefix', path: '/api/waterbuddy/mcp', handler: async (req, res) => { try { await proxyMcp(req, res) } catch (error) { json(res, 502, { error: error.message }) } } },
329
402
  ]
330
403
  const disposers = routes.map(route => ctx.webServer.register(route))
331
404
  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" />'))
332
405
  const disposePersona = ctx.systemPrompt.section({ name: 'waterplus:persona', order: 100, text: WATERPLUS_PERSONA })
406
+ // 尽力而为:宿主没有 credentials 服务(例如测试替身)时直接跳过,不发网络请求。
407
+ if (ctx.credentials?.set) {
408
+ llmLoginHook = token => bindLlmCredential(ctx, token, { config })
409
+ llmLogoutHook = () => restoreDeviceLlmCredential(ctx, { config })
410
+ void bootstrapLlmCredential(ctx, config)
411
+ }
333
412
  return () => { for (const dispose of disposers) dispose(); disposeTitle(); disposePersona(); sessions.clear(); currentUser = undefined; persistedAuth = undefined }
334
413
  }, 'waterbuddy: identity and auth')
335
414
  }
@@ -0,0 +1,424 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import http from 'node:http'
5
+ import https from 'node:https'
6
+ import { randomUUID } from 'node:crypto'
7
+
8
+ /**
9
+ * LLM 网关客户端:把「本机设备」换成一把受额度约束的网关 key。
10
+ *
11
+ * 边界:官方 DeepSeek key 只存在于 sub2api 服务端;本模块拿到的是网关 key,
12
+ * 写进 harness 凭据存储后由 `llm-pi-ai` 的 `apiKeyEnv` 引用按请求解析,
13
+ * 因此换 key 无需重启(凭据文件是热重载的)。
14
+ */
15
+
16
+ /** 与 patches/cordis.patch.yml 中 waterplus-ai 路由的 apiKeyEnv 保持一致。 */
17
+ export const LLM_GATEWAY_CREDENTIAL_REF = 'WATERPLUS_AI_LLM_KEY'
18
+ /** 提供方路由名,同时也是设置里的默认提供方值。 */
19
+ export const LLM_GATEWAY_PROVIDER = 'waterplus-ai'
20
+ /** 网关默认走平台域名;部署不同可用插件 config 或环境变量覆盖。 */
21
+ export const DEFAULT_LLM_GATEWAY_BASE_URL = 'https://ai.shuiwujia.com'
22
+ /** 首次安装时指向的默认模型。 */
23
+ export const DEFAULT_LLM_GATEWAY_MODEL = 'deepseek-flash'
24
+ /** 未登录设备的额度用尽后由客户端引导登录(见 P3)。 */
25
+ export const LOW_BALANCE_HINT = 0
26
+
27
+ const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/
28
+
29
+ let llmGatewayTransport
30
+
31
+ /**
32
+ * Test seam only, mirroring `setUpstreamTransport` in `lib/host/index.js`.
33
+ * Production always uses the protocol-matched node request; regression tests
34
+ * install a scripted transport so the gateway contract is asserted offline.
35
+ */
36
+ export function setLlmGatewayTransport(transport) {
37
+ llmGatewayTransport = transport
38
+ }
39
+
40
+ export function dshHome() {
41
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
42
+ }
43
+
44
+ export function deviceStateFile() {
45
+ return path.join(dshHome(), 'waterbuddy-device.json')
46
+ }
47
+
48
+ export function resolveGatewayBaseUrl(config) {
49
+ const raw = config?.llmGatewayBaseUrl || process.env.WATERBUDDY_LLM_GATEWAY_URL || DEFAULT_LLM_GATEWAY_BASE_URL
50
+ return String(raw).trim().replace(/\/+$/, '')
51
+ }
52
+
53
+ function readDeviceState() {
54
+ try {
55
+ const value = JSON.parse(fs.readFileSync(deviceStateFile(), 'utf8'))
56
+ return value && typeof value === 'object' ? value : {}
57
+ } catch {
58
+ return {}
59
+ }
60
+ }
61
+
62
+ function writeDeviceState(state) {
63
+ try {
64
+ const file = deviceStateFile()
65
+ fs.mkdirSync(path.dirname(file), { recursive: true })
66
+ fs.writeFileSync(file, JSON.stringify(state), { mode: 0o600 })
67
+ try { fs.chmodSync(file, 0o600) } catch {}
68
+ } catch (error) {
69
+ console.warn('[WaterBuddy] failed to persist device state:', error.message)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * 读取本机 deviceId,没有则生成并持久化。
75
+ *
76
+ * 未登录时的额度按 deviceId 一人一份,所以它必须稳定:重装插件不能让用户
77
+ * 白拿一份新试用额度。
78
+ */
79
+ export function readOrCreateDeviceId() {
80
+ const state = readDeviceState()
81
+ const existing = typeof state.deviceId === 'string' ? state.deviceId.trim() : ''
82
+ if (DEVICE_ID_PATTERN.test(existing)) {
83
+ return { deviceId: existing, state, created: false }
84
+ }
85
+ const deviceId = randomUUID()
86
+ const next = { ...state, deviceId, createdAt: state.createdAt || new Date().toISOString() }
87
+ writeDeviceState(next)
88
+ return { deviceId, state: next, created: true }
89
+ }
90
+
91
+ export function markDefaultProviderApplied() {
92
+ writeDeviceState({ ...readDeviceState(), defaultProviderApplied: true, defaultProviderAppliedAt: new Date().toISOString() })
93
+ }
94
+
95
+ /**
96
+ * 拆掉 swj-kb-api 的全局响应信封。
97
+ *
98
+ * 后端注册了 `TransformInterceptor`,所有非 SSE 响应都被包成
99
+ * `{ success, message, code, result, timestamp }`;这里同时兼容未包裹的扁平
100
+ * 结构,便于直连调试或将来去掉拦截器。
101
+ */
102
+ export function unwrapGatewayPayload(raw) {
103
+ if (raw && typeof raw === 'object' && raw.result && typeof raw.result === 'object') {
104
+ return raw.result
105
+ }
106
+ return raw
107
+ }
108
+
109
+ /** 校验后端响应,缺失 apiKey 视为失败而不是写入空凭据。 */
110
+ export function normalizeCredentialPayload(raw) {
111
+ const payload = unwrapGatewayPayload(raw)
112
+ const apiKey = typeof payload?.apiKey === 'string' ? payload.apiKey.trim() : ''
113
+ if (!apiKey) {
114
+ throw new Error('llm-credential-missing-api-key')
115
+ }
116
+ const baseURL = typeof payload?.baseURL === 'string' ? payload.baseURL.trim() : ''
117
+ return {
118
+ apiKey,
119
+ baseURL,
120
+ provider: typeof payload?.provider === 'string' && payload.provider.trim() ? payload.provider.trim() : LLM_GATEWAY_PROVIDER,
121
+ balance: Number.isFinite(Number(payload?.balance)) ? Number(payload.balance) : 0,
122
+ subjectType: payload?.subjectType === 'user' ? 'user' : 'device',
123
+ isAnonymous: payload?.isAnonymous !== false,
124
+ }
125
+ }
126
+
127
+ /**
128
+ * 按协议选择请求实现。
129
+ *
130
+ * 生产网关走 https,但本机联调/私有部署会用 http(例如 10.x 网段的 sub2api),
131
+ * 硬编码 `https.request` 会直接抛 ERR_INVALID_PROTOCOL。
132
+ */
133
+ export function selectTransport(url) {
134
+ return new URL(url).protocol === 'http:' ? http.request : https.request
135
+ }
136
+
137
+ function defaultTransport(url, options, callback) {
138
+ // 必须把 callback 透传下去:漏掉它就永远收不到响应,只能等到超时。
139
+ return selectTransport(url)(url, { method: options.method, headers: options.headers, insecureHTTPParser: true }, callback)
140
+ }
141
+
142
+ /**
143
+ * 调一次 swj-kb-api 的 llm 端点,返回拆掉信封后的业务对象。
144
+ *
145
+ * 注意这里**不是**平台的 `{code, message}` 信封:swj-kb-api 是 NestJS,
146
+ * 响应经 `TransformInterceptor` 包成 `{success, code, result}`(见 unwrapGatewayPayload),
147
+ * 非 2xx 即失败并把上游 message 带出来。
148
+ */
149
+ export async function callLlmGateway(options) {
150
+ const { baseUrl, path: requestPath, method = 'POST', token, body, transport, timeoutMs = 15000 } = options
151
+ const url = new URL(`${baseUrl}${requestPath}`)
152
+ const payloadText = body === undefined ? undefined : JSON.stringify(body)
153
+ const send = transport || llmGatewayTransport || defaultTransport
154
+
155
+ return await new Promise((resolve, reject) => {
156
+ const timer = setTimeout(() => reject(new Error('llm-gateway-timeout')), timeoutMs)
157
+ const fail = error => { clearTimeout(timer); reject(error) }
158
+ const headers = {}
159
+ if (payloadText !== undefined) {
160
+ headers['content-type'] = 'application/json'
161
+ headers['content-length'] = Buffer.byteLength(payloadText)
162
+ }
163
+ if (token) headers.Authorization = `Bearer ${token}`
164
+
165
+ let request
166
+ try {
167
+ request = send(url, { method, headers }, response => {
168
+ const chunks = []
169
+ response.on('data', chunk => chunks.push(chunk))
170
+ response.on('end', () => {
171
+ clearTimeout(timer)
172
+ const text = Buffer.concat(chunks).toString('utf8')
173
+ let payload
174
+ try { payload = text ? JSON.parse(text) : {} } catch { payload = { message: text } }
175
+ const status = response.statusCode || 500
176
+ if (status >= 400) {
177
+ return reject(new Error(payload?.message || `llm-gateway-failed:${status}`))
178
+ }
179
+ resolve(unwrapGatewayPayload(payload))
180
+ })
181
+ })
182
+ } catch (error) {
183
+ return fail(error)
184
+ }
185
+ request.on('error', fail)
186
+ if (payloadText !== undefined) request.write(payloadText)
187
+ request.end()
188
+ })
189
+ }
190
+
191
+ /** 领取设备档凭证(幂等:同一 deviceId 返回同一把 key)。 */
192
+ export async function requestDeviceCredential(options) {
193
+ const { baseUrl, deviceId, transport, timeoutMs = 15000 } = options
194
+ const payload = await callLlmGateway({
195
+ baseUrl,
196
+ path: '/swj-kb-api/client/llm/credentials',
197
+ method: 'POST',
198
+ body: { deviceId },
199
+ transport,
200
+ timeoutMs,
201
+ })
202
+ return normalizeCredentialPayload(payload)
203
+ }
204
+
205
+ /** 用登录态换账号档凭证:额度升档,并把设备试用额度并入账号。 */
206
+ export async function requestUserCredential(options) {
207
+ const { baseUrl, token, deviceId, mergeDeviceQuota = true, transport, timeoutMs = 15000 } = options
208
+ if (!token) throw new Error('llm-bind-missing-token')
209
+ const payload = await callLlmGateway({
210
+ baseUrl,
211
+ path: '/swj-kb-api/client/llm/bind',
212
+ method: 'POST',
213
+ token,
214
+ body: { deviceId, mergeDeviceQuota },
215
+ transport,
216
+ timeoutMs,
217
+ })
218
+ return normalizeCredentialPayload(payload)
219
+ }
220
+
221
+ /**
222
+ * 查当前额度。
223
+ *
224
+ * 带 token 查账号档,否则按 deviceId 查设备档——与后端 `GET /client/llm/quota`
225
+ * 的两种鉴权方式一致(JWT 或 `?deviceId=`)。
226
+ */
227
+ export async function fetchLlmQuota(options) {
228
+ const { baseUrl, token, deviceId, transport, timeoutMs = 8000 } = options
229
+ const query = !token && deviceId ? `?deviceId=${encodeURIComponent(deviceId)}` : ''
230
+ const payload = await callLlmGateway({
231
+ baseUrl,
232
+ path: `/swj-kb-api/client/llm/quota${query}`,
233
+ method: 'GET',
234
+ token,
235
+ transport,
236
+ timeoutMs,
237
+ })
238
+ return {
239
+ balance: Number.isFinite(Number(payload?.balance)) ? Number(payload.balance) : 0,
240
+ totalRecharged: Number.isFinite(Number(payload?.totalRecharged)) ? Number(payload.totalRecharged) : 0,
241
+ isAnonymous: payload?.isAnonymous !== false,
242
+ exhausted: payload?.exhausted === true,
243
+ lowBalance: payload?.lowBalance === true,
244
+ lowBalanceThreshold: Number(payload?.lowBalanceThreshold) || 0,
245
+ }
246
+ }
247
+
248
+ /**
249
+ * 领取(或复用)设备凭证并写入 harness 凭据存储。
250
+ *
251
+ * 幂等:后端按 deviceId 返回同一把 key;凭据写入是热生效的,不需要重启。
252
+ */
253
+ export async function ensureLlmCredential(ctx, options = {}) {
254
+ // 先确认凭据存储可用再发请求:没有 credentials 服务的宿主不该产生任何网络流量。
255
+ if (!ctx?.credentials?.set) {
256
+ throw new Error('llm-credential-store-unavailable')
257
+ }
258
+
259
+ const baseUrl = resolveGatewayBaseUrl(options.config)
260
+ const { deviceId, state } = readOrCreateDeviceId()
261
+ const credential = await requestDeviceCredential({ baseUrl, deviceId, transport: options.transport })
262
+ await ctx.credentials.set(LLM_GATEWAY_CREDENTIAL_REF, credential.apiKey)
263
+
264
+ return { deviceId, credential, state, baseUrl }
265
+ }
266
+
267
+ /**
268
+ * 首次安装把默认提供方指向 waterplus-ai。
269
+ *
270
+ * 只做一次并留标记:用户之后在 Models 设置页改回自有提供方时,插件不再抢回。
271
+ */
272
+ export async function applyDefaultProviderOnce(ctx) {
273
+ const state = readDeviceState()
274
+ if (state.defaultProviderApplied === true) {
275
+ return { applied: false, reason: 'already-applied' }
276
+ }
277
+ if (!ctx?.settings?.update) {
278
+ return { applied: false, reason: 'settings-unavailable' }
279
+ }
280
+ await ctx.settings.update('agent-default-model', {
281
+ provider: LLM_GATEWAY_PROVIDER,
282
+ model: DEFAULT_LLM_GATEWAY_MODEL,
283
+ })
284
+
285
+ // 回读确认后再置标记。插件 `apply()` 跑在宿主启动早期,此时 settings 命名空间
286
+ // 可能尚未就绪,`update` 会静默不落盘(实测过);若无条件置标记,用户就永远
287
+ // 停在默认提供方上。回读不到就当作未生效,留给下一次启动 / refresh 重试。
288
+ const persisted = ctx.settings.get?.('agent-default-model')
289
+ if (persisted && typeof persisted === 'object' && persisted.provider && persisted.provider !== LLM_GATEWAY_PROVIDER) {
290
+ return { applied: false, reason: 'not-persisted' }
291
+ }
292
+
293
+ markDefaultProviderApplied()
294
+ return { applied: true }
295
+ }
296
+
297
+ /**
298
+ * 启动早期切换默认提供方可能因命名空间未就绪而失败,这里带退避重试。
299
+ *
300
+ * 只在「确实没落盘」时重试:用户自己改过提供方(already-applied)或宿主没有
301
+ * settings 服务(settings-unavailable)都直接结束。
302
+ */
303
+ export async function switchDefaultProviderWithRetry(ctx, options = {}) {
304
+ const attempts = options.attempts ?? 4
305
+ const delayMs = options.delayMs ?? 3000
306
+ let last = { applied: false, reason: 'not-attempted' }
307
+
308
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
309
+ last = await applyDefaultProviderOnce(ctx)
310
+ if (last.applied || last.reason !== 'not-persisted') {
311
+ return { ...last, attempts: attempt }
312
+ }
313
+ if (attempt < attempts) {
314
+ await new Promise(resolve => setTimeout(resolve, delayMs))
315
+ }
316
+ }
317
+ return { ...last, attempts }
318
+ }
319
+
320
+ /** 额度查询失败不该让状态接口失败,降级为 null 由调用方沿用上次读数。 */
321
+ async function safeQuota(options) {
322
+ try {
323
+ return await fetchLlmQuota(options)
324
+ } catch {
325
+ return null
326
+ }
327
+ }
328
+
329
+ /** 登录后把 key 换成账号档(热生效),并带回最新额度。 */
330
+ export async function bindLlmCredential(ctx, token, options = {}) {
331
+ if (!ctx?.credentials?.set) throw new Error('llm-credential-store-unavailable')
332
+ const baseUrl = resolveGatewayBaseUrl(options.config)
333
+ const { deviceId } = readOrCreateDeviceId()
334
+ const credential = await requestUserCredential({
335
+ baseUrl,
336
+ token,
337
+ deviceId,
338
+ mergeDeviceQuota: options.mergeDeviceQuota !== false,
339
+ transport: options.transport,
340
+ })
341
+ await ctx.credentials.set(LLM_GATEWAY_CREDENTIAL_REF, credential.apiKey)
342
+ const quota = await safeQuota({ baseUrl, token, deviceId, transport: options.transport })
343
+ return { deviceId, credential, quota }
344
+ }
345
+
346
+ /** 登出后回落到设备档 key(设备档是幂等的,直接重领即可)。 */
347
+ export async function restoreDeviceLlmCredential(ctx, options = {}) {
348
+ if (!ctx?.credentials?.set) throw new Error('llm-credential-store-unavailable')
349
+ const baseUrl = resolveGatewayBaseUrl(options.config)
350
+ const { deviceId } = readOrCreateDeviceId()
351
+ const credential = await requestDeviceCredential({ baseUrl, deviceId, transport: options.transport })
352
+ await ctx.credentials.set(LLM_GATEWAY_CREDENTIAL_REF, credential.apiKey)
353
+ const quota = await safeQuota({ baseUrl, deviceId, transport: options.transport })
354
+ return { deviceId, credential, quota }
355
+ }
356
+
357
+ /** 只刷额度不换 key,供状态接口按需更新余额。 */
358
+ export async function refreshLlmQuota(options = {}) {
359
+ const baseUrl = resolveGatewayBaseUrl(options.config)
360
+ const { deviceId } = readOrCreateDeviceId()
361
+ const quota = await safeQuota({
362
+ baseUrl,
363
+ token: options.token,
364
+ deviceId,
365
+ transport: options.transport,
366
+ timeoutMs: options.timeoutMs ?? 3000,
367
+ })
368
+ return { deviceId, quota }
369
+ }
370
+
371
+ /**
372
+ * 读当前选中的默认提供方。
373
+ *
374
+ * 用户可以在模型页换成自己的提供方(我们**不锁定**),此时插件不应再展示额度:
375
+ * 消耗的不是我们的额度,显示出来只会误导。
376
+ */
377
+ export function readActiveProvider(ctx) {
378
+ try {
379
+ const section = ctx?.settings?.get?.('agent-default-model')
380
+ return typeof section?.provider === 'string' ? section.provider.trim() : ''
381
+ } catch {
382
+ return ''
383
+ }
384
+ }
385
+
386
+ /** 额度是否由我们的网关管辖(即当前默认提供方就是 waterplus-ai)。 */
387
+ export function isGatewayActive(ctx) {
388
+ return readActiveProvider(ctx) === LLM_GATEWAY_PROVIDER
389
+ }
390
+
391
+ /** 合并凭据与额度,得到下发给客户端的完整状态。 */
392
+ export function describeLlmState(credential, quota, deviceId, extra = {}) {
393
+ const balance = quota?.balance ?? credential?.balance ?? 0
394
+ return {
395
+ provider: LLM_GATEWAY_PROVIDER,
396
+ subjectType: credential?.subjectType ?? 'device',
397
+ isAnonymous: credential?.isAnonymous !== false,
398
+ balance,
399
+ totalRecharged: quota?.totalRecharged ?? 0,
400
+ // 额度耗尽是登录引导的唯一触发条件(见 SUB2API-TOKEN-QUOTA-PLAN §7.2)
401
+ exhausted: quota?.exhausted ?? balance <= LOW_BALANCE_HINT,
402
+ lowBalance: quota?.lowBalance === true,
403
+ baseURL: credential?.baseURL ?? '',
404
+ deviceId,
405
+ ...extra,
406
+ }
407
+ }
408
+
409
+ /** 供本地路由返回的状态快照。 */
410
+ export function describeLlmCredential(credential, deviceId) {
411
+ return {
412
+ provider: LLM_GATEWAY_PROVIDER,
413
+ subjectType: credential.subjectType,
414
+ isAnonymous: credential.isAnonymous,
415
+ balance: credential.balance,
416
+ exhausted: credential.balance <= LOW_BALANCE_HINT,
417
+ baseURL: credential.baseURL,
418
+ deviceId,
419
+ }
420
+ }
421
+
422
+ export function readDeviceStateSnapshot() {
423
+ return readDeviceState()
424
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waterplus-ai/waterbuddy",
3
- "version": "0.1.21",
3
+ "version": "0.1.34",
4
4
  "description": "WaterBuddy branding and Web profile bundle for DeepSeek Harness",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "files": [
15
15
  "lib/host/index.js",
16
+ "lib/host/llm-gateway.js",
16
17
  "lib/client/index.js",
17
18
  "lib/shared/persona.js",
18
19
  "patches/cordis.patch.yml",
@@ -4,9 +4,76 @@
4
4
  - id: ui-brand-official
5
5
  disabled: true
6
6
 
7
+ # WaterPlus LLM gateway route.
8
+ #
9
+ # pi-ai ships no catalog entry for our gateway, so this route declares its
10
+ # protocol, endpoint, credential reference, model list, and wire-compat switch
11
+ # outright — a route the catalog does not describe must name all of them.
12
+ #
13
+ # No secret lives here: `apiKeyEnv` is a reference the harness resolves per
14
+ # request from its credential store, and `lib/host` writes the gateway key into
15
+ # that store on startup. Because the store is hot-reloaded, rotating the key
16
+ # (for example after the user signs in) never needs a restart.
17
+ #
18
+ # `baseURL` must equal the backend's `SUB2API_PUBLIC_BASE_URL` plus `/v1`; the
19
+ # host reports the address the backend actually returned under
20
+ # `GET /api/waterbuddy/llm/status`, so drift is visible rather than silent.
21
+ #
22
+ # 本机联调:把 baseURL 改成 http://<你的局域网IP>:8080/v1,并把 llmGatewayBaseUrl
23
+ # 改成 http://<你的局域网IP>:3099(或设 WATERBUDDY_LLM_GATEWAY_URL),改的是
24
+ # profile 里安装后的那份 patch,不要改这里——这里是发布值。
25
+ - id: llm-pi-ai
26
+ config:
27
+ providers:
28
+ waterplus-ai:
29
+ displayName: WaterPlus AI
30
+ api: openai-completions
31
+ baseURL: https://token.shuiwujia.com/v1
32
+ apiKeyEnv: WATERPLUS_AI_LLM_KEY
33
+ compat:
34
+ thinkingFormat: deepseek
35
+ # DeepSeek 官方适配器对每个等级发 `thinking:{type:enabled}` + `reasoning_effort:<等级>`,
36
+ # 因此这里要打开,否则 pi-ai 只发 thinking 开关、等级不生效。
37
+ supportsReasoningEffort: true
38
+ # 必须关掉:pi-ai 在 `model.reasoning && supportsDeveloperRole` 时把系统提示词
39
+ # 的角色发成 `developer`,而 DeepSeek 只认 system/user/assistant/tool,
40
+ # 会直接 400 invalid_request_error。启用 reasoning 之前不发 developer,
41
+ # 所以这个坑是「声明 reasoningEfforts」之后才暴露的(实测踩过)。
42
+ supportsDeveloperRole: false
43
+ defaultContextWindow: 1000000
44
+ defaultMaxTokens: 256000
45
+ models:
46
+ - id: deepseek-flash
47
+ name: DeepSeek-V4-Flash
48
+ contextWindow: 1000000
49
+ maxTokens: 256000
50
+ input: [text, image]
51
+ # 必须声明:模型级省略 reasoningEfforts 会被解析成 reasoning:false,
52
+ # harness 里任何已保存的等级都会以 UNSUPPORTED_REASONING_EFFORT 直接跑挂。
53
+ # 取值与官方 DeepSeek 适配器契约一致:只接受 low / high / max。
54
+ reasoningEfforts:
55
+ low: low
56
+ high: high
57
+ max: max
58
+ - id: deepseek-v4-pro
59
+ name: DeepSeek-V4-Pro
60
+ contextWindow: 1000000
61
+ maxTokens: 256000
62
+ input: [text]
63
+ # 必须声明:模型级省略 reasoningEfforts 会被解析成 reasoning:false,
64
+ # harness 里任何已保存的等级都会以 UNSUPPORTED_REASONING_EFFORT 直接跑挂。
65
+ # 取值与官方 DeepSeek 适配器契约一致:只接受 low / high / max。
66
+ reasoningEfforts:
67
+ low: low
68
+ high: high
69
+ max: max
70
+
7
71
  - insert:
8
72
  - id: ui-brand-waterplus
9
73
  name: '@waterplus-ai/waterbuddy'
74
+ # 设备领 key 的平台地址;换部署只改这里,无需改代码。
75
+ config:
76
+ llmGatewayBaseUrl: https://ai.shuiwujia.com
10
77
  - id: mcp-xiaojia
11
78
  name: '@deepseek-ai/dsh-mcp-client'
12
79
  config: