@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/README.md +11 -2
- package/lib/client/index.js +466 -36
- package/lib/host/index.js +250 -23
- package/lib/host/llm-gateway.js +424 -0
- package/package.json +2 -1
- package/patches/cordis.patch.yml +67 -0
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
|
-
|
|
29
|
-
fs.
|
|
30
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
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 =
|
|
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:
|
|
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 =
|
|
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 =
|
|
133
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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/
|
|
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
|
}
|