@kukonce/dsh-loomy 0.0.0-stage → 0.3.2
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 +125 -0
- package/LICENSE +26 -0
- package/README.md +223 -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/pill-composer.png +0 -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
|
@@ -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
|
+
}
|
package/src/core/http.js
ADDED
|
@@ -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
|
+
}
|