@ghfight/dsh-llm-proxy 0.0.0-stage → 0.4.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/lib/index.js ADDED
@@ -0,0 +1,1777 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { execFile, spawn } from 'node:child_process'
3
+ import { constants as fsConstants, promises as fs } from 'node:fs'
4
+ import { createRequire } from 'node:module'
5
+ import net from 'node:net'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { promisify } from 'node:util'
10
+ import zlib from 'node:zlib'
11
+ import YAML from 'yaml'
12
+ import z from '@deepseek-ai/schemastery'
13
+ import { defineTool } from '@deepseek-ai/dsh-tools'
14
+
15
+ const execFileAsync = promisify(execFile)
16
+ const VERSION = '0.4.1'
17
+ const AUTO_GROUP = 'DSH-AUTO'
18
+ const SELECT_GROUP = 'DSH'
19
+ const RESERVED_NAMES = new Set([AUTO_GROUP, SELECT_GROUP])
20
+ const MAX_SUBSCRIPTION_BYTES = 10 * 1024 * 1024
21
+ const DEFAULT_TEST_URL = 'https://www.gstatic.com/generate_204'
22
+ const DEFAULT_BYPASS_HOSTS = [
23
+ 'dsh.r2049.cn', '*.r2049.cn', '*.ts.net', '*.local', '*.lan',
24
+ 'deepseek.com', '*.deepseek.com', 'deepseek.ai', '*.deepseek.ai', 'deepseek-ai.github.io'
25
+ ]
26
+ const MODEL_HOSTS = [
27
+ 'api.deepseek.com', 'api.anthropic.com', 'api.openai.com', 'chatgpt.com',
28
+ 'generativelanguage.googleapis.com', 'openrouter.ai', 'api.groq.com',
29
+ 'api.mistral.ai', 'api.x.ai', 'api.siliconflow.cn', 'api.moonshot.cn',
30
+ 'dashscope.aliyuncs.com', 'open.bigmodel.cn', 'api.minimax.chat'
31
+ ]
32
+
33
+ const CORE_PACKAGES = {
34
+ 'win32-x64': 'mihomo-windows-amd64',
35
+ 'win32-arm64': 'mihomo-windows-arm64',
36
+ 'linux-x64': 'mihomo-linux-amd64',
37
+ 'linux-arm64': 'mihomo-linux-arm64',
38
+ 'darwin-x64': 'mihomo-darwin-amd64',
39
+ 'darwin-arm64': 'mihomo-darwin-arm64'
40
+ }
41
+
42
+ export const name = '@ghfight/dsh-llm-proxy'
43
+ export const inject = ['tools', 'webServer']
44
+
45
+ export const Config = z.object({
46
+ autoStart: z.boolean().default(true),
47
+ subscriptionUrl: z.string().default(''),
48
+ corePath: z.string().default(''),
49
+ testUrl: z.string().default(DEFAULT_TEST_URL),
50
+ testInterval: z.number().step(1).min(30).max(3600).default(300),
51
+ mixedPort: z.number().step(1).min(0).max(65535).default(0),
52
+ dataDir: z.string().default(''),
53
+ downloadCore: z.boolean().default(true),
54
+ proxyScope: z.string().default('all'),
55
+ bypassHosts: z.string().default('')
56
+ })
57
+
58
+ function normalizeConfig(raw) {
59
+ const input = raw && typeof raw === 'object' ? raw : {}
60
+ return {
61
+ autoStart: input.autoStart !== false,
62
+ subscriptionUrl: typeof input.subscriptionUrl === 'string' ? input.subscriptionUrl.trim() : '',
63
+ corePath: typeof input.corePath === 'string' ? input.corePath.trim() : '',
64
+ testUrl: typeof input.testUrl === 'string' && input.testUrl.trim() ? input.testUrl.trim() : DEFAULT_TEST_URL,
65
+ testInterval: Number.isFinite(Number(input.testInterval)) ? Math.max(30, Math.min(3600, Math.trunc(Number(input.testInterval)))) : 300,
66
+ mixedPort: Number.isFinite(Number(input.mixedPort)) ? Math.max(0, Math.min(65535, Math.trunc(Number(input.mixedPort)))) : 0,
67
+ dataDir: typeof input.dataDir === 'string' ? input.dataDir.trim() : '',
68
+ downloadCore: input.downloadCore !== false,
69
+ proxyScope: input.proxyScope === 'models' ? 'models' : 'all',
70
+ bypassHosts: typeof input.bypassHosts === 'string' ? input.bypassHosts : ''
71
+ }
72
+ }
73
+
74
+ function emptyState() {
75
+ return {
76
+ version: 1,
77
+ enabled: true,
78
+ proxyScope: 'all',
79
+ bypassHosts: DEFAULT_BYPASS_HOSTS.slice(),
80
+ subscription: null,
81
+ ports: { mixed: 0, controller: 0 },
82
+ secret: '',
83
+ corePath: '',
84
+ selected: AUTO_GROUP,
85
+ mode: 'auto',
86
+ lastError: ''
87
+ }
88
+ }
89
+
90
+ function messageOf(error) {
91
+ if (error instanceof Error) return error.message
92
+ return String(error)
93
+ }
94
+
95
+ function tail(text, limit) {
96
+ const value = String(text || '').replace(/\s+$/g, '')
97
+ if (value.length <= limit) return value
98
+ return value.slice(value.length - limit)
99
+ }
100
+
101
+ function throwIfAborted(signal) {
102
+ if (signal && signal.aborted) {
103
+ const reason = signal.reason
104
+ throw reason instanceof Error ? reason : new Error('操作已取消')
105
+ }
106
+ }
107
+
108
+ function sleep(ms) {
109
+ return new Promise((resolve) => setTimeout(resolve, ms))
110
+ }
111
+
112
+ function isDirectHost(hostname) {
113
+ const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
114
+ if (host === 'localhost' || host.endsWith('.localhost') || host === '::1' || host === '0:0:0:0:0:0:0:1' || host === '::' || host === '0.0.0.0') return true
115
+ if (host.endsWith('.local') || host.endsWith('.lan')) return true
116
+ const parts = host.split('.')
117
+ if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) return false
118
+ const octets = parts.map(Number)
119
+ if (octets.some((value) => value > 255)) return false
120
+ const [a, b] = octets
121
+ return a === 0 || a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127)
122
+ }
123
+
124
+ function normalizeHostPattern(value) {
125
+ let host = String(value || '').trim().toLowerCase()
126
+ if (!host || host.startsWith('#')) return ''
127
+ host = host.replace(/^[a-z][a-z0-9+.-]*:\/\//, '').replace(/\/.*$/, '').replace(/:\d+$/, '')
128
+ if (host.startsWith('.')) host = '*.' + host.slice(1)
129
+ if (host === '*') return '*'
130
+ return host.replace(/^\[|\]$/g, '')
131
+ }
132
+
133
+ function normalizeBypassList(value) {
134
+ const list = Array.isArray(value) ? value : String(value || '').split(/[\n,]+/)
135
+ const seen = new Set()
136
+ const out = []
137
+ for (const item of list) {
138
+ const host = normalizeHostPattern(item)
139
+ if (!host || seen.has(host)) continue
140
+ seen.add(host)
141
+ out.push(host)
142
+ if (out.length >= 200) break
143
+ }
144
+ return out
145
+ }
146
+
147
+ function hostMatchesPattern(hostname, pattern) {
148
+ const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
149
+ const rule = normalizeHostPattern(pattern)
150
+ if (!rule) return false
151
+ if (rule === '*') return true
152
+ if (rule.startsWith('*.')) {
153
+ const base = rule.slice(2)
154
+ return host === base || host.endsWith('.' + base)
155
+ }
156
+ return host === rule
157
+ }
158
+
159
+ function isModelHost(hostname) {
160
+ const host = String(hostname || '').toLowerCase()
161
+ return MODEL_HOSTS.some((item) => host === item || host.endsWith('.' + item))
162
+ }
163
+
164
+ function binaryName() {
165
+ return process.platform === 'win32' ? 'mihomo.exe' : 'mihomo'
166
+ }
167
+
168
+ function corePackageName() {
169
+ return CORE_PACKAGES[process.platform + '-' + process.arch] || ''
170
+ }
171
+
172
+ function displaySource(source) {
173
+ if (/^https?:\/\//i.test(source)) {
174
+ try {
175
+ return new URL(source).host
176
+ } catch {
177
+ return 'subscription'
178
+ }
179
+ }
180
+ try {
181
+ return path.basename(source.startsWith('file://') ? fileURLToPath(source) : source)
182
+ } catch {
183
+ return 'local-file'
184
+ }
185
+ }
186
+
187
+ function isHttpSource(source) {
188
+ return /^https?:\/\//i.test(source)
189
+ }
190
+
191
+ function readCString(buffer, start, length) {
192
+ let end = start
193
+ const limit = start + length
194
+ while (end < limit && buffer[end] !== 0) end += 1
195
+ return buffer.subarray(start, end).toString('utf8')
196
+ }
197
+
198
+ function extractTarEntry(tarball, wantedName) {
199
+ const archive = zlib.gunzipSync(tarball)
200
+ let offset = 0
201
+ while (offset + 512 <= archive.length) {
202
+ const header = archive.subarray(offset, offset + 512)
203
+ let allZero = true
204
+ for (const byte of header) {
205
+ if (byte !== 0) {
206
+ allZero = false
207
+ break
208
+ }
209
+ }
210
+ if (allZero) break
211
+ const name = readCString(header, 0, 100)
212
+ const prefix = readCString(header, 345, 155)
213
+ const fullName = prefix ? prefix + '/' + name : name
214
+ const sizeText = readCString(header, 124, 12).trim()
215
+ const size = sizeText ? parseInt(sizeText, 8) : 0
216
+ const type = String.fromCharCode(header[156] || 48)
217
+ const dataStart = offset + 512
218
+ const normalized = fullName.replace(/\\/g, '/')
219
+ if ((type === '0' || type === '\0') && normalized.endsWith('/' + wantedName)) {
220
+ return archive.subarray(dataStart, dataStart + size)
221
+ }
222
+ offset = dataStart + Math.ceil(size / 512) * 512
223
+ }
224
+ throw new Error('下载的核心压缩包中未找到 ' + wantedName)
225
+ }
226
+
227
+ function parseSubscription(text) {
228
+ let doc
229
+ try {
230
+ doc = YAML.parse(text)
231
+ } catch (error) {
232
+ const compact = String(text || '').replace(/\s+/g, '')
233
+ if (compact.length > 0 && /^[A-Za-z0-9+/=]+$/.test(compact)) {
234
+ try {
235
+ doc = YAML.parse(Buffer.from(compact, 'base64').toString('utf8'))
236
+ } catch {
237
+ throw new Error('订阅不是有效的 Clash YAML: ' + messageOf(error))
238
+ }
239
+ } else {
240
+ throw new Error('订阅不是有效的 Clash YAML: ' + messageOf(error))
241
+ }
242
+ }
243
+ let rows
244
+ if (Array.isArray(doc)) rows = doc
245
+ else if (doc && Array.isArray(doc.proxies)) rows = doc.proxies
246
+ else if (doc && doc['proxy-providers']) throw new Error('该订阅只包含 proxy-providers,没有内联 proxies;请换用返回完整 Clash 配置的订阅链接')
247
+ else throw new Error('订阅中没有 proxies 列表;请确认链接返回的是 Clash / Mihomo 配置')
248
+ const seen = new Set()
249
+ const proxies = []
250
+ let skipped = 0
251
+ for (const row of rows) {
252
+ if (!row || typeof row !== 'object' || Array.isArray(row)) {
253
+ skipped += 1
254
+ continue
255
+ }
256
+ const type = typeof row.type === 'string' ? row.type.trim() : ''
257
+ const server = typeof row.server === 'string' ? row.server.trim() : ''
258
+ const port = Number(row.port)
259
+ let proxyName = typeof row.name === 'string' ? row.name.trim() : ''
260
+ if (!type || !server || !Number.isInteger(port) || port < 1 || port > 65535 || !proxyName) {
261
+ skipped += 1
262
+ continue
263
+ }
264
+ if (RESERVED_NAMES.has(proxyName)) proxyName = proxyName + ' (node)'
265
+ let base = proxyName
266
+ let suffix = 2
267
+ while (seen.has(proxyName)) {
268
+ proxyName = base + ' (' + suffix + ')'
269
+ suffix += 1
270
+ }
271
+ seen.add(proxyName)
272
+ proxies.push({ ...row, name: proxyName, type, server, port })
273
+ }
274
+ return { proxies, skipped, doc }
275
+ }
276
+
277
+ function historyDelay(entry) {
278
+ if (!entry || !Array.isArray(entry.history) || entry.history.length === 0) return 0
279
+ const last = entry.history[entry.history.length - 1]
280
+ return Number(last && last.delay) || 0
281
+ }
282
+
283
+ function objectSchema(properties) {
284
+ return { type: 'object', additionalProperties: false, properties }
285
+ }
286
+
287
+ function stringField(description) {
288
+ return { type: 'string', required: true, description }
289
+ }
290
+
291
+ function numberField(description) {
292
+ return { type: 'number', required: true, description }
293
+ }
294
+
295
+ function booleanField(description) {
296
+ return { type: 'boolean', required: true, description }
297
+ }
298
+
299
+ const nodeSchema = {
300
+ type: 'object',
301
+ additionalProperties: false,
302
+ properties: {
303
+ name: stringField('节点名称'),
304
+ type: stringField('协议类型'),
305
+ delay: numberField('最近一次延迟(毫秒,0 表示未知)'),
306
+ selected: booleanField('当前是否被手动选中')
307
+ }
308
+ }
309
+
310
+ const summaryProperties = {
311
+ running: booleanField('核心是否正在运行'),
312
+ subscription: stringField('订阅显示名'),
313
+ subscriptionHost: stringField('订阅来源主机或文件名'),
314
+ nodeCount: numberField('节点数量'),
315
+ mode: stringField('auto 或 manual'),
316
+ selected: stringField('当前选中的节点或 DSH-AUTO'),
317
+ mixedPort: numberField('本机混合代理端口'),
318
+ coreVersion: stringField('Mihomo 版本'),
319
+ message: stringField('人类可读的结果摘要')
320
+ }
321
+
322
+ function textBlock(value) {
323
+ return [{ type: 'text', text: String(value == null ? '' : value) }]
324
+ }
325
+
326
+ function summaryText(args, value) {
327
+ return textBlock(value && value.message ? value.message : '操作完成')
328
+ }
329
+
330
+ class ClashProxyRuntime {
331
+ constructor(ctx, config) {
332
+ this.ctx = ctx
333
+ this.config = config
334
+ this.home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
335
+ this.dataDir = config.dataDir || path.join(this.home, 'clash-proxy')
336
+ this.stateFile = path.join(this.dataDir, 'state.json')
337
+ this.configPath = path.join(this.dataDir, 'config.yaml')
338
+ this.proxiesFile = path.join(this.dataDir, 'proxies.json')
339
+ this.subscriptionFile = path.join(this.dataDir, 'subscription.yaml')
340
+ this.state = emptyState()
341
+ this.proxies = []
342
+ this.child = null
343
+ this.ports = { mixed: 0, controller: 0 }
344
+ this.secret = ''
345
+ this.active = false
346
+ this.dispatcherInstalled = false
347
+ this.previousDispatcher = null
348
+ this.agent = null
349
+ this.directAgent = null
350
+ this.undici = null
351
+ this.proxyAgent = null
352
+ this.proxyAgentPort = 0
353
+ this.nativeFetch = globalThis.fetch
354
+ this.wrappedFetch = null
355
+ this.previousFetch = null
356
+ this.ready = null
357
+ this.coreVersion = ''
358
+ this.lastError = ''
359
+ this.stderrTail = []
360
+ this.lock = Promise.resolve()
361
+ }
362
+
363
+ log(level, message) {
364
+ const logger = this.ctx && this.ctx.logger
365
+ if (!logger) return
366
+ const fn = logger[level] || logger.info
367
+ if (typeof fn === 'function') {
368
+ try {
369
+ fn.call(logger, '[dsh-llm-proxy] ' + message)
370
+ } catch {
371
+ // logging must never break the proxy
372
+ }
373
+ }
374
+ }
375
+
376
+ runExclusive(fn) {
377
+ const next = this.lock.then(fn, fn)
378
+ this.lock = next.catch(() => {})
379
+ return next
380
+ }
381
+
382
+ async loadState() {
383
+ try {
384
+ const raw = await fs.readFile(this.stateFile, 'utf8')
385
+ const parsed = JSON.parse(raw)
386
+ const base = emptyState()
387
+ this.state = {
388
+ ...base,
389
+ ...(parsed && typeof parsed === 'object' ? parsed : {}),
390
+ ports: {
391
+ mixed: Number(parsed && parsed.ports && parsed.ports.mixed) || 0,
392
+ controller: Number(parsed && parsed.ports && parsed.ports.controller) || 0
393
+ },
394
+ subscription: parsed && parsed.subscription && typeof parsed.subscription === 'object' ? parsed.subscription : null,
395
+ proxyScope: parsed && (parsed.proxyScope === 'all' || parsed.proxyScope === 'models')
396
+ ? parsed.proxyScope
397
+ : (this.config.proxyScope === 'models' ? 'models' : 'all'),
398
+ bypassHosts: parsed && Array.isArray(parsed.bypassHosts)
399
+ ? normalizeBypassList(parsed.bypassHosts)
400
+ : normalizeBypassList(this.config.bypassHosts || DEFAULT_BYPASS_HOSTS)
401
+ }
402
+ } catch {
403
+ this.state = emptyState()
404
+ }
405
+ if (!this.state.secret) this.state.secret = randomBytes(24).toString('hex')
406
+ this.secret = this.state.secret
407
+ this.ports = { ...this.state.ports }
408
+ this.coreVersion = ''
409
+ this.lastError = this.state.lastError || ''
410
+ }
411
+
412
+ async saveState() {
413
+ await fs.mkdir(this.dataDir, { recursive: true })
414
+ this.state.ports = { ...this.ports }
415
+ this.state.secret = this.secret || this.state.secret
416
+ this.state.lastError = this.lastError || ''
417
+ const tmp = this.stateFile + '.tmp'
418
+ const body = JSON.stringify(this.state, null, 2)
419
+ try {
420
+ await fs.writeFile(tmp, body, { mode: 0o600 })
421
+ await fs.rename(tmp, this.stateFile)
422
+ } catch {
423
+ await fs.writeFile(this.stateFile, body, { mode: 0o600 })
424
+ await fs.rm(tmp, { force: true }).catch(() => {})
425
+ }
426
+ }
427
+
428
+ async loadProxies() {
429
+ try {
430
+ const raw = await fs.readFile(this.proxiesFile, 'utf8')
431
+ const parsed = JSON.parse(raw)
432
+ if (Array.isArray(parsed) && parsed.length > 0) {
433
+ this.proxies = parsed
434
+ return
435
+ }
436
+ } catch {
437
+ // fall back to the saved subscription
438
+ }
439
+ try {
440
+ const text = await fs.readFile(this.subscriptionFile, 'utf8')
441
+ const parsed = parseSubscription(text)
442
+ this.proxies = parsed.proxies
443
+ } catch {
444
+ throw new Error('没有可用的节点配置,请先调用 clash_subscribe 导入订阅')
445
+ }
446
+ if (this.proxies.length === 0) throw new Error('没有可用的节点配置,请重新导入订阅')
447
+ }
448
+
449
+ async persistSubscription(text) {
450
+ await fs.mkdir(this.dataDir, { recursive: true })
451
+ await fs.writeFile(this.subscriptionFile, text, { mode: 0o600 })
452
+ await fs.writeFile(this.proxiesFile, JSON.stringify(this.proxies, null, 2), { mode: 0o600 })
453
+ await fs.chmod(this.subscriptionFile, 0o600).catch(() => {})
454
+ await fs.chmod(this.proxiesFile, 0o600).catch(() => {})
455
+ }
456
+
457
+ async readSource(source, signal) {
458
+ throwIfAborted(signal)
459
+ if (isHttpSource(source)) {
460
+ return { text: await this.fetchSubscription(source, signal), sourceType: 'url' }
461
+ }
462
+ let file = source
463
+ if (source.startsWith('file://')) file = fileURLToPath(source)
464
+ try {
465
+ const stat = await fs.stat(file)
466
+ if (stat.size > MAX_SUBSCRIPTION_BYTES) throw new Error('订阅文件过大')
467
+ const text = await fs.readFile(file, 'utf8')
468
+ return { text, sourceType: 'file' }
469
+ } catch (error) {
470
+ throw new Error('读取本地订阅失败: ' + messageOf(error))
471
+ }
472
+ }
473
+
474
+ async getUndici() {
475
+ if (this.undici) return this.undici
476
+ try {
477
+ this.undici = await import('undici')
478
+ } catch (error) {
479
+ this.log('warn', 'undici 不可用: ' + messageOf(error))
480
+ this.undici = null
481
+ }
482
+ return this.undici
483
+ }
484
+
485
+ async getDirectAgent() {
486
+ if (this.directAgent) return this.directAgent
487
+ const undici = await this.getUndici()
488
+ if (!undici) return null
489
+ this.directAgent = new undici.Agent({ connections: 8 })
490
+ return this.directAgent
491
+ }
492
+
493
+ async getProxyAgent() {
494
+ const undici = await this.getUndici()
495
+ if (!undici || !this.ports.mixed) return null
496
+ if (this.proxyAgent && this.proxyAgentPort === this.ports.mixed) return this.proxyAgent
497
+ await this.closeProxyAgent()
498
+ this.proxyAgent = new undici.ProxyAgent({ uri: 'http://127.0.0.1:' + this.ports.mixed, connections: 64 })
499
+ this.proxyAgentPort = this.ports.mixed
500
+ return this.proxyAgent
501
+ }
502
+
503
+ async closeProxyAgent() {
504
+ const agent = this.proxyAgent
505
+ this.proxyAgent = null
506
+ this.proxyAgentPort = 0
507
+ if (!agent) return
508
+ try {
509
+ if (typeof agent.close === 'function') await agent.close()
510
+ } catch {
511
+ // ignore shutdown races
512
+ }
513
+ }
514
+
515
+ async request(url, init, dispatcher) {
516
+ const undici = await this.getUndici()
517
+ const options = { ...(init || {}) }
518
+ if (dispatcher && options.dispatcher === undefined) options.dispatcher = dispatcher
519
+ if (undici && typeof undici.fetch === 'function') return await undici.fetch(url, options)
520
+ return await this.nativeFetch(url, options)
521
+ }
522
+
523
+ async fetchText(url, headers, signal, dispatcher) {
524
+ const timeout = AbortSignal.timeout(30000)
525
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout
526
+ const response = await this.request(url, {
527
+ method: 'GET',
528
+ redirect: 'follow',
529
+ headers,
530
+ signal: combined,
531
+ ...(dispatcher ? { dispatcher } : {})
532
+ })
533
+ if (!response.ok) throw new Error('订阅地址返回 HTTP ' + response.status)
534
+ const length = Number(response.headers.get('content-length') || 0)
535
+ if (length > MAX_SUBSCRIPTION_BYTES) throw new Error('订阅内容超过 10MB 限制')
536
+ const text = await response.text()
537
+ if (text.length > MAX_SUBSCRIPTION_BYTES) throw new Error('订阅内容超过 10MB 限制')
538
+ return text
539
+ }
540
+
541
+ async fetchSubscription(url, signal) {
542
+ const headers = {
543
+ 'user-agent': 'clash.meta/1.19.34',
544
+ accept: '*/*',
545
+ 'accept-encoding': 'gzip, deflate, br'
546
+ }
547
+ const direct = await this.getDirectAgent()
548
+ try {
549
+ return await this.fetchText(url, headers, signal, direct)
550
+ } catch (firstError) {
551
+ if (this.active) {
552
+ try {
553
+ const proxy = await this.getProxyAgent()
554
+ return await this.fetchText(url, headers, signal, proxy || undefined)
555
+ } catch (secondError) {
556
+ throw new Error('下载订阅失败: ' + messageOf(secondError))
557
+ }
558
+ }
559
+ throw new Error('下载订阅失败: ' + messageOf(firstError))
560
+ }
561
+ }
562
+
563
+ async fetchDirect(url, options) {
564
+ const direct = await this.getDirectAgent()
565
+ return await this.request(url, options, direct || undefined)
566
+ }
567
+
568
+ async isFileExecutable(file) {
569
+ try {
570
+ const stat = await fs.stat(file)
571
+ if (!stat.isFile()) return false
572
+ await fs.access(file, fsConstants.X_OK)
573
+ return true
574
+ } catch {
575
+ return false
576
+ }
577
+ }
578
+
579
+ coreCandidates() {
580
+ const result = []
581
+ if (this.config.corePath) result.push(this.config.corePath)
582
+ if (process.env.DSH_CLASH_CORE) result.push(process.env.DSH_CLASH_CORE)
583
+ if (this.state.corePath) result.push(this.state.corePath)
584
+ result.push(path.join(this.dataDir, 'bin', binaryName()))
585
+ const pkg = corePackageName()
586
+ if (pkg) {
587
+ try {
588
+ const require = createRequire(import.meta.url)
589
+ const entry = require.resolve('@pkgship/' + pkg)
590
+ result.push(path.join(path.dirname(entry), 'bin', binaryName()))
591
+ } catch {
592
+ // package not installed; the downloader handles it
593
+ }
594
+ }
595
+ return [...new Set(result)]
596
+ }
597
+
598
+ async resolveCore(allowDownload, signal) {
599
+ throwIfAborted(signal)
600
+ for (const candidate of this.coreCandidates()) {
601
+ if (candidate && (await this.isFileExecutable(candidate))) return candidate
602
+ }
603
+ if (allowDownload && this.config.downloadCore) {
604
+ const installed = await this.installCore(signal)
605
+ return installed.path
606
+ }
607
+ throw new Error('未找到 Mihomo 核心。请调用 clash_install_core 自动下载,或在插件配置 corePath 中指定 mihomo 可执行文件')
608
+ }
609
+
610
+ async installCore(signal) {
611
+ throwIfAborted(signal)
612
+ const pkg = corePackageName()
613
+ if (!pkg) throw new Error('当前平台不受支持: ' + process.platform + '-' + process.arch)
614
+ const metaUrl = 'https://registry.npmjs.org/' + encodeURIComponent('@pkgship/' + pkg) + '/latest'
615
+ const metaResponse = await this.fetchDirect(metaUrl, {
616
+ headers: { accept: 'application/json', 'user-agent': 'dsh-llm-proxy/' + VERSION },
617
+ signal
618
+ })
619
+ if (!metaResponse.ok) throw new Error('获取核心版本信息失败: HTTP ' + metaResponse.status)
620
+ const meta = await metaResponse.json()
621
+ const tarballUrl = meta && meta.dist && meta.dist.tarball
622
+ const integrity = meta && meta.dist && meta.dist.integrity
623
+ if (!tarballUrl) throw new Error('核心包元数据缺少 tarball 地址')
624
+ this.log('info', 'downloading ' + pkg + '@' + (meta.version || 'latest'))
625
+ const response = await this.fetchDirect(tarballUrl, {
626
+ headers: { accept: 'application/octet-stream', 'user-agent': 'dsh-llm-proxy/' + VERSION },
627
+ signal
628
+ })
629
+ if (!response.ok) throw new Error('下载核心失败: HTTP ' + response.status)
630
+ const bytes = Buffer.from(await response.arrayBuffer())
631
+ if (bytes.length > 100 * 1024 * 1024) throw new Error('核心压缩包超过 100MB,已中止')
632
+ if (integrity && integrity.startsWith('sha512-')) {
633
+ const actual = createHash('sha512').update(bytes).digest('base64')
634
+ if (actual !== integrity.slice('sha512-'.length)) throw new Error('核心包完整性校验失败')
635
+ }
636
+ const wanted = 'bin/' + binaryName()
637
+ const binary = extractTarEntry(bytes, wanted)
638
+ const dest = path.join(this.dataDir, 'bin', binaryName())
639
+ await fs.mkdir(path.dirname(dest), { recursive: true })
640
+ await fs.writeFile(dest, binary, { mode: 0o755 })
641
+ await fs.chmod(dest, 0o755).catch(() => {})
642
+ const version = await this.probeCore(dest)
643
+ this.state.corePath = dest
644
+ await this.saveState()
645
+ return { path: dest, version }
646
+ }
647
+
648
+ async probeCore(core) {
649
+ try {
650
+ const result = await execFileAsync(core, ['-v'], { timeout: 20000, windowsHide: true, maxBuffer: 1024 * 1024 })
651
+ const line = String(result.stdout || '').split(/\r?\n/).find((row) => row.trim().length > 0) || ''
652
+ return line.trim()
653
+ } catch (error) {
654
+ throw new Error('核心无法执行: ' + messageOf(error))
655
+ }
656
+ }
657
+
658
+ async freePort() {
659
+ return await new Promise((resolve, reject) => {
660
+ const server = net.createServer()
661
+ server.once('error', reject)
662
+ server.listen(0, '127.0.0.1', () => {
663
+ const address = server.address()
664
+ const port = address && typeof address === 'object' ? address.port : 0
665
+ server.close(() => resolve(port))
666
+ })
667
+ })
668
+ }
669
+
670
+ async portFree(port) {
671
+ if (!port) return false
672
+ return await new Promise((resolve) => {
673
+ const server = net.createServer()
674
+ server.once('error', () => resolve(false))
675
+ server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)))
676
+ })
677
+ }
678
+
679
+ async ensurePorts() {
680
+ const saved = this.state.ports || {}
681
+ if (saved.mixed && saved.controller && (await this.portFree(saved.mixed)) && (await this.portFree(saved.controller))) {
682
+ return { mixed: saved.mixed, controller: saved.controller }
683
+ }
684
+ let mixed = 0
685
+ if (this.config.mixedPort > 0 && (await this.portFree(this.config.mixedPort))) mixed = this.config.mixedPort
686
+ if (!mixed) mixed = await this.freePort()
687
+ let controller = await this.freePort()
688
+ let guard = 0
689
+ while ((controller === mixed || !(await this.portFree(controller))) && guard < 20) {
690
+ controller = await this.freePort()
691
+ guard += 1
692
+ }
693
+ return { mixed, controller }
694
+ }
695
+
696
+ buildConfig() {
697
+ const names = this.proxies.map((proxy) => proxy.name)
698
+ if (names.length === 0) throw new Error('没有可用节点,无法生成配置')
699
+ return {
700
+ 'mixed-port': this.ports.mixed,
701
+ 'allow-lan': false,
702
+ 'bind-address': '127.0.0.1',
703
+ mode: 'rule',
704
+ 'log-level': 'warning',
705
+ ipv6: false,
706
+ 'unified-delay': true,
707
+ 'tcp-concurrent': true,
708
+ 'find-process-mode': 'off',
709
+ 'external-controller': '127.0.0.1:' + this.ports.controller,
710
+ secret: this.secret,
711
+ 'geodata-mode': false,
712
+ 'geo-auto-update': false,
713
+ profile: { 'store-selected': true, 'store-fake-ip': false },
714
+ dns: { enable: false },
715
+ proxies: this.proxies,
716
+ 'proxy-groups': [
717
+ {
718
+ name: AUTO_GROUP,
719
+ type: 'url-test',
720
+ url: this.config.testUrl,
721
+ interval: this.config.testInterval,
722
+ tolerance: 50,
723
+ lazy: false,
724
+ proxies: names
725
+ },
726
+ {
727
+ name: SELECT_GROUP,
728
+ type: 'select',
729
+ proxies: [AUTO_GROUP, ...names]
730
+ }
731
+ ],
732
+ rules: ['MATCH,' + SELECT_GROUP]
733
+ }
734
+ }
735
+
736
+ async writeConfig() {
737
+ await fs.mkdir(this.dataDir, { recursive: true })
738
+ const body = YAML.stringify(this.buildConfig(), { lineWidth: 0 })
739
+ await fs.writeFile(this.configPath, body, { mode: 0o600 })
740
+ await fs.chmod(this.configPath, 0o600).catch(() => {})
741
+ }
742
+
743
+ async testConfig(core) {
744
+ try {
745
+ const result = await execFileAsync(core, ['-t', '-d', this.dataDir, '-f', this.configPath], {
746
+ timeout: 30000,
747
+ windowsHide: true,
748
+ maxBuffer: 4 * 1024 * 1024
749
+ })
750
+ this.log('debug', 'config test ok: ' + tail(result.stdout || '', 200))
751
+ } catch (error) {
752
+ const output = [error && error.stdout, error && error.stderr].filter(Boolean).map(String).join('\n')
753
+ throw new Error('Mihomo 配置校验失败: ' + tail(output || messageOf(error), 1200))
754
+ }
755
+ }
756
+
757
+ async spawnCore(core) {
758
+ this.stderrTail = []
759
+ this.lastError = ''
760
+ const child = spawn(core, ['-d', this.dataDir, '-f', this.configPath], {
761
+ cwd: this.dataDir,
762
+ windowsHide: true,
763
+ stdio: ['ignore', 'pipe', 'pipe']
764
+ })
765
+ this.child = child
766
+ child.stdout?.setEncoding('utf8')
767
+ child.stderr?.setEncoding('utf8')
768
+ child.stdout?.on('data', (chunk) => this.log('debug', String(chunk).trim()))
769
+ child.stderr?.on('data', (chunk) => {
770
+ const line = String(chunk).trim()
771
+ if (!line) return
772
+ this.stderrTail.push(line)
773
+ if (this.stderrTail.length > 40) this.stderrTail.splice(0, this.stderrTail.length - 40)
774
+ this.log('debug', line)
775
+ })
776
+ child.once('error', (error) => {
777
+ this.lastError = 'Mihomo 进程错误: ' + messageOf(error)
778
+ if (this.child === child) {
779
+ this.child = null
780
+ this.active = false
781
+ }
782
+ })
783
+ child.once('exit', (code, signal) => {
784
+ if (this.child === child) {
785
+ this.child = null
786
+ this.active = false
787
+ }
788
+ this.log('warn', 'mihomo exited code=' + String(code) + ' signal=' + String(signal))
789
+ })
790
+ const deadline = Date.now() + 20000
791
+ let last = ''
792
+ while (Date.now() < deadline) {
793
+ if (this.child !== child) {
794
+ throw new Error('Mihomo 启动失败: ' + (this.lastError || tail(this.stderrTail.join(' | '), 1200)))
795
+ }
796
+ try {
797
+ const response = await fetch(this.controllerUrl('/version'), {
798
+ headers: { Authorization: 'Bearer ' + this.secret },
799
+ signal: AbortSignal.timeout(800)
800
+ })
801
+ if (response.ok) {
802
+ const info = await response.json().catch(() => ({}))
803
+ this.coreVersion = String(info.version || '')
804
+ return
805
+ }
806
+ last = 'HTTP ' + response.status
807
+ } catch (error) {
808
+ last = messageOf(error)
809
+ }
810
+ await sleep(100)
811
+ }
812
+ throw new Error('Mihomo 控制器未就绪: ' + last + ' ' + tail(this.stderrTail.join(' | '), 800))
813
+ }
814
+
815
+ controllerUrl(pathname) {
816
+ return 'http://127.0.0.1:' + this.ports.controller + pathname
817
+ }
818
+
819
+ async api(pathname, init) {
820
+ const options = { ...(init || {}) }
821
+ const headers = { Authorization: 'Bearer ' + this.secret }
822
+ if (options.body) headers['content-type'] = 'application/json'
823
+ options.headers = { ...headers, ...(options.headers || {}) }
824
+ if (!options.signal) options.signal = AbortSignal.timeout(6000)
825
+ const response = await fetch(this.controllerUrl(pathname), options)
826
+ const text = await response.text()
827
+ if (!response.ok) {
828
+ throw new Error('Mihomo API ' + pathname + ' 返回 HTTP ' + response.status + (text ? ': ' + text.slice(0, 300) : ''))
829
+ }
830
+ if (!text) return {}
831
+ try {
832
+ const parsed = JSON.parse(text)
833
+ if (pathname === '/proxies' && parsed && typeof parsed === 'object' && parsed.proxies && typeof parsed.proxies === 'object') {
834
+ return parsed.proxies
835
+ }
836
+ return parsed
837
+ } catch {
838
+ return { raw: text }
839
+ }
840
+ }
841
+
842
+ async health() {
843
+ if (!this.child || !this.ports.controller) return false
844
+ try {
845
+ const response = await fetch(this.controllerUrl('/version'), {
846
+ headers: { Authorization: 'Bearer ' + this.secret },
847
+ signal: AbortSignal.timeout(1500)
848
+ })
849
+ return response.ok
850
+ } catch {
851
+ return false
852
+ }
853
+ }
854
+
855
+ shouldBypass(hostname) {
856
+ const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
857
+ if (isDirectHost(host)) return true
858
+ if (DEFAULT_BYPASS_HOSTS.some((rule) => hostMatchesPattern(host, rule))) return true
859
+ if (this.state.proxyScope === 'models' && !isModelHost(host)) return true
860
+ const rules = Array.isArray(this.state.bypassHosts) ? this.state.bypassHosts : []
861
+ return rules.some((rule) => hostMatchesPattern(host, rule))
862
+ }
863
+
864
+ installFetchWrapper() {
865
+ if (this.wrappedFetch) return
866
+ const previous = globalThis.fetch
867
+ if (typeof previous !== 'function') return
868
+ this.previousFetch = previous
869
+ const runtime = this
870
+ const wrapped = async function (input, init) {
871
+ if (!runtime.active) return await previous(input, init)
872
+ let hostname
873
+ try {
874
+ const raw = typeof input === 'string' || input instanceof URL ? String(input) : input && input.url
875
+ hostname = new URL(raw).hostname
876
+ } catch {
877
+ return await previous(input, init)
878
+ }
879
+ if (runtime.shouldBypass(hostname)) return await previous(input, init)
880
+ const agent = await runtime.getProxyAgent()
881
+ if (!agent) return await previous(input, init)
882
+ const options = { ...(init || {}) }
883
+ if (options.dispatcher === undefined) options.dispatcher = agent
884
+ return await runtime.request(input, options)
885
+ }
886
+ this.wrappedFetch = wrapped
887
+ globalThis.fetch = wrapped
888
+ }
889
+
890
+ uninstallFetchWrapper() {
891
+ if (this.wrappedFetch && globalThis.fetch === this.wrappedFetch) {
892
+ globalThis.fetch = this.previousFetch || this.nativeFetch
893
+ }
894
+ this.wrappedFetch = null
895
+ this.previousFetch = null
896
+ }
897
+
898
+ async installDispatcher() {
899
+ this.installFetchWrapper()
900
+ this.active = true
901
+ if (!this.wrappedFetch) this.log('warn', '无法安装 DSH fetch 代理包装器')
902
+ else this.log('info', 'DSH 的网络请求已指向 127.0.0.1:' + this.ports.mixed)
903
+ }
904
+
905
+ async uninstallDispatcher() {
906
+ this.active = false
907
+ await this.closeProxyAgent()
908
+ }
909
+
910
+ async killChild(child) {
911
+ if (!child || child.exitCode !== null) return
912
+ await new Promise((resolve) => {
913
+ let settled = false
914
+ const finish = () => {
915
+ if (settled) return
916
+ settled = true
917
+ clearTimeout(timer)
918
+ resolve()
919
+ }
920
+ const timer = setTimeout(async () => {
921
+ try {
922
+ if (process.platform === 'win32' && child.pid) {
923
+ await execFileAsync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }).catch(() => {})
924
+ } else {
925
+ child.kill('SIGKILL')
926
+ }
927
+ } catch {
928
+ // ignore
929
+ }
930
+ finish()
931
+ }, 4000)
932
+ child.once('exit', finish)
933
+ try {
934
+ child.kill()
935
+ } catch {
936
+ finish()
937
+ }
938
+ })
939
+ }
940
+
941
+ async _stop(options) {
942
+ const persist = !options || options.persist !== false
943
+ await this.uninstallDispatcher()
944
+ const child = this.child
945
+ this.child = null
946
+ if (child) await this.killChild(child)
947
+ this.active = false
948
+ if (persist) {
949
+ this.state.enabled = false
950
+ await this.saveState().catch(() => {})
951
+ }
952
+ return { running: false, message: 'DSH LLM Proxy 已停止;DSH 的网络请求已恢复直连,系统代理未改动。' }
953
+ }
954
+
955
+ async _start(options) {
956
+ const selection = options && options.selection ? options.selection : 'auto'
957
+ const signal = options && options.signal
958
+ throwIfAborted(signal)
959
+ await this._stop({ persist: false })
960
+ try {
961
+ if (this.proxies.length === 0) await this.loadProxies()
962
+ const core = await this.resolveCore(true, signal)
963
+ this.ports = await this.ensurePorts()
964
+ this.secret = this.state.secret || randomBytes(24).toString('hex')
965
+ this.state.secret = this.secret
966
+ await this.writeConfig()
967
+ await this.testConfig(core)
968
+ await this.spawnCore(core)
969
+ this.active = true
970
+ await this.installDispatcher()
971
+ let target = selection
972
+ if (target !== 'auto' && target !== AUTO_GROUP && !this.proxies.some((proxy) => proxy.name === target)) target = 'auto'
973
+ try {
974
+ await this.selectInternal(target, signal)
975
+ } catch (error) {
976
+ this.lastError = '节点选择失败,已回退自动: ' + messageOf(error)
977
+ this.log('warn', this.lastError)
978
+ await this.selectInternal('auto', signal).catch(() => {})
979
+ }
980
+ this.state.enabled = true
981
+ this.state.lastError = this.lastError
982
+ await this.saveState()
983
+ return this.summary('已启动 DSH LLM Proxy')
984
+ } catch (error) {
985
+ this.lastError = messageOf(error)
986
+ this.state.enabled = false
987
+ this.state.lastError = this.lastError
988
+ await this.saveState().catch(() => {})
989
+ await this._stop({ persist: false }).catch(() => {})
990
+ throw error
991
+ }
992
+ }
993
+
994
+ async selectInternal(name, signal) {
995
+ throwIfAborted(signal)
996
+ const target = name === 'auto' || name === AUTO_GROUP ? AUTO_GROUP : name
997
+ if (target !== AUTO_GROUP && !this.proxies.some((proxy) => proxy.name === target)) {
998
+ throw new Error('节点不存在: ' + target)
999
+ }
1000
+ await this.api('/proxies/' + encodeURIComponent(SELECT_GROUP), {
1001
+ method: 'PUT',
1002
+ body: JSON.stringify({ name: target }),
1003
+ signal
1004
+ })
1005
+ this.state.selected = target
1006
+ this.state.mode = target === AUTO_GROUP ? 'auto' : 'manual'
1007
+ }
1008
+
1009
+ async _subscribe(source, displayName, signal) {
1010
+ const trimmed = String(source || '').trim()
1011
+ if (!trimmed) throw new Error('订阅链接不能为空')
1012
+ throwIfAborted(signal)
1013
+ const loaded = await this.readSource(trimmed, signal)
1014
+ const parsed = parseSubscription(loaded.text)
1015
+ if (parsed.proxies.length === 0) throw new Error('订阅中没有可用的代理节点')
1016
+ this.proxies = parsed.proxies
1017
+ const host = displaySource(trimmed)
1018
+ this.state.subscription = {
1019
+ source: trimmed,
1020
+ sourceType: loaded.sourceType,
1021
+ host,
1022
+ name: displayName && displayName.trim() ? displayName.trim() : host,
1023
+ updatedAt: new Date().toISOString(),
1024
+ nodeCount: parsed.proxies.length,
1025
+ skipped: parsed.skipped
1026
+ }
1027
+ await this.persistSubscription(loaded.text)
1028
+ await this.saveState()
1029
+ this.log('info', 'imported ' + parsed.proxies.length + ' nodes from ' + host)
1030
+ return await this._start({ selection: 'auto', signal })
1031
+ }
1032
+
1033
+ async subscribe(source, displayName, signal) {
1034
+ await this.ready
1035
+ return await this.runExclusive(async () => {
1036
+ const result = await this._subscribe(source, displayName, signal)
1037
+ return {
1038
+ ...result,
1039
+ imported: true,
1040
+ message: '已导入 ' + result.nodeCount + ' 个节点并启动代理;DSH 的请求将走 127.0.0.1:' + result.mixedPort + ',系统代理未改动。'
1041
+ }
1042
+ })
1043
+ }
1044
+
1045
+ async update(signal) {
1046
+ await this.ready
1047
+ return await this.runExclusive(async () => {
1048
+ const subscription = this.state.subscription
1049
+ if (!subscription || !subscription.source) throw new Error('尚未导入订阅,请先调用 clash_subscribe')
1050
+ const previousMode = this.state.mode
1051
+ const previousSelected = this.state.selected
1052
+ const loaded = await this.readSource(subscription.source, signal)
1053
+ const parsed = parseSubscription(loaded.text)
1054
+ if (parsed.proxies.length === 0) throw new Error('订阅中没有可用的代理节点')
1055
+ this.proxies = parsed.proxies
1056
+ subscription.updatedAt = new Date().toISOString()
1057
+ subscription.nodeCount = parsed.proxies.length
1058
+ subscription.skipped = parsed.skipped
1059
+ await this.persistSubscription(loaded.text)
1060
+ await this.saveState()
1061
+ const selection = previousMode === 'manual' && parsed.proxies.some((proxy) => proxy.name === previousSelected) ? previousSelected : 'auto'
1062
+ const result = await this._start({ selection, signal })
1063
+ return {
1064
+ ...result,
1065
+ updated: true,
1066
+ message: '订阅已更新,共 ' + result.nodeCount + ' 个节点;' + result.message
1067
+ }
1068
+ })
1069
+ }
1070
+
1071
+ async start(signal) {
1072
+ await this.ready
1073
+ return await this.runExclusive(async () => {
1074
+ const selection = this.state.mode === 'manual' ? this.state.selected : 'auto'
1075
+ return await this._start({ selection, signal })
1076
+ })
1077
+ }
1078
+
1079
+ async stop() {
1080
+ await this.ready
1081
+ return await this.runExclusive(async () => await this._stop({ persist: true }))
1082
+ }
1083
+
1084
+ summary(prefix) {
1085
+ const subscription = this.state.subscription || {}
1086
+ return {
1087
+ running: true,
1088
+ subscription: String(subscription.name || ''),
1089
+ subscriptionHost: String(subscription.host || ''),
1090
+ nodeCount: this.proxies.length,
1091
+ mode: this.state.mode || 'auto',
1092
+ selected: this.state.selected || AUTO_GROUP,
1093
+ mixedPort: this.ports.mixed,
1094
+ coreVersion: this.coreVersion,
1095
+ message: prefix + ':' + this.proxies.length + ' 个节点,混合端口 127.0.0.1:' + this.ports.mixed + ',当前 ' + (this.state.mode === 'manual' ? '手动节点 ' + this.state.selected : '自动选择 ' + AUTO_GROUP) + '。系统代理未改动。'
1096
+ }
1097
+ }
1098
+
1099
+ async status() {
1100
+ await this.ready
1101
+ const subscription = this.state.subscription
1102
+ const configured = Boolean(subscription)
1103
+ const running = await this.health()
1104
+ let selected = this.state.selected || AUTO_GROUP
1105
+ let mode = running ? (selected === AUTO_GROUP ? 'auto' : 'manual') : 'off'
1106
+ let coreVersion = this.coreVersion
1107
+ if (running) {
1108
+ try {
1109
+ const group = await this.api('/proxies/' + encodeURIComponent(SELECT_GROUP))
1110
+ if (group && group.now) selected = String(group.now)
1111
+ mode = selected === AUTO_GROUP ? 'auto' : 'manual'
1112
+ const version = await this.api('/version')
1113
+ coreVersion = String((version && version.version) || coreVersion)
1114
+ } catch (error) {
1115
+ this.lastError = messageOf(error)
1116
+ }
1117
+ }
1118
+ return {
1119
+ configured,
1120
+ running,
1121
+ mode,
1122
+ selected,
1123
+ subscription: String(subscription && subscription.name ? subscription.name : ''),
1124
+ subscriptionHost: String(subscription && subscription.host ? subscription.host : ''),
1125
+ updatedAt: String(subscription && subscription.updatedAt ? subscription.updatedAt : ''),
1126
+ nodeCount: subscription && Number(subscription.nodeCount) ? Number(subscription.nodeCount) : this.proxies.length,
1127
+ mixedPort: this.ports.mixed || 0,
1128
+ controllerPort: this.ports.controller || 0,
1129
+ coreVersion,
1130
+ pid: this.child && this.child.pid ? this.child.pid : 0,
1131
+ autoGroup: AUTO_GROUP,
1132
+ testUrl: this.config.testUrl,
1133
+ lastError: this.lastError || this.state.lastError || ''
1134
+ }
1135
+ }
1136
+
1137
+ policy() {
1138
+ const saved = Array.isArray(this.state.bypassHosts) ? this.state.bypassHosts : []
1139
+ return {
1140
+ proxyScope: this.state.proxyScope === 'models' ? 'models' : 'all',
1141
+ bypassHosts: normalizeBypassList([...DEFAULT_BYPASS_HOSTS, ...saved]),
1142
+ defaultBypassHosts: DEFAULT_BYPASS_HOSTS.slice(),
1143
+ modelHosts: MODEL_HOSTS.slice()
1144
+ }
1145
+ }
1146
+
1147
+ async setPolicy(input) {
1148
+ const body = input && typeof input === 'object' ? input : {}
1149
+ this.state.proxyScope = body.proxyScope === 'models' ? 'models' : 'all'
1150
+ this.state.bypassHosts = normalizeBypassList(body.bypassHosts)
1151
+ await this.saveState()
1152
+ this.log('info', '代理范围已更新: ' + this.state.proxyScope + ',绕行规则 ' + this.state.bypassHosts.length + ' 条')
1153
+ return { policy: this.policy(), message: '代理范围与绕行规则已保存,立即生效。' }
1154
+ }
1155
+
1156
+ async uiState() {
1157
+ await this.ready
1158
+ if (this.proxies.length === 0 && this.state.subscription) await this.loadProxies().catch(() => {})
1159
+ const status = await this.status()
1160
+ let activeNode = status.selected
1161
+ let list = this.proxies.map((proxy) => ({
1162
+ name: proxy.name,
1163
+ type: String(proxy.type || ''),
1164
+ delay: 0,
1165
+ selected: proxy.name === status.selected
1166
+ }))
1167
+ if (status.running) {
1168
+ try {
1169
+ const all = await this.api('/proxies')
1170
+ const group = all[SELECT_GROUP] || {}
1171
+ const auto = all[AUTO_GROUP] || {}
1172
+ if (group.now === AUTO_GROUP && auto.now) activeNode = String(auto.now)
1173
+ if (group.now && group.now !== AUTO_GROUP) activeNode = String(group.now)
1174
+ list = list.map((node) => ({
1175
+ ...node,
1176
+ delay: historyDelay(all[node.name]),
1177
+ selected: node.name === group.now
1178
+ }))
1179
+ } catch (error) {
1180
+ this.lastError = messageOf(error)
1181
+ }
1182
+ }
1183
+ return { status: { ...status, activeNode }, nodes: list, policy: this.policy() }
1184
+ }
1185
+
1186
+ async nodes(options) {
1187
+ await this.ready
1188
+ if (!(await this.health())) throw new Error('代理未运行,请先调用 clash_start 或 clash_subscribe')
1189
+ const all = await this.api('/proxies')
1190
+ const group = all[SELECT_GROUP] || {}
1191
+ let list = this.proxies.map((proxy) => ({
1192
+ name: proxy.name,
1193
+ type: String(proxy.type || ''),
1194
+ delay: historyDelay(all[proxy.name]),
1195
+ selected: proxy.name === group.now
1196
+ }))
1197
+ if (options && options.test === true) {
1198
+ const limit = Number.isFinite(Number(options.limit)) ? Math.max(1, Math.min(50, Math.trunc(Number(options.limit)))) : 12
1199
+ const names = list.slice(0, limit).map((node) => node.name)
1200
+ const delays = await this.testMany(names, options.signal)
1201
+ list = list.map((node) => (delays.has(node.name) ? { ...node, delay: delays.get(node.name) } : node))
1202
+ }
1203
+ return {
1204
+ count: list.length,
1205
+ selected: String(group.now || ''),
1206
+ auto: group.now === AUTO_GROUP,
1207
+ nodes: list
1208
+ }
1209
+ }
1210
+
1211
+ async testMany(names, signal) {
1212
+ const unique = [...new Set(names.filter((name) => this.proxies.some((proxy) => proxy.name === name)))]
1213
+ const results = new Map()
1214
+ let cursor = 0
1215
+ const workers = Array.from({ length: Math.min(8, unique.length) }, async () => {
1216
+ while (cursor < unique.length) {
1217
+ throwIfAborted(signal)
1218
+ const name = unique[cursor]
1219
+ cursor += 1
1220
+ results.set(name, await this.testNode(name, signal).catch(() => 0))
1221
+ }
1222
+ })
1223
+ await Promise.all(workers)
1224
+ return results
1225
+ }
1226
+
1227
+ async testNode(name, signal) {
1228
+ const url = '/proxies/' + encodeURIComponent(name) + '/delay?url=' + encodeURIComponent(this.config.testUrl) + '&timeout=5000'
1229
+ const result = await this.api(url, { signal })
1230
+ return Number(result && result.delay) || 0
1231
+ }
1232
+
1233
+ async test(names, limit, signal) {
1234
+ await this.ready
1235
+ if (!(await this.health())) throw new Error('代理未运行,请先调用 clash_start 或 clash_subscribe')
1236
+ let requested = Array.isArray(names) ? names.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim()) : []
1237
+ if (requested.length === 0) {
1238
+ const capped = Number.isFinite(Number(limit)) ? Math.max(1, Math.min(50, Math.trunc(Number(limit)))) : 12
1239
+ requested = this.proxies.slice(0, capped).map((proxy) => proxy.name)
1240
+ }
1241
+ const delays = await this.testMany(requested, signal)
1242
+ const all = await this.api('/proxies')
1243
+ const group = all[SELECT_GROUP] || {}
1244
+ const list = this.proxies.map((proxy) => ({
1245
+ name: proxy.name,
1246
+ type: String(proxy.type || ''),
1247
+ delay: delays.has(proxy.name) ? delays.get(proxy.name) : historyDelay(all[proxy.name]),
1248
+ selected: proxy.name === group.now
1249
+ }))
1250
+ const tested = list.filter((node) => delays.has(node.name))
1251
+ const best = tested.slice().sort((a, b) => (a.delay || 999999) - (b.delay || 999999))[0]
1252
+ return {
1253
+ tested: tested.length,
1254
+ nodes: list,
1255
+ message: '已测试 ' + tested.length + ' 个节点' + (best ? ',当前最快 ' + best.name + '(' + best.delay + 'ms)' : '') + '。'
1256
+ }
1257
+ }
1258
+
1259
+ async select(name, signal) {
1260
+ await this.ready
1261
+ return await this.runExclusive(async () => {
1262
+ if (!(await this.health())) throw new Error('代理未运行,请先调用 clash_start 或 clash_subscribe')
1263
+ const target = String(name || '').trim()
1264
+ if (!target) throw new Error('节点名称不能为空')
1265
+ await this.selectInternal(target, signal)
1266
+ await this.saveState()
1267
+ return {
1268
+ selected: this.state.selected,
1269
+ mode: this.state.mode,
1270
+ message: this.state.mode === 'auto' ? '已切换为自动选择(DSH-AUTO)' : '已切换到节点 ' + this.state.selected
1271
+ }
1272
+ })
1273
+ }
1274
+
1275
+ async auto(signal) {
1276
+ return await this.select('auto', signal)
1277
+ }
1278
+
1279
+ async installCoreTool(signal) {
1280
+ await this.ready
1281
+ return await this.runExclusive(async () => {
1282
+ const installed = await this.installCore(signal)
1283
+ return {
1284
+ installed: true,
1285
+ path: installed.path,
1286
+ version: installed.version,
1287
+ message: 'Mihomo 核心已安装到 ' + installed.path + '(' + installed.version + ')'
1288
+ }
1289
+ })
1290
+ }
1291
+
1292
+ async boot() {
1293
+ try {
1294
+ await this.loadState()
1295
+ if (!this.config.autoStart) return
1296
+ if (this.state.enabled === false) return
1297
+ if (!this.state.subscription && this.config.subscriptionUrl) {
1298
+ await this.runExclusive(async () => {
1299
+ await this._subscribe(this.config.subscriptionUrl, '', undefined)
1300
+ })
1301
+ return
1302
+ }
1303
+ if (!this.state.subscription) return
1304
+ await this.runExclusive(async () => {
1305
+ const selection = this.state.mode === 'manual' ? this.state.selected : 'auto'
1306
+ await this._start({ selection })
1307
+ })
1308
+ } catch (error) {
1309
+ this.lastError = messageOf(error)
1310
+ this.state.lastError = this.lastError
1311
+ this.log('warn', '自动启动失败: ' + this.lastError)
1312
+ await this.saveState().catch(() => {})
1313
+ }
1314
+ }
1315
+
1316
+ async dispose() {
1317
+ try {
1318
+ await this.runExclusive(async () => {
1319
+ await this._stop({ persist: false })
1320
+ })
1321
+ } catch {
1322
+ // ignore
1323
+ }
1324
+ this.uninstallFetchWrapper()
1325
+ await this.closeProxyAgent()
1326
+ if (this.directAgent) {
1327
+ try {
1328
+ if (typeof this.directAgent.destroy === 'function') this.directAgent.destroy()
1329
+ else await this.directAgent.close()
1330
+ } catch {
1331
+ // ignore
1332
+ }
1333
+ this.directAgent = null
1334
+ }
1335
+ }
1336
+ }
1337
+
1338
+ function registerTool(ctx, definition) {
1339
+ ctx.tools.register(defineTool(definition))
1340
+ }
1341
+
1342
+ function writeJson(res, status, value) {
1343
+ const body = JSON.stringify(value)
1344
+ res.writeHead(status, {
1345
+ 'content-type': 'application/json; charset=utf-8',
1346
+ 'cache-control': 'no-store',
1347
+ 'x-content-type-options': 'nosniff'
1348
+ })
1349
+ res.end(body)
1350
+ }
1351
+
1352
+ function readJsonBody(req, limit = 64 * 1024) {
1353
+ return new Promise((resolve, reject) => {
1354
+ let size = 0
1355
+ const chunks = []
1356
+ req.on('data', (chunk) => {
1357
+ size += chunk.length
1358
+ if (size > limit) {
1359
+ const error = new Error('请求体过大')
1360
+ error.statusCode = 413
1361
+ reject(error)
1362
+ req.destroy()
1363
+ return
1364
+ }
1365
+ chunks.push(chunk)
1366
+ })
1367
+ req.on('end', () => {
1368
+ if (size === 0) {
1369
+ resolve({})
1370
+ return
1371
+ }
1372
+ try {
1373
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
1374
+ } catch {
1375
+ const error = new Error('请求体不是有效 JSON')
1376
+ error.statusCode = 400
1377
+ reject(error)
1378
+ }
1379
+ })
1380
+ req.on('error', reject)
1381
+ })
1382
+ }
1383
+
1384
+ function isTrustedBrowserRequest(req) {
1385
+ const origin = req.headers.origin
1386
+ if (typeof origin === 'string' && origin) {
1387
+ try {
1388
+ return new URL(origin).host === req.headers.host
1389
+ } catch {
1390
+ return false
1391
+ }
1392
+ }
1393
+ const site = req.headers['sec-fetch-site']
1394
+ if (typeof site === 'string' && site && site !== 'same-origin' && site !== 'none') return false
1395
+ return true
1396
+ }
1397
+
1398
+ function panelUrlForExternalBrowser(value) {
1399
+ let parsed
1400
+ try {
1401
+ parsed = new URL(String(value || '').trim())
1402
+ } catch {
1403
+ const error = new Error('页面地址无效')
1404
+ error.statusCode = 400
1405
+ throw error
1406
+ }
1407
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
1408
+ const error = new Error('只允许打开 http(s) 页面')
1409
+ error.statusCode = 400
1410
+ throw error
1411
+ }
1412
+ if (parsed.username || parsed.password || !isDirectHost(parsed.hostname)) {
1413
+ const error = new Error('只允许打开本机 DSH 页面')
1414
+ error.statusCode = 400
1415
+ throw error
1416
+ }
1417
+ if (parsed.pathname !== '/clash-proxy' && parsed.pathname !== '/clash-proxy/') {
1418
+ const error = new Error('只能打开 Clash 控制面板')
1419
+ error.statusCode = 400
1420
+ throw error
1421
+ }
1422
+ parsed.search = ''
1423
+ parsed.hash = ''
1424
+ return parsed.toString()
1425
+ }
1426
+
1427
+ function spawnDetached(command, args) {
1428
+ return new Promise((resolve, reject) => {
1429
+ let child
1430
+ try {
1431
+ child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true })
1432
+ } catch (error) {
1433
+ reject(error)
1434
+ return
1435
+ }
1436
+ child.once('error', reject)
1437
+ child.once('spawn', () => {
1438
+ child.unref()
1439
+ resolve()
1440
+ })
1441
+ })
1442
+ }
1443
+
1444
+ function spawnDefaultBrowser(url) {
1445
+ if (process.platform === 'win32') {
1446
+ const root = process.env.SystemRoot || 'C:\\Windows'
1447
+ const rundll = path.join(root, 'System32', 'rundll32.exe')
1448
+ const explorer = path.join(root, 'explorer.exe')
1449
+ const comspec = process.env.ComSpec || 'cmd.exe'
1450
+ return spawnDetached(comspec, ['/d', '/s', '/c', 'start', '', url])
1451
+ .catch(() => spawnDetached(rundll, ['url.dll,FileProtocolHandler', url]))
1452
+ .catch(() => spawnDetached(explorer, [url]))
1453
+ }
1454
+ return spawnDetached(process.platform === 'darwin' ? 'open' : 'xdg-open', [url])
1455
+ }
1456
+
1457
+ async function openInSystemBrowser(url) {
1458
+ if (process.versions && process.versions.electron) {
1459
+ try {
1460
+ const electron = createRequire(import.meta.url)('electron')
1461
+ if (electron && electron.shell && typeof electron.shell.openExternal === 'function') {
1462
+ await electron.shell.openExternal(url)
1463
+ return 'electron'
1464
+ }
1465
+ } catch {}
1466
+ }
1467
+ await spawnDefaultBrowser(url)
1468
+ return 'spawn'
1469
+ }
1470
+
1471
+ function registerWebUi(ctx, runtime) {
1472
+ if (!ctx.webServer || typeof ctx.webServer.register !== 'function') return
1473
+ let pageCache = ''
1474
+ const pageFile = new URL('./ui.html', import.meta.url)
1475
+ const loadPage = async () => {
1476
+ if (!pageCache) pageCache = await fs.readFile(pageFile, 'utf8')
1477
+ return pageCache
1478
+ }
1479
+ const api = {
1480
+ open: async (body) => {
1481
+ const url = panelUrlForExternalBrowser(body.url)
1482
+ const via = await openInSystemBrowser(url)
1483
+ return { opened: true, url, via, message: '已在系统默认浏览器中打开' }
1484
+ },
1485
+ state: async () => ({ state: await runtime.uiState() }),
1486
+ settings: async (body) => {
1487
+ const result = await runtime.setPolicy(body)
1488
+ return { state: await runtime.uiState(), message: result.message }
1489
+ },
1490
+ subscribe: async (body) => {
1491
+ const url = String(body.url || '').trim()
1492
+ if (!url) throw new Error('订阅链接不能为空')
1493
+ await runtime.subscribe(url, body.name ? String(body.name).trim() : undefined)
1494
+ return { state: await runtime.uiState() }
1495
+ },
1496
+ select: async (body) => {
1497
+ const name = String(body.name || '').trim()
1498
+ if (!name) throw new Error('节点名称不能为空')
1499
+ await runtime.select(name)
1500
+ return { state: await runtime.uiState() }
1501
+ },
1502
+ auto: async () => {
1503
+ await runtime.auto()
1504
+ return { state: await runtime.uiState() }
1505
+ },
1506
+ start: async () => {
1507
+ await runtime.start()
1508
+ return { state: await runtime.uiState() }
1509
+ },
1510
+ stop: async () => {
1511
+ await runtime.stop()
1512
+ return { state: await runtime.uiState() }
1513
+ },
1514
+ update: async () => {
1515
+ await runtime.update()
1516
+ return { state: await runtime.uiState() }
1517
+ },
1518
+ test: async (body) => {
1519
+ const result = await runtime.test(Array.isArray(body.names) ? body.names : undefined, body.limit)
1520
+ return { state: await runtime.uiState(), tested: result.tested, nodes: result.nodes }
1521
+ },
1522
+ installCore: async () => {
1523
+ await runtime.installCoreTool()
1524
+ return { state: await runtime.uiState() }
1525
+ }
1526
+ }
1527
+ ctx.effect(() => ctx.webServer.register({
1528
+ kind: 'prefix',
1529
+ path: '/clash-proxy',
1530
+ handler: async (req, res) => {
1531
+ try {
1532
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
1533
+ if (pathname === '/clash-proxy' || pathname === '/clash-proxy/') {
1534
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { message: 'method not allowed' } }); return }
1535
+ const page = await loadPage()
1536
+ res.writeHead(200, {
1537
+ 'content-type': 'text/html; charset=utf-8',
1538
+ 'cache-control': 'no-store',
1539
+ 'x-content-type-options': 'nosniff',
1540
+ 'x-frame-options': 'SAMEORIGIN',
1541
+ 'content-security-policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'self'"
1542
+ })
1543
+ res.end(page)
1544
+ return
1545
+ }
1546
+ const prefix = '/clash-proxy/api/'
1547
+ if (!pathname.startsWith(prefix)) { writeJson(res, 404, { ok: false, error: { message: 'not found' } }); return }
1548
+ if (!isTrustedBrowserRequest(req)) { writeJson(res, 403, { ok: false, error: { message: 'forbidden' } }); return }
1549
+ const method = pathname.slice(prefix.length)
1550
+ if (!method || method.includes('/') || !Object.hasOwn(api, method) || typeof api[method] !== 'function') {
1551
+ writeJson(res, 404, { ok: false, error: { message: 'unknown api method' } }); return
1552
+ }
1553
+ if (req.method !== 'GET' && req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { message: 'method not allowed' } }); return }
1554
+ if (method === 'state' && req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { message: 'method not allowed' } }); return }
1555
+ if (method !== 'state' && req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { message: 'method not allowed' } }); return }
1556
+ const body = req.method === 'POST' ? await readJsonBody(req) : {}
1557
+ const result = await api[method](body)
1558
+ writeJson(res, 200, { ok: true, ...result })
1559
+ } catch (error) {
1560
+ writeJson(res, Number(error && error.statusCode) || 500, { ok: false, error: { message: messageOf(error) } })
1561
+ }
1562
+ }
1563
+ }), 'dsh-llm-proxy: web UI')
1564
+ }
1565
+
1566
+ function registerTools(ctx, runtime) {
1567
+ registerTool(ctx, {
1568
+ name: 'clash_subscribe',
1569
+ description: '导入 Clash / Mihomo 订阅链接并启动 DSH 专用代理。订阅只影响 DSH 进程,不修改 Windows 系统代理。支持 http(s) 链接或本地 YAML 文件路径。',
1570
+ parameters: {
1571
+ url: { type: 'string', required: true, description: 'Clash 订阅链接,或本地 Clash YAML 文件路径' },
1572
+ name: { type: 'string', description: '可选的订阅显示名' }
1573
+ },
1574
+ output: {
1575
+ schema: objectSchema({
1576
+ imported: booleanField('是否完成导入'),
1577
+ ...summaryProperties
1578
+ }),
1579
+ render: summaryText
1580
+ },
1581
+ timeoutMs: 180000,
1582
+ isConcurrencySafe: false,
1583
+ execute: async (args, exec) => await runtime.subscribe(args.url, args.name, exec.signal)
1584
+ })
1585
+
1586
+ registerTool(ctx, {
1587
+ name: 'clash_status',
1588
+ description: '查看 DSH LLM Proxy 状态:是否运行、当前节点、自动/手动模式、端口、订阅和核心版本。',
1589
+ parameters: {},
1590
+ output: {
1591
+ schema: objectSchema({
1592
+ configured: booleanField('是否已导入订阅'),
1593
+ running: booleanField('核心是否正在运行'),
1594
+ mode: stringField('auto / manual / off'),
1595
+ selected: stringField('当前节点或 DSH-AUTO'),
1596
+ subscription: stringField('订阅显示名'),
1597
+ subscriptionHost: stringField('订阅主机或文件名'),
1598
+ updatedAt: stringField('订阅更新时间'),
1599
+ nodeCount: numberField('节点数量'),
1600
+ mixedPort: numberField('混合代理端口'),
1601
+ controllerPort: numberField('控制端口'),
1602
+ coreVersion: stringField('Mihomo 版本'),
1603
+ pid: numberField('核心进程 ID,0 表示未运行'),
1604
+ autoGroup: stringField('自动选择组名'),
1605
+ testUrl: stringField('自动测速 URL'),
1606
+ lastError: stringField('最近一次错误,空表示无')
1607
+ }),
1608
+ render: (args, value) => textBlock(
1609
+ 'DSH LLM Proxy:' + (value.running ? '运行中' : '未运行') + '\n' +
1610
+ '订阅:' + (value.subscription || '未导入') + (value.subscriptionHost ? '(' + value.subscriptionHost + ')' : '') + '\n' +
1611
+ '节点:' + value.nodeCount + ',模式:' + value.mode + ',当前:' + (value.selected || '无') + '\n' +
1612
+ '端口:混合 ' + value.mixedPort + ' / 控制 ' + value.controllerPort + ',核心:' + (value.coreVersion || '未知') + '\n' +
1613
+ (value.lastError ? '错误:' + value.lastError : '系统代理未改动。')
1614
+ )
1615
+ },
1616
+ timeoutMs: 20000,
1617
+ isConcurrencySafe: true,
1618
+ execute: async () => await runtime.status()
1619
+ })
1620
+
1621
+ registerTool(ctx, {
1622
+ name: 'clash_nodes',
1623
+ description: '列出订阅中的节点、当前选择和已知延迟;test=true 时会对前 limit 个节点发起延迟测试。',
1624
+ parameters: {
1625
+ test: { type: 'boolean', description: '是否立即测速' },
1626
+ limit: { type: 'number', description: '最多测试多少个节点,默认 12,最大 50' }
1627
+ },
1628
+ output: {
1629
+ schema: objectSchema({
1630
+ count: numberField('节点总数'),
1631
+ selected: stringField('当前节点或 DSH-AUTO'),
1632
+ auto: booleanField('是否处于自动选择'),
1633
+ nodes: { type: 'array', required: true, description: '节点列表', items: nodeSchema }
1634
+ }),
1635
+ render: (args, value) => {
1636
+ const rows = value.nodes.slice(0, 30).map((node) => (node.selected ? '* ' : ' ') + node.name + ' [' + node.type + '] ' + (node.delay ? node.delay + 'ms' : '未测速'))
1637
+ return textBlock('节点 ' + value.count + ' 个,当前:' + (value.selected || '无') + '\n' + rows.join('\n'))
1638
+ }
1639
+ },
1640
+ timeoutMs: 60000,
1641
+ isConcurrencySafe: true,
1642
+ execute: async (args, exec) => await runtime.nodes({ test: args.test === true, limit: args.limit, signal: exec.signal })
1643
+ })
1644
+
1645
+ registerTool(ctx, {
1646
+ name: 'clash_select',
1647
+ description: '手动指定 DSH 使用的节点。传节点名称,或传 auto 恢复自动选择。不会影响其他程序。',
1648
+ parameters: {
1649
+ name: { type: 'string', required: true, description: '节点名称,或 auto' }
1650
+ },
1651
+ output: {
1652
+ schema: objectSchema({
1653
+ selected: stringField('当前节点或 DSH-AUTO'),
1654
+ mode: stringField('auto 或 manual'),
1655
+ message: stringField('结果摘要')
1656
+ }),
1657
+ render: summaryText
1658
+ },
1659
+ timeoutMs: 30000,
1660
+ isConcurrencySafe: false,
1661
+ execute: async (args, exec) => await runtime.select(args.name, exec.signal)
1662
+ })
1663
+
1664
+ registerTool(ctx, {
1665
+ name: 'clash_auto',
1666
+ description: '恢复自动节点调度:Mihomo 的 DSH-AUTO url-test 组会按延迟自动选择并定期切换节点。',
1667
+ parameters: {},
1668
+ output: {
1669
+ schema: objectSchema({
1670
+ selected: stringField('当前节点或 DSH-AUTO'),
1671
+ mode: stringField('auto 或 manual'),
1672
+ message: stringField('结果摘要')
1673
+ }),
1674
+ render: summaryText
1675
+ },
1676
+ timeoutMs: 30000,
1677
+ isConcurrencySafe: false,
1678
+ execute: async (args, exec) => await runtime.auto(exec.signal)
1679
+ })
1680
+
1681
+ registerTool(ctx, {
1682
+ name: 'clash_test',
1683
+ description: '测试节点延迟。可传 names 指定节点;不传则测试前 limit 个节点(默认 12)。',
1684
+ parameters: {
1685
+ names: { type: 'array', items: { type: 'string' }, description: '要测试的节点名称列表' },
1686
+ limit: { type: 'number', description: '未指定 names 时测试前多少个节点,默认 12,最大 50' }
1687
+ },
1688
+ output: {
1689
+ schema: objectSchema({
1690
+ tested: numberField('实际测试的节点数'),
1691
+ nodes: { type: 'array', required: true, description: '节点列表', items: nodeSchema },
1692
+ message: stringField('结果摘要')
1693
+ }),
1694
+ render: summaryText
1695
+ },
1696
+ timeoutMs: 90000,
1697
+ isConcurrencySafe: true,
1698
+ execute: async (args, exec) => await runtime.test(args.names, args.limit, exec.signal)
1699
+ })
1700
+
1701
+ registerTool(ctx, {
1702
+ name: 'clash_update',
1703
+ description: '重新下载当前订阅并热重启代理,尽量保留当前手动选择的节点。',
1704
+ parameters: {},
1705
+ output: {
1706
+ schema: objectSchema({
1707
+ updated: booleanField('是否完成更新'),
1708
+ ...summaryProperties
1709
+ }),
1710
+ render: summaryText
1711
+ },
1712
+ timeoutMs: 180000,
1713
+ isConcurrencySafe: false,
1714
+ execute: async (args, exec) => await runtime.update(exec.signal)
1715
+ })
1716
+
1717
+ registerTool(ctx, {
1718
+ name: 'clash_start',
1719
+ description: '用已保存的订阅启动 DSH 专用代理。',
1720
+ parameters: {},
1721
+ output: {
1722
+ schema: objectSchema(summaryProperties),
1723
+ render: summaryText
1724
+ },
1725
+ timeoutMs: 60000,
1726
+ isConcurrencySafe: false,
1727
+ execute: async (args, exec) => await runtime.start(exec.signal)
1728
+ })
1729
+
1730
+ registerTool(ctx, {
1731
+ name: 'clash_stop',
1732
+ description: '停止 DSH 专用代理并恢复直连;不会改动系统代理。',
1733
+ parameters: {},
1734
+ output: {
1735
+ schema: objectSchema({
1736
+ running: booleanField('核心是否仍在运行'),
1737
+ message: stringField('结果摘要')
1738
+ }),
1739
+ render: summaryText
1740
+ },
1741
+ timeoutMs: 30000,
1742
+ isConcurrencySafe: false,
1743
+ execute: async () => await runtime.stop()
1744
+ })
1745
+
1746
+ registerTool(ctx, {
1747
+ name: 'clash_install_core',
1748
+ description: '下载并安装匹配当前平台的 Mihomo 核心到 DSH 数据目录,用于首次使用或修复核心缺失。',
1749
+ parameters: {},
1750
+ output: {
1751
+ schema: objectSchema({
1752
+ installed: booleanField('是否安装成功'),
1753
+ path: stringField('核心可执行文件路径'),
1754
+ version: stringField('核心版本'),
1755
+ message: stringField('结果摘要')
1756
+ }),
1757
+ render: summaryText
1758
+ },
1759
+ timeoutMs: 300000,
1760
+ isConcurrencySafe: false,
1761
+ execute: async (args, exec) => await runtime.installCoreTool(exec.signal)
1762
+ })
1763
+ }
1764
+
1765
+ export function apply(ctx, rawConfig) {
1766
+ const config = normalizeConfig(rawConfig)
1767
+ const runtime = new ClashProxyRuntime(ctx, config)
1768
+ runtime.installFetchWrapper()
1769
+ registerTools(ctx, runtime)
1770
+ registerWebUi(ctx, runtime)
1771
+ if (typeof ctx.effect === 'function') {
1772
+ ctx.effect(() => () => {
1773
+ runtime.dispose().catch(() => {})
1774
+ }, 'dsh-llm-proxy: lifecycle')
1775
+ }
1776
+ runtime.ready = runtime.boot()
1777
+ }