@kukonce/dsh-loomy 0.0.0-stage → 0.3.1
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/CHANGELOG.md +119 -0
- package/LICENSE +26 -0
- package/README.md +215 -2
- package/client/index.js +1092 -0
- package/cordis.patch.yml +28 -0
- package/docs/PUBLISHING.md +87 -0
- package/docs/community-entry.yml +21 -0
- package/docs/models.md +74 -0
- package/docs/protocol.md +403 -0
- package/docs/verify/live-check.mjs +406 -0
- package/icon-mask.png +0 -0
- package/icon.png +0 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -3
- package/src/account/auth.js +297 -0
- package/src/account/credits.js +373 -0
- package/src/account/desktop-session.js +127 -0
- package/src/account/models.js +216 -0
- package/src/account/session.js +115 -0
- package/src/core/endpoints.js +78 -0
- package/src/core/http.js +124 -0
- package/src/core/paths.js +71 -0
- package/src/core/sign.js +166 -0
- package/src/index.js +429 -0
- package/src/llm/adapter.js +698 -0
- package/src/llm/endings.js +116 -0
- package/src/llm/sse.js +80 -0
- package/src/llm/wire.js +272 -0
- package/src/panel/api.js +661 -0
package/src/panel/api.js
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host HTTP surface for the Settings panel.
|
|
3
|
+
*
|
|
4
|
+
* Routes live under a top-level `/loomy` namespace — the same convention
|
|
5
|
+
* `dsh-host-open-in-app` uses with `/open-in-app` — deliberately outside `/api`,
|
|
6
|
+
* which the client-connection bridge owns as a prefix route. A prefix route under
|
|
7
|
+
* `/api` would be shadowed for every path the bridge also answers.
|
|
8
|
+
*
|
|
9
|
+
* Every route passes the connection trust fence first: only peers the running Web GUI
|
|
10
|
+
* already admits can read account state or start a login.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here ever answers with a session string. The account table on disk holds
|
|
13
|
+
* them; every payload below goes through `accountView()`, which is the only shape
|
|
14
|
+
* that reaches the browser.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash } from 'node:crypto'
|
|
18
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
19
|
+
import { readFile } from 'node:fs/promises'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
accountOf,
|
|
24
|
+
assertAccountId,
|
|
25
|
+
fetchUserInfo,
|
|
26
|
+
importDesktopAccount,
|
|
27
|
+
loginBySmsCode,
|
|
28
|
+
logoutRemote,
|
|
29
|
+
readAccounts,
|
|
30
|
+
removeAccount,
|
|
31
|
+
renameAccount,
|
|
32
|
+
sendSmsCode,
|
|
33
|
+
upsertAccount
|
|
34
|
+
} from '../account/auth.js'
|
|
35
|
+
import { describeSearchPaths, findDesktopSession } from '../account/desktop-session.js'
|
|
36
|
+
import { anchorLedger, consumedOnDay, dailyGrantState, ensureDailyGrant, fetchCredits, grantedToday } from '../account/credits.js'
|
|
37
|
+
import { loadCatalog, readCachedCatalog } from '../account/models.js'
|
|
38
|
+
import { clearExpired, expiredInfo, sessionForId, withSession } from '../account/session.js'
|
|
39
|
+
import { CLIENT_VERSION, GATEWAY_BASE_URL } from '../core/endpoints.js'
|
|
40
|
+
import { localDayKey } from '../core/http.js'
|
|
41
|
+
|
|
42
|
+
const API_PREFIX = '/loomy'
|
|
43
|
+
const MAX_BODY_BYTES = 16 * 1024
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The plugin's artwork, resolved from the package root (this file lives in `src/panel/`).
|
|
47
|
+
*
|
|
48
|
+
* Resolved through `import.meta.url` rather than `process.cwd()`: the host may run from
|
|
49
|
+
* any directory, and a relative path here fails at request time, not at import time —
|
|
50
|
+
* which is exactly how a missing logo reaches a release unnoticed.
|
|
51
|
+
*/
|
|
52
|
+
const ICON_PATH = fileURLToPath(new URL('../../icon.png', import.meta.url))
|
|
53
|
+
const MASK_PATH = fileURLToPath(new URL('../../icon-mask.png', import.meta.url))
|
|
54
|
+
let iconBytes
|
|
55
|
+
let maskBytes
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Which build of this file the running host actually loaded.
|
|
59
|
+
*
|
|
60
|
+
* A reload does not replace routes an earlier load registered — the host keeps serving
|
|
61
|
+
* the first registration it was given — so a panel talking to a stale instance answers
|
|
62
|
+
* `not found` on newer routes. Printing a content hash on mount makes the running build
|
|
63
|
+
* identifiable in the host log. A content hash rather than a timestamp: the host process
|
|
64
|
+
* may print in a different time zone than the file's mtime suggests.
|
|
65
|
+
*/
|
|
66
|
+
const CODE_STAMP = (() => {
|
|
67
|
+
try {
|
|
68
|
+
const self = fileURLToPath(import.meta.url)
|
|
69
|
+
const hash = createHash('sha256').update(readFileSync(self)).digest('hex').slice(0, 8)
|
|
70
|
+
return `api.js #${hash} (${statSync(self).mtime.toISOString().slice(0, 16)}Z)`
|
|
71
|
+
} catch {
|
|
72
|
+
return 'api.js (unidentified)'
|
|
73
|
+
}
|
|
74
|
+
})()
|
|
75
|
+
|
|
76
|
+
/** JSON answer with the two headers every panel payload needs. */
|
|
77
|
+
function sendJson(res, status, payload) {
|
|
78
|
+
res.statusCode = status
|
|
79
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
80
|
+
// Account state and balances are per-user and change under the panel's feet: a cached
|
|
81
|
+
// copy in the WebView would keep showing a balance the user just spent.
|
|
82
|
+
res.setHeader('cache-control', 'no-store')
|
|
83
|
+
res.end(JSON.stringify(payload))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Collect a bounded JSON body; `undefined` when it is oversized or not a JSON object. */
|
|
87
|
+
async function readJsonBody(req) {
|
|
88
|
+
const chunks = []
|
|
89
|
+
let size = 0
|
|
90
|
+
for await (const chunk of req) {
|
|
91
|
+
size += chunk.byteLength
|
|
92
|
+
if (size > MAX_BODY_BYTES) return undefined
|
|
93
|
+
chunks.push(chunk)
|
|
94
|
+
}
|
|
95
|
+
if (size === 0) return {}
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
98
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : undefined
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `133****3805` — enough to tell two accounts apart, not enough to be a phone number. */
|
|
105
|
+
function maskPhone(phone) {
|
|
106
|
+
const digits = String(phone ?? '').trim()
|
|
107
|
+
if (digits.length === 0) return ''
|
|
108
|
+
if (digits.length < 8) return '****'
|
|
109
|
+
return `${digits.slice(0, 3)}****${digits.slice(-4)}`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A number the panel can print; NaN must never reach the UI. */
|
|
113
|
+
const finite = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The credit fields the panel shows, or `null` when nothing has been read yet.
|
|
117
|
+
*
|
|
118
|
+
* Kept narrow on purpose: the full `fetchCredits` answer also carries the invite code
|
|
119
|
+
* and the cycle bookkeeping, none of which the card renders, and every extra field is
|
|
120
|
+
* one more thing to widen by accident later.
|
|
121
|
+
*/
|
|
122
|
+
function creditsSummary(credits) {
|
|
123
|
+
if (credits === null || credits === undefined || typeof credits !== 'object') return null
|
|
124
|
+
return {
|
|
125
|
+
ok: credits.ok !== false,
|
|
126
|
+
balance: finite(credits.balance),
|
|
127
|
+
permanentBalance: finite(credits.permanentBalance),
|
|
128
|
+
dailyBalance: finite(credits.dailyBalance),
|
|
129
|
+
dailyQuota: finite(credits.dailyQuota),
|
|
130
|
+
dailyConsumed: finite(credits.dailyConsumed),
|
|
131
|
+
availableBalance: finite(credits.availableBalance),
|
|
132
|
+
dailyCycleDate: typeof credits.dailyCycleDate === 'string' ? credits.dailyCycleDate : '',
|
|
133
|
+
...(typeof credits.error === 'string' ? { error: credits.error } : {})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One account as the panel may see it.
|
|
139
|
+
*
|
|
140
|
+
* The session is intentionally absent — this function is the boundary — and the phone
|
|
141
|
+
* arrives masked. Everything else is stored metadata or a count read from the catalog
|
|
142
|
+
* cache: no field here costs a network round trip, because `/state` is called on every
|
|
143
|
+
* panel open and after every action.
|
|
144
|
+
*/
|
|
145
|
+
function accountView(account, adapters) {
|
|
146
|
+
const cached = readCachedCatalog(account.id)
|
|
147
|
+
const grant = dailyGrantState(account.id)
|
|
148
|
+
const expired = expiredInfo(account.id)
|
|
149
|
+
const day = localDayKey()
|
|
150
|
+
return {
|
|
151
|
+
id: account.id,
|
|
152
|
+
label: account.label,
|
|
153
|
+
phone: maskPhone(account.phone),
|
|
154
|
+
userid: account.userid,
|
|
155
|
+
source: account.source,
|
|
156
|
+
loggedInAt: finite(account.loggedInAt),
|
|
157
|
+
signedIn: account.session.length > 0,
|
|
158
|
+
expired: expired !== undefined,
|
|
159
|
+
expiredAt: finite(expired?.at),
|
|
160
|
+
expiredReason: typeof expired?.reason === 'string' ? expired.reason : '',
|
|
161
|
+
provider: `@loomy/dsh-loomy/${account.id}`,
|
|
162
|
+
mounted: adapters[account.id] !== undefined,
|
|
163
|
+
models: Array.isArray(cached?.entries) ? cached.entries.length : 0,
|
|
164
|
+
modelsCachedAt: finite(cached?.savedAt),
|
|
165
|
+
// The grant ledger is the last recorded claim, not a live balance: it is what the
|
|
166
|
+
// panel needs to word the button ("今日已领取"), not what it prints as the balance.
|
|
167
|
+
dailyGrant: grant === undefined ? null : {
|
|
168
|
+
day: grant.day,
|
|
169
|
+
at: finite(grant.at),
|
|
170
|
+
granted: grant.granted === true,
|
|
171
|
+
balance: finite(grant.balance),
|
|
172
|
+
quota: finite(grant.quota),
|
|
173
|
+
reason: typeof grant.reason === 'string' ? grant.reason : '',
|
|
174
|
+
...(typeof grant.error === 'string' ? { error: grant.error } : {})
|
|
175
|
+
},
|
|
176
|
+
grantedToday: grantedToday(account.id),
|
|
177
|
+
credits: grant === undefined ? null : creditsSummary(projectedCredits(grant, account.id, day)),
|
|
178
|
+
// Milliseconds, for clients that must judge staleness against one clock:
|
|
179
|
+
// Date.now() in the WebView can drift from this process by minutes.
|
|
180
|
+
serverNow: Date.now()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The ledger's claim-time pot minus what chat has spent since, as far as this
|
|
186
|
+
* process knows.
|
|
187
|
+
*
|
|
188
|
+
* The adapter books each turn's `points_consumed` into `usage.json` the moment the
|
|
189
|
+
* gateway's accounting frame arrives, so this projection tracks chatting in real
|
|
190
|
+
* time. It is still an estimate between sync points — a turn billed outside this
|
|
191
|
+
* process (onboarding tasks, another machine on the account) or a failed spend
|
|
192
|
+
* write is only corrected when the next claim re-reads the server. `spent` is
|
|
193
|
+
* clamped to the pot so a stale spend file can never push the display negative.
|
|
194
|
+
*/
|
|
195
|
+
function projectedCredits(grant, accountId, day) {
|
|
196
|
+
const pot = finite(grant.balance)
|
|
197
|
+
const spent = grant.day === day ? consumedOnDay(accountId, day) : 0
|
|
198
|
+
const remaining = Math.max(0, pot - Math.min(spent, pot))
|
|
199
|
+
return {
|
|
200
|
+
ok: grant.granted === true,
|
|
201
|
+
balance: grant.balance,
|
|
202
|
+
dailyBalance: remaining,
|
|
203
|
+
dailyQuota: grant.quota,
|
|
204
|
+
// The server's number for this is quota - dailyBalance; the same arithmetic here
|
|
205
|
+
// also counts what chat spent after the claim.
|
|
206
|
+
dailyConsumed: Math.max(0, finite(grant.quota) - remaining),
|
|
207
|
+
availableBalance: remaining,
|
|
208
|
+
dailyCycleDate: day,
|
|
209
|
+
...(typeof grant.error === 'string' ? { error: grant.error } : {})
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The model the picker should offer by default, from cached catalogs only.
|
|
215
|
+
*
|
|
216
|
+
* No network here: `/state` is on the panel's critical path, and a default model is a
|
|
217
|
+
* display detail that a stale answer still gets right.
|
|
218
|
+
*/
|
|
219
|
+
function defaultModelOf(adapters) {
|
|
220
|
+
for (const id of Object.keys(adapters)) {
|
|
221
|
+
const declared = adapters[id]?.defaultModel
|
|
222
|
+
if (typeof declared === 'string' && declared.length > 0) return declared
|
|
223
|
+
const entries = readCachedCatalog(id)?.entries
|
|
224
|
+
const preferred = Array.isArray(entries) ? entries.find((entry) => entry?.isDefault === true) : undefined
|
|
225
|
+
if (preferred !== undefined) return preferred.id
|
|
226
|
+
}
|
|
227
|
+
for (const id of Object.keys(adapters)) {
|
|
228
|
+
const entries = readCachedCatalog(id)?.entries
|
|
229
|
+
if (Array.isArray(entries) && entries.length > 0) return entries[0].id
|
|
230
|
+
}
|
|
231
|
+
return ''
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Whether the Loomy desktop app has a session parked on this machine. */
|
|
235
|
+
function desktopAvailable() {
|
|
236
|
+
try {
|
|
237
|
+
return findDesktopSession() !== undefined
|
|
238
|
+
} catch {
|
|
239
|
+
return false
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The panel's full snapshot. */
|
|
244
|
+
function statePayload(adapters) {
|
|
245
|
+
return {
|
|
246
|
+
accounts: readAccounts().map((account) => accountView(account, adapters)),
|
|
247
|
+
defaultModel: defaultModelOf(adapters),
|
|
248
|
+
gateway: GATEWAY_BASE_URL,
|
|
249
|
+
version: CLIENT_VERSION,
|
|
250
|
+
desktop: {
|
|
251
|
+
available: desktopAvailable(),
|
|
252
|
+
// The import button is a dead end on a machine that never ran Loomy, and its
|
|
253
|
+
// failure message has to name where the plugin looked.
|
|
254
|
+
paths: describeSearchPaths()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Map one catalog entry to the subset the panel renders. */
|
|
260
|
+
function modelView(entry) {
|
|
261
|
+
return {
|
|
262
|
+
id: entry.id,
|
|
263
|
+
name: entry.name ?? entry.id,
|
|
264
|
+
// 'image' entries share the chat URL but 404 on it, so the panel lists them apart
|
|
265
|
+
// from the ones it offers for a conversation.
|
|
266
|
+
type: entry.type ?? 'chat',
|
|
267
|
+
chat: entry.type !== 'image',
|
|
268
|
+
protocol: entry.protocol,
|
|
269
|
+
contextLength: finite(entry.contextLength),
|
|
270
|
+
maxOutputTokens: finite(entry.maxOutputTokens),
|
|
271
|
+
input: Array.isArray(entry.input) ? entry.input : ['text'],
|
|
272
|
+
vision: entry.vision === true,
|
|
273
|
+
toolCalls: entry.toolCalls === true,
|
|
274
|
+
reasoning: entry.reasoning === true,
|
|
275
|
+
efforts: Array.isArray(entry.efforts) ? entry.efforts : [],
|
|
276
|
+
defaultEffort: typeof entry.defaultEffort === 'string' ? entry.defaultEffort : '',
|
|
277
|
+
isDefault: entry.isDefault === true
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The live model catalog for one account.
|
|
283
|
+
*
|
|
284
|
+
* The mounted adapter wins when there is one: its list is the merged one the model
|
|
285
|
+
* picker reads, so the panel and the picker can never disagree about what is offered.
|
|
286
|
+
* The account-level catalog is the fallback for a composition where the adapter for
|
|
287
|
+
* this id is not mounted (an account added to config without a restart).
|
|
288
|
+
*/
|
|
289
|
+
async function modelsPayload(accountId, adapters, options = {}) {
|
|
290
|
+
const adapter = adapters[accountId]
|
|
291
|
+
if (adapter !== undefined && typeof adapter.models === 'function') {
|
|
292
|
+
if (options.refresh === true && typeof adapter.invalidateCatalog === 'function') adapter.invalidateCatalog()
|
|
293
|
+
const entries = await adapter.models()
|
|
294
|
+
return {
|
|
295
|
+
id: accountId,
|
|
296
|
+
source: typeof adapter.catalogSource === 'string' ? adapter.catalogSource : 'adapter',
|
|
297
|
+
// `catalogCachedAt` is what the WorkBuddy adapter calls it; this one calls its
|
|
298
|
+
// in-memory timestamp `catalogAt`. Both are read so neither adapter shape breaks
|
|
299
|
+
// the panel's "as of" line.
|
|
300
|
+
cachedAt: finite(adapter.catalogCachedAt ?? adapter.catalogAt),
|
|
301
|
+
models: (Array.isArray(entries) ? entries : []).map(modelView)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const { session } = sessionForId(accountId)
|
|
305
|
+
const catalog = await loadCatalog(accountId, session, { force: options.refresh === true })
|
|
306
|
+
return {
|
|
307
|
+
id: accountId,
|
|
308
|
+
source: catalog.source,
|
|
309
|
+
cachedAt: finite(catalog.savedAt),
|
|
310
|
+
models: (Array.isArray(catalog.entries) ? catalog.entries : []).map(modelView)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Validate an account id a route was handed, with an error the panel can show.
|
|
316
|
+
*
|
|
317
|
+
* An omitted id means "the account this panel is about", which for every caller so far
|
|
318
|
+
* is the first one in the table. Requiring it would make a single-account install pass
|
|
319
|
+
* an id on every request to say nothing — and the panel's own default fetch does not.
|
|
320
|
+
*/
|
|
321
|
+
function accountIdParam(raw) {
|
|
322
|
+
const value = String(raw ?? '').trim()
|
|
323
|
+
if (value.length === 0) {
|
|
324
|
+
const first = readAccounts()[0]
|
|
325
|
+
if (first === undefined) throw new Error('还没有登录任何 Loomy 账号')
|
|
326
|
+
return first.id
|
|
327
|
+
}
|
|
328
|
+
return assertAccountId(value)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Register the panel's routes. Call inside `ctx.inject(['webServer'], …)` so a
|
|
333
|
+
* composition without an HTTP carrier keeps the provider routes and the command.
|
|
334
|
+
*
|
|
335
|
+
* The second argument accepts either shape the plugin has been wired with: the bare
|
|
336
|
+
* adapter map (`{ [accountId]: adapter }`) or the host bundle
|
|
337
|
+
* `{ adapters, mountAccount, unmountAccount }`. Both were live at different points while
|
|
338
|
+
* this plugin was being written, and a mismatch fails only at the moment a route runs —
|
|
339
|
+
* so the normalization is here, once, rather than trusting the caller.
|
|
340
|
+
*
|
|
341
|
+
* @param {object} webCtx - host plugin context that carries `webServer`.
|
|
342
|
+
* @param {Record<string, object>|{ adapters: Record<string, object>, mountAccount?: Function, unmountAccount?: Function }} wiring - per-account LLM adapters, or the host bundle.
|
|
343
|
+
* @returns {() => void} the disposer the caller registers with `webCtx.effect`.
|
|
344
|
+
*/
|
|
345
|
+
export function registerLoomyApi(webCtx, wiring) {
|
|
346
|
+
const bundle = wiring !== null && typeof wiring === 'object' && typeof wiring.adapters === 'object' && wiring.adapters !== null
|
|
347
|
+
? wiring
|
|
348
|
+
: { adapters: wiring ?? {} }
|
|
349
|
+
const catalogue = bundle.adapters
|
|
350
|
+
const mountAccount = typeof bundle.mountAccount === 'function' ? bundle.mountAccount : undefined
|
|
351
|
+
const unmountAccount = typeof bundle.unmountAccount === 'function' ? bundle.unmountAccount : undefined
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The trust fence, identical to the one every other panel route in this host uses:
|
|
355
|
+
* ask the connection service whether it would reject this request, and let it answer
|
|
356
|
+
* with the status code it wants.
|
|
357
|
+
*/
|
|
358
|
+
/**
|
|
359
|
+
* Mount the provider route for an account a route just created.
|
|
360
|
+
*
|
|
361
|
+
* Failures are logged and swallowed: the account is already stored at this point, so
|
|
362
|
+
* turning a mount problem into a login failure would make the user retry a login that
|
|
363
|
+
* in fact succeeded. The panel's `mounted` flag says the route is missing.
|
|
364
|
+
*/
|
|
365
|
+
const mountSafely = (account) => {
|
|
366
|
+
if (mountAccount === undefined || account === undefined) return
|
|
367
|
+
if (catalogue[account.id] !== undefined) return
|
|
368
|
+
try {
|
|
369
|
+
mountAccount(account)
|
|
370
|
+
} catch (error) {
|
|
371
|
+
webCtx.logger?.warn?.(`[loomy] ${account.id}: 挂载 provider 路由失败 — ${error instanceof Error ? error.message : String(error)}`)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const rejected = (req, res) => {
|
|
376
|
+
const connection = webCtx.get('connection')
|
|
377
|
+
if (connection === undefined || typeof connection.requestRejection !== 'function') return false
|
|
378
|
+
const rejection = connection.requestRejection(req)
|
|
379
|
+
if (rejection === undefined) return false
|
|
380
|
+
res.statusCode = rejection
|
|
381
|
+
res.end()
|
|
382
|
+
return true
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const handler = async (req, res) => {
|
|
386
|
+
const url = new URL(String(req.url ?? '/'), 'http://localhost')
|
|
387
|
+
const method = req.method ?? 'GET'
|
|
388
|
+
if (rejected(req, res)) return
|
|
389
|
+
const path = url.pathname
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
if (path === `${API_PREFIX}/state` && method === 'GET') {
|
|
393
|
+
sendJson(res, 200, statePayload(catalogue))
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// The packaged desktop app has no devtools, so the panel reports its load stages
|
|
398
|
+
// here and they land in the host log. That is how "the bundle never loaded" stays
|
|
399
|
+
// distinguishable from "loaded, but apply() threw".
|
|
400
|
+
if (path === `${API_PREFIX}/client-log` && method === 'POST') {
|
|
401
|
+
const body = await readJsonBody(req)
|
|
402
|
+
webCtx.logger?.info?.(`[loomy] client half: ${JSON.stringify(body ?? {})}`)
|
|
403
|
+
sendJson(res, 200, { ok: true })
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The artwork, twice: the opaque mark for surfaces that want a fixed colour, and
|
|
409
|
+
* the alpha-only silhouette the panel paints through a CSS mask so one asset works
|
|
410
|
+
* in both themes.
|
|
411
|
+
*/
|
|
412
|
+
if ((path === `${API_PREFIX}/icon.png` || path === `${API_PREFIX}/icon-mask.png`) && method === 'GET') {
|
|
413
|
+
const isMask = path.endsWith('icon-mask.png')
|
|
414
|
+
try {
|
|
415
|
+
if (isMask) maskBytes ??= await readFile(MASK_PATH)
|
|
416
|
+
else iconBytes ??= await readFile(ICON_PATH)
|
|
417
|
+
res.statusCode = 200
|
|
418
|
+
// Both assets are PNG (the encoder in scripts/ only writes PNG); serving one as
|
|
419
|
+
// image/webp would make the browser refuse to render it.
|
|
420
|
+
res.setHeader('content-type', 'image/png')
|
|
421
|
+
// The artwork never changes within a build, so it may be cached; the payloads
|
|
422
|
+
// above may not.
|
|
423
|
+
res.setHeader('cache-control', 'public, max-age=3600')
|
|
424
|
+
res.end(isMask ? maskBytes : iconBytes)
|
|
425
|
+
} catch {
|
|
426
|
+
// No artwork in this build is a 404, not an error: the panel renders without a
|
|
427
|
+
// logo rather than refusing to open.
|
|
428
|
+
res.statusCode = 404
|
|
429
|
+
res.end()
|
|
430
|
+
}
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (path === `${API_PREFIX}/models`) {
|
|
435
|
+
if (method !== 'GET') {
|
|
436
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const id = accountIdParam(url.searchParams.get('id'))
|
|
441
|
+
const payload = await modelsPayload(id, catalogue, { refresh: url.searchParams.get('refresh') === '1' })
|
|
442
|
+
sendJson(res, 200, payload)
|
|
443
|
+
} catch (error) {
|
|
444
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
445
|
+
}
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (path === `${API_PREFIX}/credits`) {
|
|
450
|
+
if (method !== 'GET') {
|
|
451
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const id = accountIdParam(url.searchParams.get('id'))
|
|
456
|
+
// `fetchCredits` is the per-day idempotent first-login claim: reading the
|
|
457
|
+
// balance also renews the daily pot, so a balance refresh is never a wasted call.
|
|
458
|
+
const credits = await withSession(id, (session) => fetchCredits(session))
|
|
459
|
+
// Re-anchor the ledger from the live answer so the pill's projection agrees
|
|
460
|
+
// the moment the user has checked the real number against it.
|
|
461
|
+
anchorLedger(id, credits, '刷新积分')
|
|
462
|
+
sendJson(res, 200, {
|
|
463
|
+
id,
|
|
464
|
+
credits: creditsSummary(credits),
|
|
465
|
+
dailyGrant: dailyGrantState(id) ?? null,
|
|
466
|
+
grantedToday: grantedToday(id)
|
|
467
|
+
})
|
|
468
|
+
} catch (error) {
|
|
469
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
470
|
+
}
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The manual fallback for the daily 5000. The host claims on startup and at local
|
|
476
|
+
* midnight; this route exists because those timers cannot run while the app was
|
|
477
|
+
* closed across a day boundary, and because a failed automatic claim is invisible
|
|
478
|
+
* until somebody asks.
|
|
479
|
+
*/
|
|
480
|
+
if (path === `${API_PREFIX}/credits/claim` && method === 'POST') {
|
|
481
|
+
const body = await readJsonBody(req)
|
|
482
|
+
if (body === undefined) {
|
|
483
|
+
sendJson(res, 400, { error: 'invalid JSON body' })
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const id = accountIdParam(body.id)
|
|
488
|
+
const outcome = await withSession(id, (session) => ensureDailyGrant(id, session, { force: true, reason: '手动' }))
|
|
489
|
+
webCtx.logger?.info?.(`[loomy] ${id}: 手动领取每日积分 ${outcome.granted === true ? '成功' : '未生效'}${outcome.error === undefined ? '' : `(${outcome.error})`}`)
|
|
490
|
+
sendJson(res, 200, {
|
|
491
|
+
id,
|
|
492
|
+
granted: outcome.granted === true,
|
|
493
|
+
skipped: outcome.skipped === true,
|
|
494
|
+
credits: creditsSummary(outcome),
|
|
495
|
+
dailyGrant: dailyGrantState(id) ?? null,
|
|
496
|
+
grantedToday: grantedToday(id)
|
|
497
|
+
})
|
|
498
|
+
} catch (error) {
|
|
499
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
500
|
+
}
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (path === `${API_PREFIX}/login/send` && method === 'POST') {
|
|
505
|
+
const body = await readJsonBody(req)
|
|
506
|
+
if (body === undefined) {
|
|
507
|
+
sendJson(res, 400, { error: 'invalid JSON body' })
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
const result = await sendSmsCode(body.phone)
|
|
512
|
+
sendJson(res, 200, { phone: maskPhone(result.phone), msgid: result.msgid, expire: result.expire })
|
|
513
|
+
} catch (error) {
|
|
514
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
515
|
+
}
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Exchange the SMS code for a session. The answer is the stored account record —
|
|
521
|
+
* never the session string, not even masked.
|
|
522
|
+
*/
|
|
523
|
+
if (path === `${API_PREFIX}/login/verify` && method === 'POST') {
|
|
524
|
+
const body = await readJsonBody(req)
|
|
525
|
+
if (body === undefined) {
|
|
526
|
+
sendJson(res, 400, { error: 'invalid JSON body' })
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const label = typeof body.label === 'string' ? body.label : ''
|
|
531
|
+
const loggedIn = await loginBySmsCode({ phone: body.phone, code: body.code, msgid: body.msgid, label })
|
|
532
|
+
const record = upsertAccount({
|
|
533
|
+
session: loggedIn.session,
|
|
534
|
+
userid: loggedIn.userid,
|
|
535
|
+
phone: loggedIn.phone,
|
|
536
|
+
label,
|
|
537
|
+
source: 'sms'
|
|
538
|
+
})
|
|
539
|
+
// The profile read only fills in a display name and a canonical phone; a
|
|
540
|
+
// failure here must not turn a successful login into an error.
|
|
541
|
+
try {
|
|
542
|
+
const info = await fetchUserInfo(loggedIn.session)
|
|
543
|
+
if (info.phone.length > 0 && info.phone !== record.phone) {
|
|
544
|
+
upsertAccount({
|
|
545
|
+
session: record.session,
|
|
546
|
+
userid: record.userid,
|
|
547
|
+
phone: info.phone,
|
|
548
|
+
label: record.label,
|
|
549
|
+
source: record.source
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
} catch {
|
|
553
|
+
// keep the login, lose the profile
|
|
554
|
+
}
|
|
555
|
+
clearExpired(record.id)
|
|
556
|
+
const stored = accountOf(record.id) ?? record
|
|
557
|
+
// The provider route is mounted per account, so a login has to create one now:
|
|
558
|
+
// otherwise the panel reports a working account that the model picker cannot
|
|
559
|
+
// offer until the host restarts.
|
|
560
|
+
mountSafely(stored)
|
|
561
|
+
webCtx.logger?.info?.(`[loomy] ${stored.id}: 短信登录成功(${maskPhone(stored.phone)})`)
|
|
562
|
+
sendJson(res, 200, { account: accountView(stored, catalogue) })
|
|
563
|
+
} catch (error) {
|
|
564
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
565
|
+
}
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Adopt the login the Loomy desktop app already has on this machine. */
|
|
570
|
+
if (path === `${API_PREFIX}/login/import` && method === 'POST') {
|
|
571
|
+
const body = await readJsonBody(req)
|
|
572
|
+
if (body === undefined) {
|
|
573
|
+
sendJson(res, 400, { error: 'invalid JSON body' })
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
const imported = await importDesktopAccount({ label: typeof body.label === 'string' ? body.label : '' })
|
|
578
|
+
clearExpired(imported.record.id)
|
|
579
|
+
mountSafely(imported.record)
|
|
580
|
+
webCtx.logger?.info?.(`[loomy] ${imported.record.id}: 已导入桌面端登录态(来自 ${imported.path})`)
|
|
581
|
+
sendJson(res, 200, {
|
|
582
|
+
account: accountView(imported.record, catalogue),
|
|
583
|
+
// A filesystem location, not a credential: the panel prints it as the
|
|
584
|
+
// confirmation of where the session came from.
|
|
585
|
+
path: imported.path
|
|
586
|
+
})
|
|
587
|
+
} catch (error) {
|
|
588
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
589
|
+
}
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (path === `${API_PREFIX}/logout` && method === 'POST') {
|
|
594
|
+
const body = await readJsonBody(req)
|
|
595
|
+
if (body === undefined) {
|
|
596
|
+
sendJson(res, 400, { error: 'invalid JSON body' })
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
const id = accountIdParam(body.id)
|
|
601
|
+
const account = accountOf(id)
|
|
602
|
+
if (account === undefined) {
|
|
603
|
+
sendJson(res, 404, { error: `未找到账号 "${id}"` })
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
// Server-side retirement is best effort: a session the server already forgot is
|
|
607
|
+
// still a session this plugin should drop locally, and a network failure must
|
|
608
|
+
// not leave the user unable to sign out.
|
|
609
|
+
const remote = await logoutRemote(account.session)
|
|
610
|
+
removeAccount(id)
|
|
611
|
+
clearExpired(id)
|
|
612
|
+
// Retire the route too: leaving it registered would keep offering a provider
|
|
613
|
+
// whose session this plugin just deleted.
|
|
614
|
+
try {
|
|
615
|
+
unmountAccount?.(id)
|
|
616
|
+
} catch (error) {
|
|
617
|
+
webCtx.logger?.warn?.(`[loomy] ${id}: 卸载 provider 路由失败 — ${error instanceof Error ? error.message : String(error)}`)
|
|
618
|
+
}
|
|
619
|
+
webCtx.logger?.info?.(`[loomy] ${id}: 已退出登录(远端${remote.ok === true ? '已注销' : '未确认'})`)
|
|
620
|
+
sendJson(res, 200, { id, removed: true, remote })
|
|
621
|
+
} catch (error) {
|
|
622
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
623
|
+
}
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Rename one account. The id stays: routes and cache files hang off it. */
|
|
628
|
+
if (path === `${API_PREFIX}/label` && method === 'POST') {
|
|
629
|
+
const body = await readJsonBody(req)
|
|
630
|
+
if (body === undefined) {
|
|
631
|
+
sendJson(res, 400, { error: 'invalid JSON body' })
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
try {
|
|
635
|
+
const id = accountIdParam(body.id)
|
|
636
|
+
const updated = renameAccount(id, typeof body.label === 'string' ? body.label : '')
|
|
637
|
+
if (updated === undefined) {
|
|
638
|
+
sendJson(res, 404, { error: `未找到账号 "${id}"` })
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
sendJson(res, 200, { account: accountView(updated, catalogue) })
|
|
642
|
+
} catch (error) {
|
|
643
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
644
|
+
}
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Naming the path matters: the host keeps serving the *first* registration it got,
|
|
649
|
+
// so after a reload a request can still land on an older build that simply does not
|
|
650
|
+
// know the route.
|
|
651
|
+
webCtx.logger?.warn?.(`[loomy] 未匹配的面板请求 ${method} ${path}(当前挂载的是 ${CODE_STAMP})`)
|
|
652
|
+
sendJson(res, 404, { error: 'not found' })
|
|
653
|
+
} catch (error) {
|
|
654
|
+
webCtx.logger?.warn?.(`[loomy] API ${method} ${path} failed — ${error instanceof Error ? error.message : String(error)}`)
|
|
655
|
+
if (!res.headersSent) sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
webCtx.logger?.info?.(`[loomy] 面板路由已挂载(${CODE_STAMP})`)
|
|
660
|
+
return webCtx.webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
|
|
661
|
+
}
|