@waterplus-ai/waterbuddy 0.1.17 → 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.
package/lib/host/index.js CHANGED
@@ -3,19 +3,59 @@ 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'
9
10
  const PLATFORM_URL = 'https://web.shuiwujia.com'
10
11
  const AI_URL = 'https://ai.shuiwujia.com'
12
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
13
+ const VERIFY_CODE_PATTERN = /^\d{4,6}$/
11
14
  const sessions = new Map()
12
- const authFile = path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'waterbuddy-auth.json')
13
15
  let persistedAuth
14
16
  let currentUser
17
+ let upstreamTransport
18
+ let llmStatus
19
+ let llmLoginHook
20
+ let llmLogoutHook
21
+
22
+ /**
23
+ * Test seam only. Production always uses `https.request`; regression tests install a
24
+ * scripted transport so the platform contract can be asserted without network access.
25
+ */
26
+ function setUpstreamTransport(transport) {
27
+ upstreamTransport = transport
28
+ }
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
+
48
+ function dshHome() {
49
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
50
+ }
51
+
52
+ function authFile() {
53
+ return path.join(dshHome(), 'waterbuddy-auth.json')
54
+ }
15
55
 
16
56
  function readPersistedAuth() {
17
57
  try {
18
- const value = JSON.parse(fs.readFileSync(authFile, 'utf8'))
58
+ const value = JSON.parse(fs.readFileSync(authFile(), 'utf8'))
19
59
  if (value?.token && value?.user) {
20
60
  currentUser = normalizeUser(value.user)
21
61
  persistedAuth = { ...value, user: currentUser }
@@ -25,9 +65,10 @@ function readPersistedAuth() {
25
65
 
26
66
  function savePersistedAuth(auth) {
27
67
  try {
28
- fs.mkdirSync(path.dirname(authFile), { recursive: true })
29
- fs.writeFileSync(authFile, JSON.stringify(auth), { mode: 0o600 })
30
- try { fs.chmodSync(authFile, 0o600) } catch {}
68
+ const file = authFile()
69
+ fs.mkdirSync(path.dirname(file), { recursive: true })
70
+ fs.writeFileSync(file, JSON.stringify(auth), { mode: 0o600 })
71
+ try { fs.chmodSync(file, 0o600) } catch {}
31
72
  } catch (error) {
32
73
  console.warn('[WaterBuddy] failed to persist login:', error.message)
33
74
  }
@@ -35,7 +76,7 @@ function savePersistedAuth(auth) {
35
76
 
36
77
  function clearPersistedAuth() {
37
78
  persistedAuth = undefined
38
- try { fs.rmSync(authFile, { force: true }) } catch {}
79
+ try { fs.rmSync(authFile(), { force: true }) } catch {}
39
80
  }
40
81
 
41
82
  function isSupportedAvatarUrl(url) {
@@ -79,17 +120,23 @@ function queryUrl(path, params) {
79
120
  return url.href
80
121
  }
81
122
 
82
- function cookieHeader(response) {
83
- if (typeof response.headers.getSetCookie !== 'function') return ''
84
- return response.headers.getSetCookie().map(value => value.split(';', 1)[0]).join('; ')
123
+ function aiUrl(path, params) {
124
+ const url = new URL(path, AI_URL)
125
+ for (const [key, value] of Object.entries(params || {})) url.searchParams.set(key, value)
126
+ return url.href
127
+ }
128
+
129
+ function joinCookies(...values) {
130
+ return values.filter(Boolean).join('; ')
85
131
  }
86
132
 
87
133
  async function requestJson(url, options = {}) {
88
134
  const parsed = new URL(url)
89
135
  const headers = { plaform: 'PC', ...(options.headers || {}) }
90
136
  const body = options.body
137
+ const send = options.request || upstreamTransport || https.request
91
138
  return await new Promise((resolve, reject) => {
92
- const request = https.request(parsed, { method: options.method || 'GET', headers, insecureHTTPParser: true }, response => {
139
+ const request = send(parsed, { method: options.method || 'GET', headers, insecureHTTPParser: true }, response => {
93
140
  const chunks = []
94
141
  response.on('data', chunk => chunks.push(chunk))
95
142
  response.on('end', () => {
@@ -106,13 +153,145 @@ async function requestJson(url, options = {}) {
106
153
  })
107
154
  }
108
155
 
156
+ function postAiJson(path, payload, options = {}) {
157
+ return requestJson(aiUrl(path, options.query), {
158
+ method: 'POST',
159
+ headers: { 'content-type': 'application/json', ...(options.headers || {}) },
160
+ body: JSON.stringify(payload),
161
+ })
162
+ }
163
+
164
+ function extractToken(result) {
165
+ if (typeof result === 'string') return result
166
+ if (!result || typeof result !== 'object') return ''
167
+ for (const key of ['token', 'accessToken', 'access_token']) {
168
+ if (typeof result[key] === 'string' && result[key]) return result[key]
169
+ }
170
+ return ''
171
+ }
172
+
173
+ async function fetchUserInfo(token) {
174
+ const profile = await requestJson(aiUrl('/swj-waterplusai-api/client/user/info'), { headers: { Authorization: `Bearer ${token}` } })
175
+ return normalizeUser(profile.body.result)
176
+ }
177
+
178
+ async function finalizeLogin(token) {
179
+ if (!token) throw new AuthUpstreamError('登录凭证无效')
180
+ const user = await fetchUserInfo(token)
181
+ currentUser = user
182
+ persistedAuth = { token, user }
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
+ }
194
+ return { token, user }
195
+ }
196
+
197
+ /** A rejected local payload: a retry cannot succeed, so the route answers 400. */
198
+ class InvalidAuthRequest extends Error {}
199
+
200
+ /** A failed upstream call: the platform may recover, so the route answers 502. */
201
+ class AuthUpstreamError extends Error {}
202
+
203
+ function requireString(value, message) {
204
+ const text = typeof value === 'string' ? value.trim() : ''
205
+ if (!text) throw new InvalidAuthRequest(message)
206
+ return text
207
+ }
208
+
209
+ function isLocalValidation(error) {
210
+ return error instanceof InvalidAuthRequest
211
+ }
212
+
213
+ async function accountLogin(body) {
214
+ const loginId = requireString(body.loginId, '请输入账号')
215
+ const password = requireString(body.password, '请输入密码')
216
+ const response = await requestJson(aiUrl('/swj-waterplusai-api/client/user/login'), {
217
+ method: 'POST',
218
+ headers: { 'content-type': 'application/json' },
219
+ body: JSON.stringify({ loginId, password }),
220
+ })
221
+ const { user } = await finalizeLogin(extractToken(response.body.result))
222
+ return { status: 'SUCCESS', user }
223
+ }
224
+
225
+ async function sendRegisterCode(body) {
226
+ const email = requireString(body.email, '请输入邮箱')
227
+ if (!EMAIL_PATTERN.test(email)) throw new InvalidAuthRequest('邮箱格式不正确')
228
+ const response = await postAiJson('/swj-waterplusai-api/client/user/register/code', { email })
229
+ return { ok: true, message: response.body.message || '验证码已发送' }
230
+ }
231
+
232
+ async function registerAccount(body) {
233
+ const email = requireString(body.email, '请输入邮箱')
234
+ if (!EMAIL_PATTERN.test(email)) throw new InvalidAuthRequest('邮箱格式不正确')
235
+ const verifyCode = requireString(body.verifyCode, '请输入验证码')
236
+ if (!VERIFY_CODE_PATTERN.test(verifyCode)) throw new InvalidAuthRequest('验证码格式不正确')
237
+ const password = requireString(body.password, '请输入密码')
238
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
239
+ const payload = { email, verifyCode, password, ...(name ? { name } : {}) }
240
+ const response = await postAiJson('/swj-waterplusai-api/client/user/register', payload)
241
+ const { user } = await finalizeLogin(extractToken(response.body.result))
242
+ return { status: 'SUCCESS', user, message: response.body.message || '注册成功' }
243
+ }
244
+
245
+ async function sendPasswordCode(body) {
246
+ const email = requireString(body.email, '请输入邮箱')
247
+ if (!EMAIL_PATTERN.test(email)) throw new InvalidAuthRequest('邮箱格式不正确')
248
+ const response = await postAiJson('/swj-waterplusai-api/client/user/password/code', { email })
249
+ return { ok: true, message: response.body.message || '验证码已发送' }
250
+ }
251
+
252
+ async function changePassword(body) {
253
+ const email = requireString(body.email, '请输入邮箱')
254
+ if (!EMAIL_PATTERN.test(email)) throw new InvalidAuthRequest('邮箱格式不正确')
255
+ const verifyCode = requireString(body.verifyCode, '请输入验证码')
256
+ if (!VERIFY_CODE_PATTERN.test(verifyCode)) throw new InvalidAuthRequest('验证码格式不正确')
257
+ const password = requireString(body.password, '请输入密码')
258
+ const response = await postAiJson('/swj-waterplusai-api/client/user/password/change', { email, verifyCode, password })
259
+ return { ok: true, message: response.body.message || '密码修改成功' }
260
+ }
261
+
262
+ function readJsonBody(req) {
263
+ return new Promise((resolve, reject) => {
264
+ const chunks = []
265
+ req.on('data', chunk => chunks.push(chunk))
266
+ req.on('end', () => {
267
+ const text = Buffer.concat(chunks).toString('utf8').trim()
268
+ if (!text) return resolve({})
269
+ try {
270
+ const parsed = JSON.parse(text)
271
+ resolve(parsed && typeof parsed === 'object' ? parsed : {})
272
+ } catch { reject(new InvalidAuthRequest('请求参数格式不正确')) }
273
+ })
274
+ req.on('error', reject)
275
+ })
276
+ }
277
+
278
+ async function handleAuthAction(req, res, action) {
279
+ if (req.method !== 'POST') return json(res, 405, { error: 'method-not-allowed' })
280
+ try {
281
+ const body = await readJsonBody(req)
282
+ json(res, 200, await action(body))
283
+ } catch (error) {
284
+ json(res, isLocalValidation(error) ? 400 : 502, { error: error.message })
285
+ }
286
+ }
287
+
109
288
  async function createLogin() {
110
289
  const login = await requestJson(queryUrl('/swj-platform-api/waterplusUser/login', { orgId: CLIENT_ID }))
111
290
  const scene = login.body.result?.scene
112
291
  if (!scene) throw new Error('登录场景创建失败')
113
292
  const qr = await requestJson(`${PLATFORM_URL}/swj-platform-api/platformWxappUser/getQRCode`, { method: 'POST', headers: { 'content-type': 'application/json', ...(login.cookie ? { cookie: login.cookie } : {}) }, body: JSON.stringify({ page: 'main/home/index', scene, check_path: true, env_version: 'release' }) })
114
293
  const sessionId = crypto.randomUUID()
115
- sessions.set(sessionId, { scene, cookie: [login.cookie, qr.cookie].filter(Boolean).join('; '), createdAt: Date.now() })
294
+ sessions.set(sessionId, { scene, cookie: joinCookies(login.cookie, qr.cookie), createdAt: Date.now() })
116
295
  const encoded = String(qr.body.result)
117
296
  return { sessionId, qrCode: `data:${encoded.startsWith('/9j/') ? 'image/jpeg' : 'image/png'};base64,${encoded}`, status: 'WAITING' }
118
297
  }
@@ -121,19 +300,15 @@ async function pollLogin(session) {
121
300
  if (Date.now() - session.createdAt > 5 * 60_000) return { status: 'EXPIRED' }
122
301
  const statusResponse = await requestJson(queryUrl('/swj-platform-api/platformWxappUser/findRegisterStatus', { scene: session.scene }), { headers: session.cookie ? { cookie: session.cookie } : {} })
123
302
  const status = statusResponse.body.result || 'WAITING'
124
- session.cookie = [session.cookie, statusResponse.cookie].filter(Boolean).join('; ')
303
+ session.cookie = joinCookies(session.cookie, statusResponse.cookie)
125
304
  if (status !== 'SUCCESS') return { status }
126
305
  if (session.user) return { status: 'SUCCESS', user: session.user }
127
306
  const auth = await requestJson(queryUrl('/swj-platform-api/platformUser/authAI', { clientId: CLIENT_ID }), { headers: session.cookie ? { cookie: session.cookie } : {} })
128
307
  const validated = await requestJson(`${AI_URL}/swj-waterplusai-api/client/user/sso/validate?authInfo=${encodeURIComponent(String(auth.body.result).replace(/ /g, '+'))}&clientId=${CLIENT_ID}`)
129
- const token = validated.body.result
130
- const profile = await requestJson(`${AI_URL}/swj-waterplusai-api/client/user/info`, { headers: { Authorization: `Bearer ${token}` } })
308
+ const { token, user } = await finalizeLogin(extractToken(validated.body.result))
131
309
  session.token = token
132
- session.user = normalizeUser(profile.body.result)
133
- currentUser = session.user
134
- persistedAuth = { token, user: currentUser }
135
- savePersistedAuth(persistedAuth)
136
- return { status: 'SUCCESS', user: currentUser }
310
+ session.user = user
311
+ return { status: 'SUCCESS', user }
137
312
  }
138
313
 
139
314
  async function proxyMcp(req, res) {
@@ -163,10 +338,31 @@ async function proxyAvatar(req, res) {
163
338
  res.end()
164
339
  }
165
340
 
166
- export const inject = ['webServer', 'systemPrompt']
167
- export { normalizeAvatar }
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']
363
+ export { normalizeAvatar, setUpstreamTransport }
168
364
 
169
- export function apply(ctx) {
365
+ export function apply(ctx, config) {
170
366
  readPersistedAuth()
171
367
  ctx.effect(() => {
172
368
  const routes = [
@@ -174,14 +370,45 @@ export function apply(ctx) {
174
370
  { 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) } },
175
371
  { kind: 'exact', path: '/api/waterbuddy/auth/qr', handler: async (req, res) => { if (req.method !== 'POST') return json(res, 405, { error: 'method-not-allowed' }); try { json(res, 200, await createLogin()) } catch (error) { json(res, 502, { error: error.message }) } } },
176
372
  { kind: 'exact', path: '/api/waterbuddy/auth/status', handler: async (req, res) => { if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' }); const sessionId = new URL(req.url, 'http://127.0.0.1').searchParams.get('sessionId'); const session = sessionId && sessions.get(sessionId); if (!session) return json(res, 404, { error: 'session-not-found' }); try { json(res, 200, await pollLogin(session)) } catch (error) { json(res, 502, { error: error.message }) } } },
373
+ { kind: 'exact', path: '/api/waterbuddy/auth/login', handler: (req, res) => handleAuthAction(req, res, accountLogin) },
374
+ { kind: 'exact', path: '/api/waterbuddy/auth/register/code', handler: (req, res) => handleAuthAction(req, res, sendRegisterCode) },
375
+ { kind: 'exact', path: '/api/waterbuddy/auth/register', handler: (req, res) => handleAuthAction(req, res, registerAccount) },
376
+ { kind: 'exact', path: '/api/waterbuddy/auth/password/code', handler: (req, res) => handleAuthAction(req, res, sendPasswordCode) },
377
+ { kind: 'exact', path: '/api/waterbuddy/auth/password/change', handler: (req, res) => handleAuthAction(req, res, changePassword) },
177
378
  { kind: 'exact', path: '/api/waterbuddy/user', handler: (_req, res) => json(res, 200, { user: currentUser || null }) },
178
379
  { kind: 'exact', path: '/api/waterbuddy/avatar', handler: async (req, res) => { try { await proxyAvatar(req, res) } catch (error) { json(res, 502, { error: error.message }) } } },
179
- { 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 }) } },
180
401
  { kind: 'prefix', path: '/api/waterbuddy/mcp', handler: async (req, res) => { try { await proxyMcp(req, res) } catch (error) { json(res, 502, { error: error.message }) } } },
181
402
  ]
182
403
  const disposers = routes.map(route => ctx.webServer.register(route))
183
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" />'))
184
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
+ }
185
412
  return () => { for (const dispose of disposers) dispose(); disposeTitle(); disposePersona(); sessions.clear(); currentUser = undefined; persistedAuth = undefined }
186
413
  }, 'waterbuddy: identity and auth')
187
414
  }