feat: v0.9.1 — 面板设置页、网络代理、后台安装、模型服务商扩展、多项修复

新功能:
- 新增独立面板设置页面(网络代理 + 代理测试 + 模型代理开关 + npm源)
- 网络代理支持:下载类操作走代理,自动绕过内网地址
- 安装/升级/卸载改为后台执行,不再阻塞界面
- 全局任务状态栏:关闭弹窗后顶部显示进度,可重新查看日志
- 安装/卸载完成后自动刷新界面状态
- 新增多个模型服务商快捷配置(硅基流动、火山引擎、阿里云百炼、智谱AI、MiniMax、NVIDIA NIM、胜算云)
- AI助手浮动按钮恢复,首次提示可拖动,实时聊天页隐藏

修复:
- 修复版本更新误判(本地版本高于远端不再误弹更新)
- 修复Windows下nvm/自定义Node路径CLI检测
- 修复npm EEXIST文件冲突(--force + 安装前自动清理)
- 修复汉化版-zh.x后缀版本比较错误
- 修复模型URL自动拼接/v1问题
- 修复切换版本后Gateway重装失败(PATH缓存刷新)
- 修复切换助手服务商时旧模型名残留

优化:
- macOS图标改用docs/logo.png统一生成
- 内置推荐版本号更新到OpenClaw 2026.3.13
- 错误诊断增强(EEXIST识别)
- 弹窗标题根据操作类型显示
- 新增版本维护文档
This commit is contained in:
晴天
2026-03-14 19:57:22 +08:00
parent c8ccb5dd4b
commit 394813a96c
88 changed files with 1807 additions and 513 deletions

View File

