@ljwei-stak/model-router-galgame 0.4.18 → 0.4.20
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/.dsh-plugin/client.js +286 -67
- package/.dsh-plugin/index.mjs +7 -0
- package/.dsh-plugin/shared/approval-gate.mjs +1 -1
- package/.dsh-plugin/shared/npm-update.mjs +312 -0
- package/.dsh-plugin/shared/web-routing.mjs +1 -1
- package/INSTALLATION_GUIDE.zh.md +783 -0
- package/MODLENS_DEPLOYMENT.md +72 -0
- package/README.i18n.yaml +2 -2
- package/README.md +20 -8
- package/README.zh.md +21 -9
- package/cordis.patch.yml +0 -7
- package/package.json +8 -10
package/.dsh-plugin/index.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
webCapabilityStatus,
|
|
27
27
|
webInstruction,
|
|
28
28
|
} from './shared/web-routing.mjs'
|
|
29
|
+
import { registerNpmUpdateRoute } from './shared/npm-update.mjs'
|
|
29
30
|
|
|
30
31
|
let settingsRuntimePromise
|
|
31
32
|
let routerSettings = { ...DEFAULT_ROUTER_SETTINGS }
|
|
@@ -427,6 +428,12 @@ function createOpenCodeRepairScheduler(ctx) {
|
|
|
427
428
|
}
|
|
428
429
|
|
|
429
430
|
export function apply(ctx) {
|
|
431
|
+
// Desktop-only package mutation is exposed through an authenticated,
|
|
432
|
+
// fixed-purpose route when the optional native capabilities are present.
|
|
433
|
+
ctx.inject?.(['connection', 'desktopProfiles', 'desktopPnpm'], desktopCtx => {
|
|
434
|
+
registerNpmUpdateRoute(desktopCtx, import.meta.url)
|
|
435
|
+
})
|
|
436
|
+
|
|
430
437
|
// Settings are optional in headless test/minimal hosts. In a full Harness
|
|
431
438
|
// process this registers the editable pricing, LiveBench and budget section;
|
|
432
439
|
// the dynamic import keeps the standalone plugin loadable during bootstrap.
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
export const ROUTER_PACKAGE_NAME = '@ljwei-stak/model-router-galgame'
|
|
6
|
+
export const ROUTER_NPM_REGISTRY = 'https://registry.npmjs.org/'
|
|
7
|
+
export const ROUTER_UPDATE_CHANNEL = '/model-router-update'
|
|
8
|
+
export const ROUTER_UPDATE_ENDPOINT = 'install-plugin'
|
|
9
|
+
export const ROUTER_UPDATE_TIMEOUT_MS = 120_000
|
|
10
|
+
|
|
11
|
+
const MAX_REGISTRY_BODY_BYTES = 64 * 1024
|
|
12
|
+
const MAX_PNPM_OUTPUT_BYTES = 32 * 1024
|
|
13
|
+
const MAX_LOG_CAUSE_LENGTH = 4 * 1024
|
|
14
|
+
const VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
|
15
|
+
|
|
16
|
+
function versionParts(value) {
|
|
17
|
+
const match = String(value ?? '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/)
|
|
18
|
+
if (match === null) return null
|
|
19
|
+
return { core: match.slice(1, 4).map(Number), prerelease: match[4]?.split('.') ?? [] }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function compareVersions(left, right) {
|
|
23
|
+
const a = versionParts(left)
|
|
24
|
+
const b = versionParts(right)
|
|
25
|
+
if (a === null || b === null) return null
|
|
26
|
+
for (let index = 0; index < 3; index += 1) {
|
|
27
|
+
if (a.core[index] !== b.core[index]) return a.core[index] < b.core[index] ? -1 : 1
|
|
28
|
+
}
|
|
29
|
+
if (a.prerelease.length === 0 || b.prerelease.length === 0) {
|
|
30
|
+
return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1
|
|
31
|
+
}
|
|
32
|
+
const length = Math.max(a.prerelease.length, b.prerelease.length)
|
|
33
|
+
for (let index = 0; index < length; index += 1) {
|
|
34
|
+
if (a.prerelease[index] === undefined) return -1
|
|
35
|
+
if (b.prerelease[index] === undefined) return 1
|
|
36
|
+
if (a.prerelease[index] === b.prerelease[index]) continue
|
|
37
|
+
const aNumber = /^\d+$/.test(a.prerelease[index])
|
|
38
|
+
const bNumber = /^\d+$/.test(b.prerelease[index])
|
|
39
|
+
if (aNumber && bNumber) return Number(a.prerelease[index]) < Number(b.prerelease[index]) ? -1 : 1
|
|
40
|
+
if (aNumber !== bNumber) return aNumber ? -1 : 1
|
|
41
|
+
return a.prerelease[index] < b.prerelease[index] ? -1 : 1
|
|
42
|
+
}
|
|
43
|
+
return 0
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function rpcFailure(code, message, details = {}) {
|
|
47
|
+
return { ok: false, error: { code, message, details } }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function errorText(error) {
|
|
51
|
+
const value = error instanceof Error ? error.stack ?? error.message : String(error)
|
|
52
|
+
return value.length <= MAX_LOG_CAUSE_LENGTH
|
|
53
|
+
? value
|
|
54
|
+
: `${value.slice(0, MAX_LOG_CAUSE_LENGTH)}\n[cause truncated]`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readTextLimited(stream, maximum) {
|
|
58
|
+
if (stream === null) return ''
|
|
59
|
+
const reader = stream.getReader()
|
|
60
|
+
const decoder = new TextDecoder()
|
|
61
|
+
let size = 0
|
|
62
|
+
let text = ''
|
|
63
|
+
try {
|
|
64
|
+
while (true) {
|
|
65
|
+
const { value, done } = await reader.read()
|
|
66
|
+
if (done) break
|
|
67
|
+
size += value.byteLength
|
|
68
|
+
if (size > maximum) throw new Error('response body is too large')
|
|
69
|
+
text += decoder.decode(value, { stream: true })
|
|
70
|
+
}
|
|
71
|
+
return text + decoder.decode()
|
|
72
|
+
} finally {
|
|
73
|
+
reader.releaseLock()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isEmptyRecord(value) {
|
|
78
|
+
return value !== null
|
|
79
|
+
&& typeof value === 'object'
|
|
80
|
+
&& !Array.isArray(value)
|
|
81
|
+
&& Object.keys(value).length === 0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read the running package version from its own ancestor manifest. */
|
|
85
|
+
export async function findInstalledPackageVersion(moduleUrl, read = readFile) {
|
|
86
|
+
let current = dirname(fileURLToPath(moduleUrl))
|
|
87
|
+
while (true) {
|
|
88
|
+
try {
|
|
89
|
+
const manifest = JSON.parse(await read(join(current, 'package.json'), 'utf8'))
|
|
90
|
+
if (manifest?.name === ROUTER_PACKAGE_NAME && typeof manifest.version === 'string' && VERSION_PATTERN.test(manifest.version)) {
|
|
91
|
+
return manifest.version
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// Keep walking until the package root is found.
|
|
95
|
+
}
|
|
96
|
+
const parent = dirname(current)
|
|
97
|
+
if (parent === current) return null
|
|
98
|
+
current = parent
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function npmLatest(fetchImpl, signal) {
|
|
103
|
+
const encoded = encodeURIComponent(ROUTER_PACKAGE_NAME)
|
|
104
|
+
const response = await fetchImpl(`${ROUTER_NPM_REGISTRY}${encoded}/latest`, {
|
|
105
|
+
method: 'GET',
|
|
106
|
+
redirect: 'error',
|
|
107
|
+
cache: 'no-store',
|
|
108
|
+
signal,
|
|
109
|
+
headers: {
|
|
110
|
+
accept: 'application/json',
|
|
111
|
+
'user-agent': 'model-router-galgame-updater',
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
if (!response.ok) throw new Error(`npm registry returned HTTP ${response.status}`)
|
|
115
|
+
const metadata = JSON.parse(await readTextLimited(response.body, MAX_REGISTRY_BODY_BYTES))
|
|
116
|
+
if (metadata?.name !== ROUTER_PACKAGE_NAME || typeof metadata.version !== 'string' || !VERSION_PATTERN.test(metadata.version)) {
|
|
117
|
+
throw new Error('npm registry returned invalid latest metadata')
|
|
118
|
+
}
|
|
119
|
+
return metadata.version
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function captureBoundedOutput(stream) {
|
|
123
|
+
let output = Buffer.alloc(0)
|
|
124
|
+
let truncated = false
|
|
125
|
+
let settle
|
|
126
|
+
const done = new Promise(resolveDone => { settle = resolveDone })
|
|
127
|
+
const onData = chunk => {
|
|
128
|
+
const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
129
|
+
if (next.byteLength >= MAX_PNPM_OUTPUT_BYTES) {
|
|
130
|
+
output = next.subarray(-MAX_PNPM_OUTPUT_BYTES)
|
|
131
|
+
truncated = true
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (output.byteLength + next.byteLength > MAX_PNPM_OUTPUT_BYTES) {
|
|
135
|
+
output = Buffer.concat([output, next]).subarray(-MAX_PNPM_OUTPUT_BYTES)
|
|
136
|
+
truncated = true
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
output = Buffer.concat([output, next])
|
|
140
|
+
}
|
|
141
|
+
const onSettled = () => settle()
|
|
142
|
+
stream.on('data', onData)
|
|
143
|
+
stream.once('end', onSettled)
|
|
144
|
+
stream.once('close', onSettled)
|
|
145
|
+
stream.once('error', onSettled)
|
|
146
|
+
stream.resume()
|
|
147
|
+
return {
|
|
148
|
+
done,
|
|
149
|
+
read() {
|
|
150
|
+
const value = output.toString('utf8').trimEnd()
|
|
151
|
+
return truncated ? `[earlier output truncated]\n${value}` : value
|
|
152
|
+
},
|
|
153
|
+
stop() {
|
|
154
|
+
stream.off('data', onData)
|
|
155
|
+
stream.off('end', onSettled)
|
|
156
|
+
stream.off('close', onSettled)
|
|
157
|
+
stream.off('error', onSettled)
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function abortPromise(signal) {
|
|
163
|
+
let rejectPromise
|
|
164
|
+
const promise = new Promise((_, reject) => { rejectPromise = reject })
|
|
165
|
+
const reject = () => rejectPromise(signal.reason ?? new Error('operation aborted'))
|
|
166
|
+
if (signal.aborted) reject()
|
|
167
|
+
else signal.addEventListener('abort', reject, { once: true })
|
|
168
|
+
return {
|
|
169
|
+
promise,
|
|
170
|
+
dispose() { signal.removeEventListener('abort', reject) },
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function runPnpmUpdate(pnpm, profileDir, version, signal) {
|
|
175
|
+
const args = [
|
|
176
|
+
'add',
|
|
177
|
+
'--save-exact',
|
|
178
|
+
`${ROUTER_PACKAGE_NAME}@${version}`,
|
|
179
|
+
`--registry=${ROUTER_NPM_REGISTRY}`,
|
|
180
|
+
]
|
|
181
|
+
let handle
|
|
182
|
+
try {
|
|
183
|
+
handle = pnpm.runPlugin(args, profileDir, signal)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const busy = /another desktop pnpm operation is already running/i.test(String(error))
|
|
186
|
+
return { ok: false, busy, details: errorText(error) }
|
|
187
|
+
}
|
|
188
|
+
const stdout = captureBoundedOutput(handle.stdout)
|
|
189
|
+
const stderr = captureBoundedOutput(handle.stderr)
|
|
190
|
+
const onAbort = () => handle.cancel()
|
|
191
|
+
const aborted = abortPromise(signal)
|
|
192
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
193
|
+
try {
|
|
194
|
+
let outcome
|
|
195
|
+
try {
|
|
196
|
+
outcome = await Promise.race([handle.done, aborted.promise])
|
|
197
|
+
await Promise.race([Promise.all([stdout.done, stderr.done]), aborted.promise])
|
|
198
|
+
} catch (error) {
|
|
199
|
+
signal.throwIfAborted()
|
|
200
|
+
return { ok: false, busy: false, details: [errorText(error), stdout.read(), stderr.read()].filter(Boolean).join('\n\n') }
|
|
201
|
+
}
|
|
202
|
+
signal.throwIfAborted()
|
|
203
|
+
if (outcome.exitCode !== 0 || outcome.signal !== null) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
busy: false,
|
|
207
|
+
details: [
|
|
208
|
+
`exitCode=${outcome.exitCode ?? 'null'} signal=${outcome.signal ?? 'none'}`,
|
|
209
|
+
stdout.read(),
|
|
210
|
+
stderr.read(),
|
|
211
|
+
].filter(Boolean).join('\n\n'),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { ok: true }
|
|
215
|
+
} finally {
|
|
216
|
+
aborted.dispose()
|
|
217
|
+
signal.removeEventListener('abort', onAbort)
|
|
218
|
+
stdout.stop()
|
|
219
|
+
stderr.stop()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function createNpmUpdateHandler({
|
|
224
|
+
moduleUrl,
|
|
225
|
+
pnpm,
|
|
226
|
+
profileDir,
|
|
227
|
+
fetchImpl = globalThis.fetch,
|
|
228
|
+
logger,
|
|
229
|
+
timeoutMs = ROUTER_UPDATE_TIMEOUT_MS,
|
|
230
|
+
}) {
|
|
231
|
+
let active = false
|
|
232
|
+
return async (endpoint, payload, requestSignal = new AbortController().signal) => {
|
|
233
|
+
if (endpoint !== ROUTER_UPDATE_ENDPOINT) {
|
|
234
|
+
return rpcFailure('router/not-found', 'unknown Router update operation')
|
|
235
|
+
}
|
|
236
|
+
if (!isEmptyRecord(payload)) {
|
|
237
|
+
return rpcFailure('router/bad-request', 'the Router update request must be an empty object')
|
|
238
|
+
}
|
|
239
|
+
if (active) return rpcFailure('router/busy', 'a Router update is already running')
|
|
240
|
+
active = true
|
|
241
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs)
|
|
242
|
+
const signal = AbortSignal.any([requestSignal, timeoutSignal])
|
|
243
|
+
try {
|
|
244
|
+
const version = await npmLatest(fetchImpl, signal)
|
|
245
|
+
const currentVersion = await findInstalledPackageVersion(moduleUrl)
|
|
246
|
+
const order = currentVersion === null ? null : compareVersions(currentVersion, version)
|
|
247
|
+
if (order !== null && order >= 0) {
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
value: {
|
|
251
|
+
changed: false,
|
|
252
|
+
packageName: ROUTER_PACKAGE_NAME,
|
|
253
|
+
currentVersion,
|
|
254
|
+
version,
|
|
255
|
+
restartRequired: false,
|
|
256
|
+
message: order === 0
|
|
257
|
+
? `npm 插件已经是最新版 ${version}。`
|
|
258
|
+
: `当前插件 ${currentVersion} 新于 npm latest ${version},不会降级。`,
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const result = await runPnpmUpdate(pnpm, profileDir, version, signal)
|
|
263
|
+
if (!result.ok) {
|
|
264
|
+
try { logger?.warn?.(`model-router: npm update failed\n${result.details}`) } catch {}
|
|
265
|
+
return rpcFailure(
|
|
266
|
+
result.busy ? 'router/busy' : 'router/install-failed',
|
|
267
|
+
result.busy ? 'DSH Desktop 正在执行其他插件操作,请稍后重试。' : 'npm 插件更新失败,请查看 DSH Desktop 日志。',
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
ok: true,
|
|
272
|
+
value: {
|
|
273
|
+
changed: true,
|
|
274
|
+
packageName: ROUTER_PACKAGE_NAME,
|
|
275
|
+
currentVersion,
|
|
276
|
+
version,
|
|
277
|
+
restartRequired: true,
|
|
278
|
+
message: `npm 插件及其依赖已更新为 ${version}。请完全退出并重新启动 DSH Desktop。`,
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
} catch (error) {
|
|
282
|
+
const timedOut = timeoutSignal.aborted && !requestSignal.aborted
|
|
283
|
+
try { logger?.warn?.(`model-router: npm update failed: ${errorText(error)}`) } catch {}
|
|
284
|
+
return rpcFailure(
|
|
285
|
+
timedOut ? 'router/timeout' : requestSignal.aborted ? 'router/cancelled' : 'router/npm-failed',
|
|
286
|
+
timedOut
|
|
287
|
+
? 'npm 插件更新超时,请稍后重试。'
|
|
288
|
+
: requestSignal.aborted
|
|
289
|
+
? 'npm 插件更新已取消。'
|
|
290
|
+
: '无法从 npm 检查或更新插件。',
|
|
291
|
+
)
|
|
292
|
+
} finally {
|
|
293
|
+
active = false
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function registerNpmUpdateRoute(ctx, moduleUrl) {
|
|
299
|
+
const connection = ctx.get?.('connection') ?? ctx.connection
|
|
300
|
+
const profiles = ctx.get?.('desktopProfiles') ?? ctx.desktopProfiles
|
|
301
|
+
const pnpm = ctx.get?.('desktopPnpm') ?? ctx.desktopPnpm
|
|
302
|
+
if (typeof connection?.rpc?.handle !== 'function'
|
|
303
|
+
|| typeof pnpm?.runPlugin !== 'function'
|
|
304
|
+
|| typeof profiles?.current?.dir !== 'string') return false
|
|
305
|
+
connection.rpc.handle(ROUTER_UPDATE_CHANNEL, createNpmUpdateHandler({
|
|
306
|
+
moduleUrl,
|
|
307
|
+
pnpm,
|
|
308
|
+
profileDir: profiles.current.dir,
|
|
309
|
+
logger: ctx.logger,
|
|
310
|
+
}))
|
|
311
|
+
return true
|
|
312
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
export const MODSEARCH_PACKAGE = '@liustack/modsearch'
|
|
11
11
|
export const MODSEARCH_VERSION = '5.10.1'
|
|
12
12
|
export const EGO_BROWSER_PACKAGE = '@ljwei-stak/dsh-ego-browser'
|
|
13
|
-
export const EGO_BROWSER_VERSION = '0.8.
|
|
13
|
+
export const EGO_BROWSER_VERSION = '0.8.3'
|
|
14
14
|
|
|
15
15
|
const WEB_RE = /https?:\/\/|www\.|联网|上网|网页|网站|搜索|查找|资料|文献|新闻|最新|实时|网页内容|页面|来源|引用|网络|x\s*帖子|twitter|推特|github|反爬|验证码|登录|人机验证/i
|
|
16
16
|
const BROWSER_RE = /反爬|验证码|人机验证|cloudflare|turnstile|recaptcha|hcaptcha|登录|需要点击|动态页面|网页窗口|浏览器|可见窗口|手动验证|页面交互/i
|