mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
fix(electron): resolve model download proxy from session
Make desktop proxy saves await Electron session proxy application before model download proxy resolution. Resolve HTTP, HTTPS, and SOCKS system proxy rules for semantic index model downloads while preserving DIRECT precedence.
This commit is contained in:
1 parent
f304146c8f
commit
cbc8728f41
9 files changed
+276
-88
No files matched your search
@@ -46,6 +46,7 @@ import { getManager as getSkillManager } from './ai/skills/manager'
|
||||
import { createElectronRunAgentStream } from './ai/agent-stream-runner'
|
||||
import { createExecuteElectronAiTool } from './ai/tools/debug-executor'
|
||||
import { assertDesktopDataDirCompatible, getDesktopAppVersion } from './runtime-compat'
|
||||
import { resolveModelDownloadProxyUrl } from './network/proxy'
|
||||
|
||||
export interface InternalEndpoint {
|
||||
baseUrl: string
|
||||
@@ -120,6 +121,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise<I
|
||||
pathProvider,
|
||||
runtime,
|
||||
sqliteVecLoadablePath: getSqliteVecLoadablePath().replace('app.asar', 'app.asar.unpacked'),
|
||||
getModelDownloadProxyUrl: resolveModelDownloadProxyUrl,
|
||||
workerEntryUrl: import.meta.url.endsWith('.ts')
|
||||
? undefined
|
||||
: new URL('./semantic-index-worker.js', import.meta.url),
|
||||
|
||||
@@ -29,23 +29,26 @@ export function registerNetworkHandlers(_context: IpcContext): void {
|
||||
/**
|
||||
* 保存代理配置
|
||||
*/
|
||||
ipcMain.handle('network:saveProxyConfig', (_event, config: ProxyConfig): { success: boolean; error?: string } => {
|
||||
try {
|
||||
// 如果是手动模式且填写了 URL,验证 URL 格式
|
||||
if (config.mode === 'manual' && config.url) {
|
||||
const validation = validateProxyUrl(config.url)
|
||||
if (!validation.valid) {
|
||||
return { success: false, error: validation.error }
|
||||
ipcMain.handle(
|
||||
'network:saveProxyConfig',
|
||||
async (_event, config: ProxyConfig): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// 如果是手动模式且填写了 URL,验证 URL 格式
|
||||
if (config.mode === 'manual' && config.url) {
|
||||
const validation = validateProxyUrl(config.url)
|
||||
if (!validation.valid) {
|
||||
return { success: false, error: validation.error }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveProxyConfig(config)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: `保存配置失败: ${errorMessage}` }
|
||||
await saveProxyConfig(config)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: `保存配置失败: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* 测试代理连接
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { ProxyApplyQueue } from './proxy-apply-queue'
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function nextTick(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
test('ProxyApplyQueue waits for the pending apply before resolving waiters', async () => {
|
||||
const queue = new ProxyApplyQueue()
|
||||
const first = deferred()
|
||||
const events: string[] = []
|
||||
|
||||
const applyPromise = queue.apply(async () => {
|
||||
events.push('apply:start')
|
||||
await first.promise
|
||||
events.push('apply:end')
|
||||
})
|
||||
|
||||
const waitPromise = queue.waitForPending().then(() => {
|
||||
events.push('wait:end')
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
assert.deepEqual(events, ['apply:start'])
|
||||
|
||||
first.resolve()
|
||||
await Promise.all([applyPromise, waitPromise])
|
||||
|
||||
assert.deepEqual(events, ['apply:start', 'apply:end', 'wait:end'])
|
||||
})
|
||||
|
||||
test('ProxyApplyQueue serializes overlapping apply calls', async () => {
|
||||
const queue = new ProxyApplyQueue()
|
||||
const first = deferred()
|
||||
const events: string[] = []
|
||||
|
||||
const firstApply = queue.apply(async () => {
|
||||
events.push('first:start')
|
||||
await first.promise
|
||||
events.push('first:end')
|
||||
})
|
||||
const secondApply = queue.apply(async () => {
|
||||
events.push('second:start')
|
||||
events.push('second:end')
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
assert.deepEqual(events, ['first:start'])
|
||||
|
||||
first.resolve()
|
||||
await Promise.all([firstApply, secondApply])
|
||||
|
||||
assert.deepEqual(events, ['first:start', 'first:end', 'second:start', 'second:end'])
|
||||
})
|
||||
|
||||
test('ProxyApplyQueue continues with later apply calls after an earlier failure', async () => {
|
||||
const queue = new ProxyApplyQueue()
|
||||
const events: string[] = []
|
||||
|
||||
const firstApply = queue.apply(async () => {
|
||||
events.push('first:start')
|
||||
throw new Error('apply failed')
|
||||
})
|
||||
const secondApply = queue.apply(async () => {
|
||||
events.push('second:start')
|
||||
events.push('second:end')
|
||||
})
|
||||
|
||||
await assert.rejects(firstApply, /apply failed/)
|
||||
await secondApply
|
||||
|
||||
assert.deepEqual(events, ['first:start', 'second:start', 'second:end'])
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
export class ProxyApplyQueue {
|
||||
private pendingApply: Promise<unknown> | null = null
|
||||
|
||||
apply<T>(task: () => Promise<T>): Promise<T> {
|
||||
const previous = this.pendingApply ?? Promise.resolve()
|
||||
const current = previous.catch(() => undefined).then(task)
|
||||
this.pendingApply = current
|
||||
|
||||
const clear = () => {
|
||||
if (this.pendingApply === current) this.pendingApply = null
|
||||
}
|
||||
current.then(clear, clear)
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
async waitForPending(): Promise<void> {
|
||||
const pending = this.pendingApply
|
||||
if (pending) await pending
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { proxyUrlFromElectronResolvedProxy } from './proxy-resolver'
|
||||
|
||||
test('proxyUrlFromElectronResolvedProxy returns the first usable model download proxy', () => {
|
||||
assert.equal(proxyUrlFromElectronResolvedProxy('DIRECT'), undefined)
|
||||
assert.equal(proxyUrlFromElectronResolvedProxy('DIRECT; PROXY 127.0.0.1:7890'), undefined)
|
||||
assert.equal(proxyUrlFromElectronResolvedProxy('PROXY 127.0.0.1:7890; DIRECT'), 'http://127.0.0.1:7890')
|
||||
assert.equal(
|
||||
proxyUrlFromElectronResolvedProxy('HTTPS proxy.example.com:443; DIRECT'),
|
||||
'https://proxy.example.com:443'
|
||||
)
|
||||
assert.equal(
|
||||
proxyUrlFromElectronResolvedProxy('SOCKS5 127.0.0.1:1080; PROXY 127.0.0.1:7890'),
|
||||
'http://127.0.0.1:7890'
|
||||
)
|
||||
assert.equal(proxyUrlFromElectronResolvedProxy('SOCKS5 127.0.0.1:1080'), 'socks5://127.0.0.1:1080')
|
||||
assert.equal(proxyUrlFromElectronResolvedProxy('SOCKS 127.0.0.1:1080'), 'socks://127.0.0.1:1080')
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Convert Electron's `session.resolveProxy()` result to a model download proxy URL.
|
||||
* Electron returns entries like `DIRECT`, `PROXY host:port`, `HTTPS host:port`;
|
||||
* HTTP(S) proxies can be used directly by the model downloader. SOCKS entries
|
||||
* are still returned so the downloader can fail explicitly instead of going direct.
|
||||
*/
|
||||
export function proxyUrlFromElectronResolvedProxy(resolvedProxy: string): string | undefined {
|
||||
let firstSocksProxyUrl: string | undefined
|
||||
|
||||
for (const rawRule of resolvedProxy.split(';')) {
|
||||
const rule = rawRule.trim()
|
||||
if (!rule) continue
|
||||
if (rule.toUpperCase() === 'DIRECT') return undefined
|
||||
|
||||
const [schemeRaw, targetRaw] = rule.split(/\s+/, 2)
|
||||
const scheme = schemeRaw?.toUpperCase()
|
||||
const target = targetRaw?.trim()
|
||||
if (!scheme || !target) continue
|
||||
|
||||
if (scheme === 'PROXY' || scheme === 'HTTP') return target.includes('://') ? target : `http://${target}`
|
||||
if (scheme === 'HTTPS') return target.includes('://') ? target : `https://${target}`
|
||||
if (scheme === 'SOCKS' && !firstSocksProxyUrl) {
|
||||
firstSocksProxyUrl = target.includes('://') ? target : `socks://${target}`
|
||||
}
|
||||
if (scheme === 'SOCKS4' && !firstSocksProxyUrl) {
|
||||
firstSocksProxyUrl = target.includes('://') ? target : `socks4://${target}`
|
||||
}
|
||||
if (scheme === 'SOCKS5' && !firstSocksProxyUrl) {
|
||||
firstSocksProxyUrl = target.includes('://') ? target : `socks5://${target}`
|
||||
}
|
||||
}
|
||||
return firstSocksProxyUrl
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { app, session } from 'electron'
|
||||
import { getSettingsDir } from '../paths'
|
||||
import { ProxyApplyQueue } from './proxy-apply-queue'
|
||||
import { proxyUrlFromElectronResolvedProxy } from './proxy-resolver'
|
||||
|
||||
// 代理模式
|
||||
export type ProxyMode = 'off' | 'system' | 'manual'
|
||||
@@ -25,6 +27,7 @@ const DEFAULT_CONFIG: ProxyConfig = {
|
||||
|
||||
// 配置文件路径
|
||||
let CONFIG_PATH: string | null = null
|
||||
const proxyApplyQueue = new ProxyApplyQueue()
|
||||
|
||||
function getConfigPath(): string {
|
||||
if (CONFIG_PATH) return CONFIG_PATH
|
||||
@@ -80,7 +83,7 @@ export function loadProxyConfig(): ProxyConfig {
|
||||
/**
|
||||
* 保存代理配置
|
||||
*/
|
||||
export function saveProxyConfig(config: ProxyConfig): void {
|
||||
export async function saveProxyConfig(config: ProxyConfig): Promise<void> {
|
||||
const configPath = getConfigPath()
|
||||
const dir = path.dirname(configPath)
|
||||
|
||||
@@ -91,7 +94,7 @@ export function saveProxyConfig(config: ProxyConfig): void {
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8')
|
||||
|
||||
// 保存后立即应用代理设置到 Electron session
|
||||
applyProxyToSession()
|
||||
await applyProxyToSession()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,9 +123,7 @@ export function validateProxyUrl(url: string): { valid: boolean; error?: string
|
||||
* 将代理设置应用到 Electron session
|
||||
* 这会影响所有通过 Electron 发起的网络请求(包括主进程的 fetch)
|
||||
*/
|
||||
export async function applyProxyToSession(): Promise<void> {
|
||||
const config = loadProxyConfig()
|
||||
|
||||
async function applyProxyConfigToSession(config: ProxyConfig): Promise<void> {
|
||||
try {
|
||||
switch (config.mode) {
|
||||
case 'off':
|
||||
@@ -178,6 +179,14 @@ export async function applyProxyToSession(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export function applyProxyToSession(): Promise<void> {
|
||||
return proxyApplyQueue.apply(() => applyProxyConfigToSession(loadProxyConfig()))
|
||||
}
|
||||
|
||||
function waitForPendingProxyApply(): Promise<void> {
|
||||
return proxyApplyQueue.waitForPending()
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试代理连接
|
||||
* 通过代理请求一个可靠的 HTTPS 地址来验证代理是否可用
|
||||
@@ -189,85 +198,80 @@ export async function testProxyConnection(proxyUrl: string): Promise<{ success:
|
||||
return { success: false, error: validation.error }
|
||||
}
|
||||
|
||||
// 测试 URL 列表(按优先级)
|
||||
const testUrls = ['https://www.google.com', 'https://www.cloudflare.com', 'https://api.deepseek.com']
|
||||
return await proxyApplyQueue.apply(async () => {
|
||||
// 测试 URL 列表(按优先级)
|
||||
const testUrls = ['https://www.google.com', 'https://www.cloudflare.com', 'https://api.deepseek.com']
|
||||
|
||||
try {
|
||||
// 临时设置代理
|
||||
await session.defaultSession.setProxy({
|
||||
proxyRules: proxyUrl,
|
||||
})
|
||||
try {
|
||||
// 临时设置代理
|
||||
await session.defaultSession.setProxy({
|
||||
proxyRules: proxyUrl,
|
||||
})
|
||||
|
||||
// 使用 Electron 的 net 模块测试连接
|
||||
const { net } = await import('electron')
|
||||
// 使用 Electron 的 net 模块测试连接
|
||||
const { net } = await import('electron')
|
||||
|
||||
let lastError: string = ''
|
||||
let lastError: string = ''
|
||||
|
||||
for (const testUrl of testUrls) {
|
||||
try {
|
||||
const result = await new Promise<{ success: boolean; error?: string }>((resolve) => {
|
||||
const request = net.request({
|
||||
method: 'HEAD',
|
||||
url: testUrl,
|
||||
for (const testUrl of testUrls) {
|
||||
try {
|
||||
const result = await new Promise<{ success: boolean; error?: string }>((resolve) => {
|
||||
const request = net.request({
|
||||
method: 'HEAD',
|
||||
url: testUrl,
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
request.abort()
|
||||
resolve({ success: false, error: '连接超时' })
|
||||
}, 10000)
|
||||
|
||||
request.on('response', (response) => {
|
||||
clearTimeout(timeout)
|
||||
// 任何响应都说明代理可用
|
||||
if (response.statusCode < 500) {
|
||||
resolve({ success: true })
|
||||
} else {
|
||||
resolve({ success: false, error: `HTTP ${response.statusCode}` })
|
||||
}
|
||||
})
|
||||
|
||||
request.on('error', (error) => {
|
||||
clearTimeout(timeout)
|
||||
resolve({ success: false, error: error.message })
|
||||
})
|
||||
|
||||
request.end()
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
request.abort()
|
||||
resolve({ success: false, error: '连接超时' })
|
||||
}, 10000)
|
||||
if (result.success) return { success: true }
|
||||
|
||||
request.on('response', (response) => {
|
||||
clearTimeout(timeout)
|
||||
// 任何响应都说明代理可用
|
||||
if (response.statusCode < 500) {
|
||||
resolve({ success: true })
|
||||
} else {
|
||||
resolve({ success: false, error: `HTTP ${response.statusCode}` })
|
||||
}
|
||||
})
|
||||
|
||||
request.on('error', (error) => {
|
||||
clearTimeout(timeout)
|
||||
resolve({ success: false, error: error.message })
|
||||
})
|
||||
|
||||
request.end()
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// 恢复之前的代理设置
|
||||
await applyProxyToSession()
|
||||
return { success: true }
|
||||
lastError = result.error || ''
|
||||
} catch (e) {
|
||||
lastError = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
|
||||
lastError = result.error || ''
|
||||
} catch (e) {
|
||||
lastError = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复之前的代理设置
|
||||
await applyProxyToSession()
|
||||
return { success: false, error: lastError || '无法通过代理连接到测试服务器' }
|
||||
} catch (error) {
|
||||
// 恢复之前的代理设置
|
||||
await applyProxyToSession()
|
||||
return { success: false, error: lastError || '无法通过代理连接到测试服务器' }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
// 友好的错误提示
|
||||
if (errorMessage.includes('ECONNREFUSED')) {
|
||||
return { success: false, error: '连接被拒绝,请检查代理服务器是否运行中' }
|
||||
}
|
||||
if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('timeout')) {
|
||||
return { success: false, error: '连接超时,请检查代理地址和端口' }
|
||||
}
|
||||
if (errorMessage.includes('ENOTFOUND')) {
|
||||
return { success: false, error: '无法解析代理服务器地址' }
|
||||
}
|
||||
|
||||
// 友好的错误提示
|
||||
if (errorMessage.includes('ECONNREFUSED')) {
|
||||
return { success: false, error: '连接被拒绝,请检查代理服务器是否运行中' }
|
||||
return { success: false, error: `代理连接失败: ${errorMessage}` }
|
||||
} finally {
|
||||
await applyProxyConfigToSession(loadProxyConfig())
|
||||
}
|
||||
if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('timeout')) {
|
||||
return { success: false, error: '连接超时,请检查代理地址和端口' }
|
||||
}
|
||||
if (errorMessage.includes('ENOTFOUND')) {
|
||||
return { success: false, error: '无法解析代理服务器地址' }
|
||||
}
|
||||
|
||||
return { success: false, error: `代理连接失败: ${errorMessage}` }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,6 +289,26 @@ export function getActiveProxyUrl(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function resolveModelDownloadProxyUrl(): Promise<string | undefined> {
|
||||
const config = loadProxyConfig()
|
||||
|
||||
if (config.mode === 'manual' && config.url) {
|
||||
const validation = validateProxyUrl(config.url)
|
||||
if (validation.valid) return config.url
|
||||
}
|
||||
|
||||
if (config.mode !== 'system') return undefined
|
||||
|
||||
try {
|
||||
await waitForPendingProxyApply()
|
||||
const resolvedProxy = await session.defaultSession.resolveProxy('https://huggingface.co/')
|
||||
return proxyUrlFromElectronResolvedProxy(resolvedProxy)
|
||||
} catch (error) {
|
||||
console.warn('[Proxy] Failed to resolve system proxy for model downloads:', error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化代理模块
|
||||
* 应用启动时调用,加载并应用代理配置
|
||||
@@ -292,10 +316,10 @@ export function getActiveProxyUrl(): string | undefined {
|
||||
export function initProxy(): void {
|
||||
// 延迟执行,确保 app ready
|
||||
if (app.isReady()) {
|
||||
applyProxyToSession()
|
||||
void applyProxyToSession()
|
||||
} else {
|
||||
app.whenReady().then(() => {
|
||||
applyProxyToSession()
|
||||
void applyProxyToSession()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@
|
||||
"onnxruntime-common": "1.24.3",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"sharp": "0.34.5",
|
||||
"sqlite-vec": "^0.1.9"
|
||||
"sqlite-vec": "^0.1.9",
|
||||
"undici": "^6.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
|
||||
Reference in new issue
Block a user