@@ -33,6 +33,22 @@ const DOCKER_NODES_PATH = path.join(OPENCLAW_DIR, 'docker-nodes.json')
const INSTANCES_PATH = path.join(OPENCLAW_DIR, 'instances.json')
const DOCKER_SOCKET = process.platform === 'win32' ? '//./pipe/docker_engine' : '/var/run/docker.sock'
const OPENCLAW_IMAGE = 'ghcr.io/qingchencloud/openclaw'
const PANEL_VERSION = (() => {
try {
return JSON.parse(fs.readFileSync(path.join(__dev_dirname, '..', 'package.json'), 'utf8')).version || '0.0.0'
} catch {
return '0.0.0'
}
})()
const VERSION_POLICY_PATH = path.join(__dev_dirname, '..', 'openclaw-version-policy.json')
const GIT_HTTPS_REWRITES = [
'ssh://git@github.com/',
'ssh://git@github.com',
'ssh://git@://github.com/',
'git@github.com:',
'git://github.com/',
'git+ssh://git@github.com/'
]
// === 异步任务存储 ===
const _taskStore = new Map() // taskId → task object
@@ -64,21 +80,175 @@ function createTask(containerId, containerName, nodeId, message) {
}
// 语义化版本比较
function versionGe(a, b) {
const pa = a.split('.').map(Number), pb = b.split('.').map(Number)
function parseVersion(value) {
return String(value || '').split(/[^0-9]/).filter(Boolean).map(Number)
}
function versionCompare(a, b) {
const pa = parseVersion(a), pb = parseVersion(b)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return true
if ((pa[i] || 0) < (pb[i] || 0)) return false
if ((pa[i] || 0) > (pb[i] || 0)) return 1
if ((pa[i] || 0) < (pb[i] || 0)) return -1
}
return true
return 0
}
function versionGe(a, b) {
return versionCompare(a, b) >= 0
}
function versionGt(a, b) {
const pa = a.split('.').map(Number), pb = b.split('.').map(Number)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return true
if ((pa[i] || 0) < (pb[i] || 0)) return false
return versionCompare(a, b) > 0
}
// 提取基础版本号(去掉 -zh.x / -nightly.xxx 等后缀)
function baseVersion(v) {
return String(v || '').split('-')[0]
}
// 判断 CLI 版本是否与推荐版匹配(考虑汉化版 -zh.x 后缀差异)
function versionsMatch(cliVer, recommended) {
if (cliVer === recommended) return true
return baseVersion(cliVer) === baseVersion(recommended)
}
// 判断推荐版是否真的比当前版本更新(忽略 -zh.x 后缀)
function recommendedIsNewer(recommended, current) {
return versionGt(baseVersion(recommended), baseVersion(current))
}
function loadVersionPolicy() {
try {
return JSON.parse(fs.readFileSync(VERSION_POLICY_PATH, 'utf8'))
} catch {
return {}
}
return false
}
function recommendedVersionFor(source = 'chinese') {
const policy = loadVersionPolicy()
return policy?.panels?.[PANEL_VERSION]?.[source]?.recommended
|| policy?.default?.[source]?.recommended
|| null
}
function npmPackageName(source = 'chinese') {
return source === 'official' ? 'openclaw' : '@qingchencloud/openclaw-zh'
}
function getConfiguredNpmRegistry() {
const regFile = path.join(OPENCLAW_DIR, 'npm-registry.txt')
try {
if (fs.existsSync(regFile)) {
const value = fs.readFileSync(regFile, 'utf8').trim()
if (value) return value
}
} catch {}
return 'https://registry.npmmirror.com'
}
function pickRegistryForPackage(pkg) {
const configured = getConfiguredNpmRegistry()
if (pkg.includes('openclaw-zh')) {
if (configured.includes('npmmirror.com') || configured.includes('npmjs.org')) return configured
return 'https://registry.npmjs.org'
}
return configured
}
function configureGitHttpsRules() {
try { execSync('git config --global --unset-all url.https://github.com/.insteadOf 2>&1', { timeout: 5000, windowsHide: true }) } catch {}
let success = 0
for (const from of GIT_HTTPS_REWRITES) {
try {
execSync(`git config --global --add url.https://github.com/.insteadOf "${from}"`, { timeout: 5000, windowsHide: true })
success++
} catch {}
}
return success
}
function buildGitInstallEnv() {
const env = {
...process.env,
GIT_TERMINAL_PROMPT: '0',
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o IdentitiesOnly=yes',
GIT_ALLOW_PROTOCOL: 'https:http:file',
GIT_CONFIG_COUNT: String(GIT_HTTPS_REWRITES.length),
}
GIT_HTTPS_REWRITES.forEach((from, idx) => {
env[`GIT_CONFIG_KEY_${idx}`] = 'url.https://github.com/.insteadOf'
env[`GIT_CONFIG_VALUE_${idx}`] = from
})
return env
}
function detectInstalledSource() {
if (isMac) {
try {
const target = fs.readlinkSync('/opt/homebrew/bin/openclaw')
if (String(target).includes('openclaw-zh')) return 'chinese'
return 'official'
} catch {}
}
if (isWindows) {
try {
const appdata = process.env.APPDATA
if (appdata) {
const zhDir = path.join(appdata, 'npm', 'node_modules', '@qingchencloud', 'openclaw-zh')
if (fs.existsSync(zhDir)) return 'chinese'
}
} catch {}
return 'official'
}
try {
const npmBin = isWindows ? 'npm.cmd' : 'npm'
const out = execSync(`${npmBin} list -g @qingchencloud/openclaw-zh --depth=0 2>&1`, { timeout: 10000, windowsHide: true }).toString()
if (out.includes('openclaw-zh@')) return 'chinese'
} catch {}
return 'official'
}
function getLocalOpenclawVersion() {
let current = null
if (isMac) {
try {
const target = fs.readlinkSync('/opt/homebrew/bin/openclaw')
const pkgPath = path.resolve('/opt/homebrew/bin', target, '..', 'package.json')
current = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version
} catch {}
}
if (!current && isWindows) {
try {
const appdata = process.env.APPDATA
if (appdata) {
for (const pkg of [path.join('@qingchencloud', 'openclaw-zh'), 'openclaw']) {
const pkgPath = path.join(appdata, 'npm', 'node_modules', pkg, 'package.json')
if (fs.existsSync(pkgPath)) {
current = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version
if (current) break
}
}
}
} catch {}
}
if (!current) {
try { current = execSync('openclaw --version 2>&1', { windowsHide: true }).toString().trim().split(/\s+/).pop() } catch {}
}
return current || null
}
async function getLatestVersionFor(source = 'chinese') {
const pkg = npmPackageName(source)
const encodedPkg = pkg.replace('/', '%2F').replace('@', '%40')
const firstRegistry = pickRegistryForPackage(pkg)
const registries = [...new Set([firstRegistry, 'https://registry.npmjs.org'])]
for (const registry of registries) {
try {
const resp = await fetch(`${registry}/${encodedPkg}/latest`, { headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000) })
if (!resp.ok) continue
const data = await resp.json()
if (data?.version) return data.version
} catch {}
}
return null
}
// === 访问密码 & Session 管理 ===
@@ -2356,13 +2526,9 @@ const handlers = {
configure_git_https() {
try {
const cmds = [
'git config --global url."https://github.com/".insteadOf "git@github.com:"',
'git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"',
'git config --global url."https://github.com/".insteadOf "git://github.com/"',
]
for (const cmd of cmds) execSync(cmd, { timeout: 5000, windowsHide: true })
return '已配置 Git HTTPS 替代 SSH'
const success = configureGitHttpsRules()
if (!success) throw new Error('Git 未安装或写入失败')
return `已配置 Git HTTPS 替代 SSH${success}/${GIT_HTTPS_REWRITES.length} 条规则)`
} catch (e) {
throw new Error('配置失败: ' + (e.message || e))
}
@@ -2408,19 +2574,22 @@ const handlers = {
},
// 版本信息
get_version_info() {
let current = null
if (isMac) {
try {
const target = fs.readlinkSync('/opt/homebrew/bin/openclaw')
const pkgPath = path.resolve('/opt/homebrew/bin', target, '..', 'package.json')
current = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version
} catch {}
async get_version_info() {
const source = detectInstalledSource()
const current = getLocalOpenclawVersion()
const latest = await getLatestVersionFor(source)
const recommended = recommendedVersionFor(source)
return {
current,
latest,
recommended,
update_available: current && recommended ? recommendedIsNewer(recommended, current) : !!recommended,
latest_update_available: current && latest ? recommendedIsNewer(latest, current) : !!latest,
is_recommended: !!current && !!recommended && versionsMatch(current, recommended),
ahead_of_recommended: !!current && !!recommended && recommendedIsNewer(current, recommended),
panel_version: PANEL_VERSION,
source
}
if (!current) {
try { current = execSync('openclaw --version 2>&1', { windowsHide: true }).toString().trim().split(/\s+/).pop() } catch {}
}
return { current, latest: null, update_available: false, source: 'chinese' }
},
// 模型测试
@@ -2429,8 +2598,8 @@ const handlers = {
: apiType === 'google-gemini' ? 'google-gemini'
: 'openai-completions'
let base = _normalizeBaseUrl(baseUrl)
// 仅 Anthropic 强制补 /v1OpenAI 兼容类不强制(火山引擎等用 /v3
if (type === 'anthropic-messages' && !/\/v1$/i.test(base)) base += '/v1'
else if (type === 'openai-completions' && !/\/v1$/i.test(base)) base += '/v1'
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30000)
try {
@@ -2499,8 +2668,8 @@ const handlers = {
: apiType === 'google-gemini' ? 'google-gemini'
: 'openai-completions'
let base = _normalizeBaseUrl(baseUrl)
// 仅 Anthropic 强制补 /v1OpenAI 兼容类不强制(火山引擎等用 /v3
if (type === 'anthropic-messages' && !/\/v1$/i.test(base)) base += '/v1'
else if (type === 'openai-completions' && !/\/v1$/i.test(base)) base += '/v1'
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 15000)
try {
@@ -2699,49 +2868,73 @@ const handlers = {
},
async list_openclaw_versions({ source = 'chinese' } = {}) {
const pkg = source === 'official' ? 'openclaw' : '@qingchencloud/openclaw-zh'
const encodedPkg = pkg.replace('/', '%2F')
const registry = 'https://registry.npmmirror.com'
try {
const resp = await fetch(`${registry}/${encodedPkg}`, { headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000) })
const data = await resp.json()
const versions = Object.keys(data.versions || {})
versions.sort((a, b) => {
const pa = a.split(/[^0-9]/).filter(Boolean).map(Number)
const pb = b.split(/[^0-9]/).filter(Boolean).map(Number)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
if ((pb[i] || 0) !== (pa[i] || 0)) return (pb[i] || 0) - (pa[i] || 0)
const pkg = npmPackageName(source)
const encodedPkg = pkg.replace('/', '%2F').replace('@', '%40')
const firstRegistry = pickRegistryForPackage(pkg)
const registries = [...new Set([firstRegistry, 'https://registry.npmjs.org'])]
let lastError = null
for (const registry of registries) {
try {
const resp = await fetch(`${registry}/${encodedPkg}`, { headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const versions = Object.keys(data.versions || {})
versions.sort((a, b) => versionCompare(b, a))
const recommended = recommendedVersionFor(source)
if (recommended) {
const pos = versions.indexOf(recommended)
if (pos >= 0) {
versions.splice(pos, 1)
versions.unshift(recommended)
} else {
versions.unshift(recommended)
}
}
return 0
})
return versions
} catch (e) {
throw new Error('查询版本失败: ' + e.message)
return versions
} catch (e) {
lastError = e
}
}
throw new Error('查询版本失败: ' + (lastError?.message || lastError || 'unknown error'))
},
upgrade_openclaw({ source = 'chinese', version } = {}) {
const OPENCLAW_DIR = path.join(homedir(), '.openclaw')
const pkg = source === 'official' ? 'openclaw' : '@qingchencloud/openclaw-zh'
const ver = version || 'latest'
const currentSource = detectInstalledSource()
const pkg = npmPackageName(source)
const recommended = recommendedVersionFor(source)
const ver = version || recommended || 'latest'
const oldPkg = npmPackageName(currentSource)
const needUninstallOld = currentSource !== source
const npmBin = isWindows ? 'npm.cmd' : 'npm'
// Configure Git HTTPS before npm install to prevent SSH auth failures
try { execSync('git config --global --unset-all url.https://github.com/.insteadOf 2>&1', { windowsHide: true }) } catch {}
for (const from of ['ssh://git@github.com/', 'git@github.com:', 'git://github.com/', 'git+ssh://git@github.com/']) {
try { execSync(`git config --global --add url.https://github.com/.insteadOf "${from}"`, { windowsHide: true }) } catch {}
}
const gitEnv = {
GIT_TERMINAL_PROMPT: '0',
GIT_CONFIG_COUNT: '4',
GIT_CONFIG_KEY_0: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_0: 'ssh://git@github.com/',
GIT_CONFIG_KEY_1: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_1: 'git@github.com:',
GIT_CONFIG_KEY_2: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_2: 'git://github.com/',
GIT_CONFIG_KEY_3: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_3: 'git+ssh://git@github.com/',
const registry = pickRegistryForPackage(pkg)
const gitConfigured = configureGitHttpsRules()
const gitEnv = buildGitInstallEnv()
const logs = []
if (!version && recommended) {
logs.push(`ClawPanel ${PANEL_VERSION} 默认绑定 OpenClaw 稳定版: ${recommended}`)
}
logs.push(`Git HTTPS 规则已就绪 (${gitConfigured}/${GIT_HTTPS_REWRITES.length})`)
const runInstall = (targetRegistry) => execSync(
`${npmBin} install -g ${pkg}@${ver} --force --registry ${targetRegistry} --verbose 2>&1`,
{ timeout: 120000, windowsHide: true, env: gitEnv }
).toString()
try {
const out = execSync(`${npmBin} install -g ${pkg}@${ver} --registry https://registry.npmmirror.com 2>&1`, { timeout: 120000, windowsHide: true, env: { ...process.env, ...gitEnv } }).toString()
const action = ver === 'latest' ? '升级' : '安装'
return `${action}完成 (${pkg}@${ver})\n${out.slice(-200)}`
let out
try {
out = runInstall(registry)
} catch (e) {
if (registry !== 'https://registry.npmjs.org') {
logs.push('镜像源安装失败,自动切换到 npm 官方源重试...')
out = runInstall('https://registry.npmjs.org')
} else {
throw e
}
}
if (needUninstallOld) {
try { execSync(`${npmBin} uninstall -g ${oldPkg} 2>&1`, { timeout: 60000, windowsHide: true }) } catch {}
}
logs.push(`安装完成 (${pkg}@${ver})`)
return `${logs.join('\n')}\n${out.slice(-400)}`
} catch (e) {
throw new Error('安装失败: ' + (e.stderr?.toString() || e.message).slice(-300))
}
@@ -2771,9 +2964,10 @@ const handlers = {
init_openclaw_config() {
if (fs.existsSync(CONFIG_PATH)) return { created: false, message: '配置文件已存在' }
if (!fs.existsSync(OPENCLAW_DIR)) fs.mkdirSync(OPENCLAW_DIR, { recursive: true })
const lastTouchedVersion = recommendedVersionFor('chinese') || '2026.1.1'
const defaultConfig = {
"$schema": "https://openclaw.ai/schema/config.json",
meta: { lastTouchedVersion: "2026.1.1" },
meta: { lastTouchedVersion },
models: { providers: {} },
gateway: {
mode: "local",
@@ -3219,6 +3413,13 @@ const handlers = {
return true
},
test_proxy({ url }) {
const cfg = readPanelConfig()
const proxyUrl = cfg?.networkProxy?.url
if (!proxyUrl) throw new Error('未配置代理地址')
return { ok: true, status: 200, elapsed_ms: 0, proxy: proxyUrl, target: url || 'N/A (Web模式不支持代理测试)' }
},
// === Agent 管理Web 模式) ===
add_agent({ name, model, workspace }) {