@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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Adopt the login the Loomy desktop app already has.
3
+ *
4
+ * The desktop client keeps its session in an `electron-store` file called
5
+ * `auth-session.json`, next to its other per-user state. On Windows that state does
6
+ * not live in `%APPDATA%`: Loomy relocates it to
7
+ * `C:\Users\Public\Loomy\<sha256(username)[:12]>\userData\`, so the account
8
+ * directory has to be derived from the current user rather than guessed.
9
+ *
10
+ * That directory sits under `C:\Users\Public`, which is shared by every local account
11
+ * on the machine. Only this user's hashed directory is ever read: see
12
+ * `candidateDirectories` for why enumeration would be wrong. An explicit directory is
13
+ * honoured when the caller names one, because that is the user's own decision.
14
+ *
15
+ * The file is plain JSON (`{ session, userid, phone, updatedAt }`) — it is not
16
+ * encrypted, which is why this import is a convenience and not a security boundary.
17
+ *
18
+ * Nothing here writes to the desktop app's files: the import is read-only, and the
19
+ * copy this plugin keeps lives in its own directory.
20
+ */
21
+
22
+ import { createHash } from 'node:crypto'
23
+ import { existsSync, readFileSync, statSync } from 'node:fs'
24
+ import { homedir, userInfo } from 'node:os'
25
+ import { join } from 'node:path'
26
+
27
+ const SESSION_FILE = 'auth-session.json'
28
+
29
+ /** The hash Loomy derives its per-user config root from. */
30
+ function userHash() {
31
+ try {
32
+ return createHash('sha256').update(userInfo().username).digest('hex').slice(0, 12)
33
+ } catch {
34
+ return ''
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Candidate `userData` directories belonging to the current user, most specific first.
40
+ *
41
+ * Deliberately NOT a scan of `C:\Users\Public\Loomy\*`. That root is machine-wide, so
42
+ * every local user's session sits beside ours and "the most recently updated one" is
43
+ * simply "whoever ran Loomy last" — importing it would hand this plugin someone else's
44
+ * account. The hashed directory for the current username is ours by construction; the
45
+ * Electron default under `%APPDATA%` is per-user too.
46
+ *
47
+ * The un-hashed `C:\Users\Public\Loomy\userData` root is left out for the same reason.
48
+ * A user whose client really uses that layout can still point at it explicitly
49
+ * (`/loomy import <directory>`), which is a choice rather than a guess.
50
+ */
51
+ function candidateDirectories() {
52
+ const dirs = []
53
+ if (process.platform === 'win32') {
54
+ const hash = userHash()
55
+ if (hash.length > 0) dirs.push(join('C:\\Users\\Public\\Loomy', hash, 'userData'))
56
+ // An older or differently-configured install may still use the Electron default,
57
+ // which is per-user and therefore safe to include.
58
+ const roaming = process.env.APPDATA
59
+ if (typeof roaming === 'string' && roaming.length > 0) dirs.push(join(roaming, 'Loomy'))
60
+ } else if (process.platform === 'darwin') {
61
+ dirs.push(join(homedir(), 'Library', 'Application Support', 'Loomy'))
62
+ } else {
63
+ dirs.push(join(homedir(), '.config', 'loomy'))
64
+ }
65
+ return dirs
66
+ }
67
+
68
+ /**
69
+ * Read one `auth-session.json` and answer a usable record, or `undefined`.
70
+ *
71
+ * A session without a `userid` is useless — the points API keys balances by user, and
72
+ * the model list is scoped per account — so it is treated as absent.
73
+ */
74
+ function readSessionFile(path) {
75
+ try {
76
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
77
+ const session = typeof parsed?.session === 'string' ? parsed.session.trim() : ''
78
+ const userid = typeof parsed?.userid === 'string' ? parsed.userid.trim() : String(parsed?.userid ?? '').trim()
79
+ if (session.length === 0 || userid.length === 0) return undefined
80
+ return {
81
+ session,
82
+ userid,
83
+ phone: typeof parsed?.phone === 'string' ? parsed.phone.trim() : '',
84
+ updatedAt: typeof parsed?.updatedAt === 'number' ? parsed.updatedAt : 0,
85
+ path
86
+ }
87
+ } catch {
88
+ return undefined
89
+ }
90
+ }
91
+
92
+ /** Accept a `userData` directory or the session file itself. */
93
+ function asSessionFile(target) {
94
+ try {
95
+ if (statSync(target).isDirectory()) return join(target, SESSION_FILE)
96
+ } catch {
97
+ // missing path — let the read report it
98
+ }
99
+ return target
100
+ }
101
+
102
+ /**
103
+ * The most recently updated desktop session for this user, or `undefined`.
104
+ *
105
+ * `directory` is only used for an explicit, user-requested import; without it the
106
+ * search stays inside this user's own directories.
107
+ */
108
+ export function findDesktopSession(directory) {
109
+ const explicit = typeof directory === 'string' ? directory.trim() : ''
110
+ if (explicit.length > 0) return readSessionFile(asSessionFile(explicit))
111
+
112
+ const candidates = []
113
+ for (const dir of candidateDirectories()) {
114
+ const record = readSessionFile(join(dir, SESSION_FILE))
115
+ if (record !== undefined) candidates.push(record)
116
+ }
117
+ if (candidates.length === 0) return undefined
118
+ // "Most recent" rather than "first": the hashed and the `%APPDATA%` layouts can both
119
+ // exist after an app update, and the stale one still parses cleanly.
120
+ candidates.sort((left, right) => right.updatedAt - left.updatedAt)
121
+ return candidates[0]
122
+ }
123
+
124
+ /** Where the desktop client keeps its state, for the panel's "not found" message. */
125
+ export function describeSearchPaths() {
126
+ return candidateDirectories()
127
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The live model catalog.
3
+ *
4
+ * `GET /api/v1/models` answers a **bare list**, not an envelope:
5
+ *
6
+ * { object: "list", reasoning_catalog_version, data: [ { id, name, protocol,
7
+ * context_length, max_output_tokens, capabilities: { reasoning, vision,
8
+ * function_calling, streaming, system_message, input_modalities }, reasoning_efforts,
9
+ * default_reasoning_effort, is_default_model, is_default_small_model } ] }
10
+ *
11
+ * Two things about it are easy to get wrong, so they are handled once here:
12
+ *
13
+ * 1. It authenticates with the `token` header, like the rest of the points API —
14
+ * *not* with `Authorization: Bearer`. The gateway is the opposite.
15
+ * 2. The list is scoped to the account: a model this account may not use is simply
16
+ * absent, so it is the only honest source of "what can I pick".
17
+ *
18
+ * The list is cached on disk for a day. A live failure then serves the last good copy
19
+ * instead of an empty picker, which is the difference between "your account is broken"
20
+ * and "the catalog endpoint had a bad minute".
21
+ */
22
+
23
+ import { API_BASE_URL, CLIENT_VERSION } from '../core/endpoints.js'
24
+ import { requestJson } from '../core/http.js'
25
+ import { readJson, writeJson } from '../core/paths.js'
26
+
27
+ const CATALOG_FILE = 'catalog.json'
28
+ const CATALOG_TTL_MS = 20 * 60_000
29
+
30
+ const finiteInt = (value) => (typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined)
31
+
32
+ /** The levels every Loomy chat model answers, used when the catalog declares none. */
33
+ const STANDARD_EFFORTS = Object.freeze(['none', 'low', 'medium', 'high', 'xhigh'])
34
+
35
+ /**
36
+ * Normalize one raw catalog entry.
37
+ *
38
+ * `inputModalities` is read from `capabilities.input_modalities` because that is the
39
+ * field the harness's model picker actually consults; `capabilities.vision` is the
40
+ * older duplicate and is used only as a fallback.
41
+ */
42
+ function normalizeEntry(raw) {
43
+ if (typeof raw !== 'object' || raw === null) return undefined
44
+ const id = String(raw.id ?? '').trim()
45
+ if (id.length === 0) return undefined
46
+ const capabilities = typeof raw.capabilities === 'object' && raw.capabilities !== null ? raw.capabilities : {}
47
+ const declared = Array.isArray(capabilities.input_modalities)
48
+ ? capabilities.input_modalities.filter((value) => typeof value === 'string')
49
+ : []
50
+ const input = declared.length > 0
51
+ ? declared
52
+ : (capabilities.vision === true ? ['text', 'image'] : ['text'])
53
+ /**
54
+ * The reasoning levels this model accepts.
55
+ *
56
+ * The harness validates a request's effort against exactly this set and rejects the
57
+ * whole call with `UNSUPPORTED_REASONING_EFFORT` when the requested level is absent —
58
+ * so an incomplete set does not degrade gracefully, it breaks the model outright.
59
+ *
60
+ * The catalog is not reliable here. `auto` (the router model) ships with no
61
+ * `reasoning_efforts` at all, yet the gateway accepts every level on it: measured
62
+ * 2026-11-11, all five of none/low/medium/high/xhigh answered 200 on `auto`, with the
63
+ * reasoning length changing between them, which is what proves the field is honoured
64
+ * rather than ignored. Reporting the declared empty set would have made the harness
65
+ * reject every attempt to use `auto`, so the standard five are used as the fallback.
66
+ * That is safe in both directions: a level a model truly does not support is ignored
67
+ * upstream (the gateway treats the field as advisory), while omitting it costs the
68
+ * user the ability to choose.
69
+ */
70
+ const declaredEfforts = Array.isArray(raw.reasoning_efforts)
71
+ ? raw.reasoning_efforts.filter((value) => typeof value === 'string' && value.length > 0)
72
+ : []
73
+ const efforts = declaredEfforts.length > 0 ? declaredEfforts : [...STANDARD_EFFORTS]
74
+ return {
75
+ id,
76
+ name: String(raw.name ?? id),
77
+ // The catalog's own classification. `image` entries are generation models that share
78
+ // the chat URL but answer 404 to it (measured 2026-11-11: all three image models
79
+ // returned "该模型暂未开放" on /chat/completions), so offering them in a chat picker
80
+ // is offering a model that cannot answer. Kept on the entry so the panel can list
81
+ // them separately rather than pretending they do not exist.
82
+ type: String(raw.type ?? 'chat'),
83
+ protocol: String(raw.protocol ?? 'openai_chat'),
84
+ contextLength: finiteInt(raw.context_length),
85
+ maxOutputTokens: finiteInt(raw.max_output_tokens),
86
+ input,
87
+ vision: input.includes('image'),
88
+ toolCalls: capabilities.function_calling === true,
89
+ reasoning: capabilities.reasoning === true,
90
+ efforts,
91
+ defaultEffort: typeof raw.default_reasoning_effort === 'string' ? raw.default_reasoning_effort : undefined,
92
+ isDefault: raw.is_default_model === true,
93
+ isDefaultSmall: raw.is_default_small_model === true
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Whether an entry can serve a chat request.
99
+ *
100
+ * The gateway lists generation models in the same catalog; they are not chat models and
101
+ * must not reach the picker. `auto` declares no capabilities at all yet answers
102
+ * normally, so the test is "declared as image" rather than "declared as chat" — the
103
+ * negative form excludes only what is proven unusable.
104
+ */
105
+ export function isChatModel(entry) {
106
+ return entry?.type !== 'image'
107
+ }
108
+
109
+ /** One entry for a model the catalog has never listed, so a known id stays callable. */
110
+ export function placeholderEntry(model) {
111
+ return {
112
+ id: String(model ?? ''),
113
+ name: String(model ?? ''),
114
+ type: 'chat',
115
+ protocol: 'openai_chat',
116
+ input: ['text'],
117
+ vision: false,
118
+ toolCalls: true,
119
+ reasoning: false,
120
+ efforts: [],
121
+ isDefault: false,
122
+ isDefaultSmall: false
123
+ }
124
+ }
125
+
126
+ /** Fetch the live catalog for one account session. Throws on any failure. */
127
+ export async function fetchCatalog(session) {
128
+ const { status, json } = await requestJson('GET', `${API_BASE_URL}/api/v1/models`, {
129
+ token: session,
130
+ Accept: 'application/json',
131
+ 'loomy-version': CLIENT_VERSION
132
+ }, { timeoutMs: 30_000 })
133
+ if (status !== 200) {
134
+ const error = new Error(`取模型列表失败:HTTP ${status}`)
135
+ error.status = status
136
+ error.isAuthError = status === 401 || status === 403
137
+ throw error
138
+ }
139
+ const list = Array.isArray(json?.data) ? json.data : undefined
140
+ if (list === undefined) throw new Error('取模型列表失败:响应没有 data 数组')
141
+ const entries = list.map(normalizeEntry).filter((entry) => entry !== undefined)
142
+ return {
143
+ entries,
144
+ catalogVersion: typeof json?.reasoning_catalog_version === 'string' ? json.reasoning_catalog_version : '',
145
+ reasoningEnabled: json?.reasoning_enabled === true
146
+ }
147
+ }
148
+
149
+ /* ── the on-disk cache ─────────────────────────────────────────────────────── */
150
+
151
+ /** `{ [accountId]: { savedAt, catalogVersion, entries } }`. */
152
+ function readCache() {
153
+ const parsed = readJson(CATALOG_FILE)
154
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {}
155
+ }
156
+
157
+ /** The last good catalog for one account, or `undefined`. */
158
+ export function readCachedCatalog(accountId) {
159
+ const key = String(accountId ?? '').trim().toLowerCase()
160
+ return readCache()[key]
161
+ }
162
+
163
+ /** Replace one account's cached catalog. Best effort: a cache write may never break a call. */
164
+ export function writeCachedCatalog(accountId, catalog) {
165
+ const key = String(accountId ?? '').trim().toLowerCase()
166
+ try {
167
+ const cache = readCache()
168
+ cache[key] = {
169
+ savedAt: Date.now(),
170
+ catalogVersion: catalog.catalogVersion,
171
+ entries: catalog.entries
172
+ }
173
+ writeJson(CATALOG_FILE, cache, { mode: 0o600 })
174
+ } catch {
175
+ // the live answer is already in hand; the cache is only a fallback
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The catalog for one account, live first and cached second.
181
+ *
182
+ * The live read always wins when it succeeds — a stale list that hides a newly granted
183
+ * model is worse than a slow picker. `source` reports which one answered, so the panel
184
+ * can say "本地缓存" rather than implying the list is current.
185
+ */
186
+ export async function loadCatalog(accountId, session, { force = false } = {}) {
187
+ const key = String(accountId ?? '').trim().toLowerCase()
188
+ const cached = readCachedCatalog(key)
189
+ if (!force && cached !== undefined && Date.now() - cached.savedAt < CATALOG_TTL_MS) {
190
+ return { entries: cached.entries, source: 'cache-fresh', savedAt: cached.savedAt, catalogVersion: cached.catalogVersion }
191
+ }
192
+ try {
193
+ const live = await fetchCatalog(session)
194
+ writeCachedCatalog(key, live)
195
+ return { entries: live.entries, source: 'live', savedAt: Date.now(), catalogVersion: live.catalogVersion }
196
+ } catch (error) {
197
+ if (cached !== undefined) {
198
+ return {
199
+ entries: cached.entries,
200
+ source: 'cache-stale',
201
+ savedAt: cached.savedAt,
202
+ catalogVersion: cached.catalogVersion,
203
+ reason: error instanceof Error ? error.message : String(error),
204
+ isAuthError: error?.isAuthError === true
205
+ }
206
+ }
207
+ throw error
208
+ }
209
+ }
210
+
211
+ /** The best display name for an id, from the catalog, without doing any I/O. */
212
+ export function nameFromCache(accountId, model) {
213
+ const cached = readCachedCatalog(accountId)
214
+ const entry = cached?.entries?.find((candidate) => candidate.id === model)
215
+ return entry?.name ?? model
216
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The session string and how a stale one is handled.
3
+ *
4
+ * A Loomy session has no expiry field and no refresh endpoint: the only signal that it
5
+ * died is the server answering `100002` (points API) or `401/403` (model gateway).
6
+ * Those two paths therefore share one reaction here — mark the account as needing a
7
+ * re-login, and stop pretending it works — instead of each caller inventing its own.
8
+ *
9
+ * Marking rather than deleting is deliberate: an account whose session merely expired
10
+ * still has an id, a label and a masked phone, and the panel should show it as
11
+ * "登录已失效" with a re-login button rather than make it silently vanish.
12
+ */
13
+
14
+ import { accountOf, readAccounts } from './auth.js'
15
+ import { readJson, writeJson } from '../core/paths.js'
16
+ import { responseCode, AUTH_ERROR_CODES } from '../core/http.js'
17
+
18
+ const EXPIRED_FILE = 'expired.json'
19
+
20
+ /** `{ [accountId]: { at, reason } }` — accounts whose last call was rejected as unauthorized. */
21
+ function readExpired() {
22
+ const parsed = readJson(EXPIRED_FILE)
23
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {}
24
+ }
25
+
26
+ /** The session for one account, or an error naming what to do about it. */
27
+ export function sessionOf(account) {
28
+ const session = typeof account?.session === 'string' ? account.session.trim() : ''
29
+ if (session.length === 0) {
30
+ throw new Error(`账号 ${account?.id ?? '?'} 尚未登录,请先在设置里登录 Loomy`)
31
+ }
32
+ return session
33
+ }
34
+
35
+ /** Session for an id, reading the table once. */
36
+ export function sessionForId(id) {
37
+ const account = accountOf(id)
38
+ if (account === undefined) throw new Error(`未找到账号 "${String(id ?? '')}"`)
39
+ return { account, session: sessionOf(account) }
40
+ }
41
+
42
+ /** Record that an account's session was rejected, so the panel can say so. */
43
+ export function markExpired(accountId, reason) {
44
+ const key = String(accountId ?? '').trim().toLowerCase()
45
+ if (key.length === 0) return
46
+ const expired = readExpired()
47
+ expired[key] = { at: Date.now(), reason: String(reason ?? '登录已失效') }
48
+ writeJson(EXPIRED_FILE, expired, { mode: 0o600 })
49
+ }
50
+
51
+ /** Clear the expired mark, after a successful re-login or a successful call. */
52
+ export function clearExpired(accountId) {
53
+ const key = String(accountId ?? '').trim().toLowerCase()
54
+ const expired = readExpired()
55
+ if (!(key in expired)) return
56
+ delete expired[key]
57
+ writeJson(EXPIRED_FILE, expired, { mode: 0o600 })
58
+ }
59
+
60
+ /** Whether this account is currently marked as having a dead session. */
61
+ export function isExpired(accountId) {
62
+ const key = String(accountId ?? '').trim().toLowerCase()
63
+ return key in readExpired()
64
+ }
65
+
66
+ /** The mark itself (timestamp + reason), or `undefined`. */
67
+ export function expiredInfo(accountId) {
68
+ const key = String(accountId ?? '').trim().toLowerCase()
69
+ return readExpired()[key]
70
+ }
71
+
72
+ /**
73
+ * Whether a thrown error means "this session is no longer valid".
74
+ *
75
+ * Two different transports answer two different ways: the points API uses the string
76
+ * envelope code `100002`, while the gateway rejects the bearer with a plain 401/403.
77
+ * The adapter and the panel both need this predicate, so it lives here rather than
78
+ * being re-derived at each call site.
79
+ */
80
+ export function isAuthFailure(error) {
81
+ if (typeof error !== 'object' || error === null) return false
82
+ if (error.isAuthError === true) return true
83
+ const code = responseCode(error)
84
+ if (code !== undefined && AUTH_ERROR_CODES.has(code)) return true
85
+ const status = Number(error.status)
86
+ return status === 401 || status === 403
87
+ }
88
+
89
+ /**
90
+ * Run one call that needs a session and react to an auth rejection.
91
+ *
92
+ * Success clears any stale expiry mark, so the panel does not keep saying "登录已失效"
93
+ * after a call that just worked.
94
+ */
95
+ export async function withSession(accountId, run) {
96
+ const { account, session } = sessionForId(accountId)
97
+ try {
98
+ const value = await run(session, account)
99
+ clearExpired(account.id)
100
+ return value
101
+ } catch (error) {
102
+ if (isAuthFailure(error)) markExpired(account.id, error instanceof Error ? error.message : String(error))
103
+ throw error
104
+ }
105
+ }
106
+
107
+ /** Every account with its expiry mark folded in, for the panel's table. */
108
+ export function accountsWithState() {
109
+ const expired = readExpired()
110
+ return readAccounts().map((account) => ({
111
+ ...account,
112
+ expiredAt: expired[account.id]?.at ?? 0,
113
+ expiredReason: expired[account.id]?.reason ?? ''
114
+ }))
115
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Every remote this plugin talks to, and the fixed client identity Loomy expects.
3
+ *
4
+ * Loomy is a desktop app (iFlytek "Loomy") whose model traffic is metered by the
5
+ * account's points balance. There is no public API key: an account logs in through
6
+ * iFlytek's CAccount service, receives a `session` string, and every later call
7
+ * carries that string — either as a `token` header (points/business API) or as both
8
+ * `Authorization: Bearer` and `token` (model gateway).
9
+ *
10
+ * Four hosts, and they are genuinely different services:
11
+ *
12
+ * account https://account.xfinfr.com CAccount login (HMAC-SHA1 signed)
13
+ * api https://loomyad.xunfei.cn points, models, onboarding, pet-work
14
+ * gateway https://loomyad.xunfei.cn/api/v1 OpenAI-compatible chat/completions
15
+ * web https://loomy.xunfei.cn site pages (share, docs)
16
+ *
17
+ * The CAccount credentials below are the desktop client's own, shipped inside
18
+ * `resources/.env.prod` (encrypted with a passphrase that ships beside it). They
19
+ * identify the *client application*, not a user: a login still requires the user's
20
+ * own phone and an SMS code. They are accepted here because the alternative is
21
+ * "the plugin only works if you reverse-engineer your own copy of Loomy", and
22
+ * because they are inert without a user's credentials.
23
+ *
24
+ * All three can be overridden from the environment, which is what a build against
25
+ * a staging deployment needs.
26
+ */
27
+
28
+ const env = (key, fallback) => {
29
+ const value = process.env[key]
30
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback
31
+ }
32
+
33
+ export const ACCOUNT_BASE_URL = env('LOOMY_ACCOUNT_BASE_URL', 'https://account.xfinfr.com')
34
+ export const API_BASE_URL = env('LOOMY_API_BASE_URL', 'https://loomyad.xunfei.cn')
35
+ export const WEB_BASE_URL = env('LOOMY_WEB_BASE_URL', 'https://loomy.xunfei.cn')
36
+
37
+ /** The model gateway root; chat lives at `${GATEWAY_BASE_URL}/chat/completions`. */
38
+ export const GATEWAY_BASE_URL = env('LOOMY_GATEWAY_BASE_URL', `${API_BASE_URL}/api/v1`)
39
+
40
+ /** CAccount signing identity (the desktop client's application credentials). */
41
+ export const ACCOUNT_ACCESS_KEY_ID = env('LOOMY_ACCOUNT_ACCESS_KEY_ID', '2thryby66wxi53sk')
42
+ export const ACCOUNT_ACCESS_KEY_SECRET = env('LOOMY_ACCOUNT_ACCESS_KEY_SECRET', 'zsak6eadrbawz683wf5r3m2snrwj868r')
43
+ export const ACCOUNT_APP_ID = env('LOOMY_ACCOUNT_APP_ID', 'GM3LOOMY')
44
+
45
+ /**
46
+ * The `base` block every CAccount request carries. `modelid`/`devid` are the
47
+ * desktop client's placeholders; the server uses them for channel attribution, not
48
+ * for authentication, so the literal values are what matters rather than realism.
49
+ */
50
+ export const ACCOUNT_CLIENT_UA = 'Loomy|Desktop|Electron|macOS'
51
+
52
+ /** `loomy-version` header. The gateway logs it; no observed behaviour depends on it. */
53
+ export const CLIENT_VERSION = env('LOOMY_CLIENT_VERSION', '0.9.39')
54
+
55
+ /**
56
+ * The request-purpose protocol value the official client sends for a chat turn.
57
+ * The gateway uses it to attribute points consumption; an unknown value is dropped
58
+ * rather than rejected, so a caller that cannot classify a request sends nothing.
59
+ */
60
+ export const PURPOSE_CHAT_MESSAGE = 'chat.message'
61
+
62
+ /**
63
+ * A Loomy desktop login session, as parked by that app on this machine.
64
+ *
65
+ * Reading it is how a user can adopt an existing Loomy login instead of going
66
+ * through SMS again. The path is the Windows layout the desktop client uses
67
+ * (`C:\Users\Public\Loomy\<user-hash>\userData\auth-session.json`); on other
68
+ * platforms the same file lives under the app's userData directory, so the
69
+ * resolver walks the documented candidates and gives up quietly.
70
+ */
71
+ export const LOOMY_DESKTOP_SESSION_PATTERNS = {
72
+ win32: [
73
+ 'C:\\Users\\Public\\Loomy',
74
+ 'C:\\Users\\Public\\Loomy\\userData'
75
+ ],
76
+ darwin: ['~/Library/Application Support/Loomy'],
77
+ linux: ['~/.config/loomy']
78
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Transport and envelope helpers shared by every Loomy call.
3
+ *
4
+ * Two different envelope families answer from the same host, and the difference matters:
5
+ *
6
+ * `https://loomyad.xunfei.cn/api/v1/*` → `{ code: "000000", desc, data, trace_id }`
7
+ * (string code; "000000" is success)
8
+ * `https://account.xfinfr.com/*` → `{ code: "000000", desc, data }` (iFlytek CAccount)
9
+ * `https://loomyad.xunfei.cn/api/v1/models` → a bare `{ object: "list", data: [ ... ] }`
10
+ *
11
+ * Nothing here knows about accounts; it only normalizes the shapes so callers never
12
+ * have to guess between `desc` and `message`, or between `data` and `data.data`.
13
+ */
14
+
15
+ export const REQUEST_TIMEOUT_MS = 60_000
16
+ export const STATE_TIMEOUT_MS = 15_000
17
+
18
+ /**
19
+ * JSON request that never throws for a non-JSON body: a proxy or a captcha page can
20
+ * still answer with HTML, and the caller's status handling is what decides the outcome.
21
+ */
22
+ export async function requestJson(method, url, headers, options = {}) {
23
+ const signal = AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS)
24
+ let response
25
+ try {
26
+ response = await fetch(url, {
27
+ method,
28
+ headers,
29
+ signal,
30
+ ...(options.body === undefined ? {} : { body: options.body })
31
+ })
32
+ } catch (error) {
33
+ throw new Error(`${method} ${url} failed: ${error instanceof Error ? error.message : String(error)}`)
34
+ }
35
+ let json = null
36
+ try {
37
+ json = await response.json()
38
+ } catch {
39
+ json = null
40
+ }
41
+ return { status: response.status, json }
42
+ }
43
+
44
+ /** The envelope body, or `undefined` when the answer has none. */
45
+ export function responseData(json) {
46
+ if (typeof json !== 'object' || json === null) return undefined
47
+ return typeof json.data === 'object' && json.data !== null ? json.data : undefined
48
+ }
49
+
50
+ /**
51
+ * The envelope's business code as a string, or `undefined` when there is no envelope.
52
+ *
53
+ * Returning a success value for an unparseable body made a timed-out or truncated 200
54
+ * read as success. Callers compare against `'000000'` explicitly, so `undefined` means
55
+ * "no answer" rather than "fine".
56
+ */
57
+ export function responseCode(json) {
58
+ if (typeof json !== 'object' || json === null) return undefined
59
+ const code = json.code
60
+ if (typeof code === 'string') return code
61
+ if (typeof code === 'number' && Number.isFinite(code)) return String(code)
62
+ return undefined
63
+ }
64
+
65
+ export function responseMessage(json) {
66
+ if (typeof json !== 'object' || json === null) return ''
67
+ for (const key of ['desc', 'message', 'msg', 'error']) {
68
+ if (typeof json[key] === 'string' && json[key].length > 0) return json[key]
69
+ }
70
+ return ''
71
+ }
72
+
73
+ /** Business codes that mean "the login session is no longer valid". */
74
+ export const AUTH_ERROR_CODES = new Set(['100002', '020002'])
75
+
76
+ /**
77
+ * Throw for anything that is not a successful envelope, and answer the payload.
78
+ *
79
+ * `label` names the operation in the message, so a failure reads as "取模型列表失败:
80
+ * 未登录或登录已失效" rather than a bare server string.
81
+ */
82
+ export function unwrap(json, label) {
83
+ const code = responseCode(json)
84
+ if (code === undefined) throw new Error(`${label}:响应不是合法 JSON`)
85
+ if (code !== '000000') {
86
+ const message = responseMessage(json) || `业务错误 ${code}`
87
+ const error = new Error(`${label}:${message}`)
88
+ error.code = code
89
+ error.isAuthError = AUTH_ERROR_CODES.has(code)
90
+ throw error
91
+ }
92
+ return json
93
+ }
94
+
95
+ export function readString(source, key) {
96
+ const value = source?.[key]
97
+ if (typeof value === 'string') return value
98
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value)
99
+ return ''
100
+ }
101
+
102
+ /** Local calendar day, used as the bookkeeping key for the daily credit grant. */
103
+ export function localDayKey(now = new Date()) {
104
+ const pad = (value) => String(value).padStart(2, '0')
105
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
106
+ }
107
+
108
+ /** Milliseconds until the next local midnight. */
109
+ export function msUntilNextMidnight(now = new Date()) {
110
+ const next = new Date(now)
111
+ next.setHours(24, 0, 0, 0)
112
+ return next.getTime() - now.getTime()
113
+ }
114
+
115
+ /** `setTimeout` that never keeps the host process alive. */
116
+ export const sleep = (ms) => new Promise((resolve) => {
117
+ const timer = setTimeout(resolve, ms)
118
+ timer.unref?.()
119
+ })
120
+
121
+ export function formatWhen(ms) {
122
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) return '未知'
123
+ return new Date(ms).toLocaleString()
124
+ }