feat: npm 源可配置,支持淘宝/官方/华为云镜像切换

- 所有 npm 操作使用用户配置的 registry,默认淘宝镜像
- 服务管理页面新增 npm 源设置区域(预设 + 自定义)
- 版本检测 API 同步使用配置源
- 配置持久化到 ~/.openclaw/npm-registry.txt
This commit is contained in:
晴天
2026-02-28 14:10:09 +08:00
parent da8932a3e0
commit a5c7760f25
4 changed files with 96 additions and 4 deletions

View File

@@ -6,6 +6,26 @@ use std::process::Command;
use crate::models::types::VersionInfo;
/// 预设 npm 源列表
const DEFAULT_REGISTRY: &str = "https://registry.npmmirror.com";
/// 读取用户配置的 npm registryfallback 到淘宝镜像
fn get_configured_registry() -> String {
let path = super::openclaw_dir().join("npm-registry.txt");
fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_REGISTRY.to_string())
}
/// 创建使用配置源的 npm Command
fn npm_command() -> Command {
let mut cmd = Command::new("npm");
cmd.args(["--registry", &get_configured_registry()]);
cmd
}
fn backups_dir() -> PathBuf {
super::openclaw_dir().join("backups")
}
@@ -76,7 +96,8 @@ async fn get_latest_version_for(source: &str) -> Option<String> {
.build()
.ok()?;
let pkg = npm_package_name(source).replace('/', "%2F").replace('@', "%40");
let url = format!("https://registry.npmjs.org/{pkg}/latest");
let registry = get_configured_registry();
let url = format!("{registry}/{pkg}/latest");
let resp = client.get(&url).send().await.ok()?;
let json: Value = resp.json().await.ok()?;
json.get("version")
@@ -95,7 +116,7 @@ fn detect_installed_source() -> String {
return "official".into();
}
// 方法2fallback 到 npm list
if let Ok(o) = Command::new("npm")
if let Ok(o) = npm_command()
.args(["list", "-g", "@qingchencloud/openclaw-zh", "--depth=0"])
.output()
{
@@ -151,7 +172,7 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
let old_pkg = npm_package_name(&current_source);
let _ = app.emit("upgrade-log", format!("正在卸载旧版本 ({old_pkg})..."));
let _ = app.emit("upgrade-progress", 5);
let _ = Command::new("npm")
let _ = npm_command()
.args(["uninstall", "-g", old_pkg])
.output();
}
@@ -159,7 +180,7 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
let _ = app.emit("upgrade-log", format!("$ npm install -g {pkg}"));
let _ = app.emit("upgrade-progress", 10);
let mut child = Command::new("npm")
let mut child = npm_command()
.args(["install", "-g", &pkg])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -569,3 +590,15 @@ pub fn uninstall_gateway() -> Result<String, String> {
Ok("Gateway 服务已卸载".to_string())
}
#[tauri::command]
pub fn get_npm_registry() -> Result<String, String> {
Ok(get_configured_registry())
}
#[tauri::command]
pub fn set_npm_registry(registry: String) -> Result<(), String> {
let path = super::openclaw_dir().join("npm-registry.txt");
fs::write(&path, registry.trim())
.map_err(|e| format!("保存失败: {e}"))
}

View File

@@ -25,6 +25,8 @@ pub fn run() {
config::upgrade_openclaw,
config::install_gateway,
config::uninstall_gateway,
config::get_npm_registry,
config::set_npm_registry,
// 服务
service::get_services_status,
service::start_service,

View File

@@ -99,6 +99,8 @@ function mockInvoke(cmd, args) {
upgrade_openclaw: () => '升级成功,当前版本: 2026.2.26-zh.3 (mock)',
install_gateway: () => 'Gateway 服务已安装 (mock)',
uninstall_gateway: () => 'Gateway 服务已卸载 (mock)',
get_npm_registry: () => 'https://registry.npmmirror.com',
set_npm_registry: () => true,
test_model: ({ modelId }) => `模型 ${modelId} 连通正常 (mock)`,
list_remote_models: () => ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo', 'o3-mini', 'dall-e-3', 'text-embedding-3-small'],
write_env_file: () => true,
@@ -144,6 +146,8 @@ export const api = {
upgradeOpenclaw: (source = 'chinese') => invoke('upgrade_openclaw', { source }),
installGateway: () => invoke('install_gateway'),
uninstallGateway: () => invoke('uninstall_gateway'),
getNpmRegistry: () => invoke('get_npm_registry'),
setNpmRegistry: (registry) => invoke('set_npm_registry', { registry }),
testModel: (baseUrl, apiKey, modelId) => invoke('test_model', { baseUrl, apiKey, modelId }),
listRemoteModels: (baseUrl, apiKey) => invoke('list_remote_models', { baseUrl, apiKey }),

View File

@@ -27,6 +27,10 @@ export async function render() {
</div>
<div id="version-bar"></div>
<div id="services-list">加载中...</div>
<div class="config-section" id="registry-section">
<div class="config-section-title">npm 源设置</div>
<div id="registry-bar">加载中...</div>
</div>
<div class="config-section" id="backup-section">
<div class="config-section-title">配置备份</div>
<div id="backup-actions" style="margin-bottom:var(--space-md)">
@@ -45,6 +49,7 @@ async function loadAll(page) {
await Promise.all([
loadVersion(page),
loadServices(page),
loadRegistry(page),
loadBackups(page),
])
}
@@ -85,6 +90,41 @@ async function loadVersion(page) {
}
}
// ===== npm 源设置 =====
const REGISTRIES = [
{ label: '淘宝镜像 (推荐)', value: 'https://registry.npmmirror.com' },
{ label: 'npm 官方源', value: 'https://registry.npmjs.org' },
{ label: '华为云镜像', value: 'https://repo.huaweicloud.com/repository/npm/' },
]
async function loadRegistry(page) {
const bar = page.querySelector('#registry-bar')
try {
const current = await api.getNpmRegistry()
const isPreset = REGISTRIES.some(r => r.value === current)
bar.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<select class="form-input" data-name="registry" style="max-width:320px">
${REGISTRIES.map(r => `<option value="${r.value}" ${r.value === current ? 'selected' : ''}>${r.label}</option>`).join('')}
<option value="custom" ${!isPreset ? 'selected' : ''}>自定义</option>
</select>
<input class="form-input" data-name="custom-registry" placeholder="https://..." value="${isPreset ? '' : escapeHtml(current)}" style="max-width:320px;${isPreset ? 'display:none' : ''}">
<button class="btn btn-primary btn-sm" data-action="save-registry">保存</button>
</div>
<div class="form-hint" style="margin-top:var(--space-xs)">升级和版本检测使用此源下载 npm 包,国内用户推荐淘宝镜像</div>
`
// 切换预设/自定义
const select = bar.querySelector('[data-name="registry"]')
const customInput = bar.querySelector('[data-name="custom-registry"]')
select.onchange = () => {
customInput.style.display = select.value === 'custom' ? '' : 'none'
}
} catch (e) {
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
}
}
// ===== 服务列表 =====
async function loadServices(page) {
@@ -214,6 +254,9 @@ function bindEvents(page) {
case 'uninstall-gateway':
await handleUninstallGateway(btn, page)
break
case 'save-registry':
await handleSaveRegistry(btn, page)
break
}
} catch (e) {
toast(e.toString(), 'error')
@@ -310,3 +353,13 @@ async function handleUninstallGateway(btn, page) {
toast('Gateway 服务已卸载', 'success')
await loadServices(page)
}
async function handleSaveRegistry(btn, page) {
const section = page.querySelector('#registry-section')
const select = section.querySelector('[data-name="registry"]')
const customInput = section.querySelector('[data-name="custom-registry"]')
const registry = select.value === 'custom' ? customInput.value.trim() : select.value
if (!registry) { toast('请输入源地址', 'error'); return }
await api.setNpmRegistry(registry)
toast('npm 源已保存', 'success')
}