fix: wsClient.close→disconnect, model vision input, memory leaks; feat: loading skeletons, panel update check; bump v0.2.0

This commit is contained in:
晴天
2026-03-04 18:07:12 +08:00
parent 57ad84fcd3
commit 3b81a193bb
12 changed files with 169 additions and 13 deletions

View File

@@ -865,6 +865,105 @@ pub fn uninstall_gateway() -> Result<String, String> {
Ok("Gateway 服务已卸载".to_string())
}
/// 为 openclaw.json 中所有模型添加 input: ["text", "image"],使 Gateway 识别模型支持图片输入
#[tauri::command]
pub fn patch_model_vision() -> Result<bool, String> {
let path = super::openclaw_dir().join("openclaw.json");
let content = fs::read_to_string(&path).map_err(|e| format!("读取配置失败: {e}"))?;
let mut config: Value =
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?;
let vision_input = Value::Array(vec![
Value::String("text".into()),
Value::String("image".into()),
]);
let mut changed = false;
if let Some(obj) = config.as_object_mut() {
if let Some(models_val) = obj.get_mut("models") {
if let Some(models_obj) = models_val.as_object_mut() {
if let Some(providers_val) = models_obj.get_mut("providers") {
if let Some(providers_obj) = providers_val.as_object_mut() {
for (_provider_name, provider_val) in providers_obj.iter_mut() {
if let Some(provider_obj) = provider_val.as_object_mut() {
if let Some(Value::Array(arr)) = provider_obj.get_mut("models") {
for model in arr.iter_mut() {
if let Some(mobj) = model.as_object_mut() {
if !mobj.contains_key("input") {
mobj.insert(
"input".into(),
vision_input.clone(),
);
changed = true;
}
}
}
}
}
}
}
}
}
}
}
if changed {
let bak = super::openclaw_dir().join("openclaw.json.bak");
let _ = fs::copy(&path, &bak);
let json =
serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?;
fs::write(&path, json).map_err(|e| format!("写入失败: {e}"))?;
}
Ok(changed)
}
/// 检查 ClawPanel 自身是否有新版本(通过 GitHub releases API
#[tauri::command]
pub async fn check_panel_update() -> Result<Value, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.user_agent("ClawPanel")
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
let url = "https://api.github.com/repos/qingchencloud/clawpanel/releases/latest";
let resp = client
.get(url)
.send()
.await
.map_err(|e| format!("请求失败: {e}"))?;
if !resp.status().is_success() {
return Err(format!("GitHub API 返回 {}", resp.status()));
}
let json: Value = resp
.json()
.await
.map_err(|e| format!("解析响应失败: {e}"))?;
let tag = json
.get("tag_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim_start_matches('v')
.to_string();
let mut result = serde_json::Map::new();
result.insert("latest".into(), Value::String(tag));
result.insert(
"url".into(),
json.get("html_url")
.cloned()
.unwrap_or(Value::String(
"https://github.com/qingchencloud/clawpanel/releases".into(),
)),
);
Ok(Value::Object(result))
}
#[tauri::command]
pub fn get_npm_registry() -> Result<String, String> {
Ok(get_configured_registry())

View File

@@ -33,6 +33,8 @@ pub fn run() {
config::upgrade_openclaw,
config::install_gateway,
config::uninstall_gateway,
config::patch_model_vision,
config::check_panel_update,
config::get_npm_registry,
config::set_npm_registry,
// 设备密钥 + Gateway 握手

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "ClawPanel",
"version": "0.1.0",
"version": "0.2.0",
"identifier": "ai.openclaw.clawpanel",
"build": {
"frontendDist": "../dist",

View File

@@ -175,6 +175,8 @@ function mockInvoke(cmd, args) {
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'],
patch_model_vision: () => false,
check_panel_update: () => ({ latest: '0.2.0', url: 'https://github.com/qingchencloud/clawpanel/releases' }),
write_env_file: () => true,
list_backups: () => [
{ name: 'openclaw-20260226-143000.json', size: 8542, created_at: 1740577800 },
@@ -247,6 +249,8 @@ export const api = {
checkInstallation: () => cachedInvoke('check_installation', {}, 60000),
checkNode: () => cachedInvoke('check_node', {}, 60000),
getDeployConfig: () => cachedInvoke('get_deploy_config'),
patchModelVision: () => invoke('patch_model_vision'),
checkPanelUpdate: () => invoke('check_panel_update'),
writeEnvFile: (path, config) => invoke('write_env_file', { path, config }),
// 备份管理

View File

@@ -64,7 +64,7 @@ async function boot() {
if (running) {
autoConnectWebSocket()
} else {
wsClient.close()
wsClient.disconnect()
}
})
}
@@ -86,6 +86,17 @@ async function autoConnectWebSocket() {
console.warn('[main] autoPairDevice 失败(非致命):', pairErr)
}
// 确保模型配置包含 vision 支持input: ["text", "image"]
try {
const patched = await api.patchModelVision()
if (patched) {
console.log('[main] 已为模型添加 vision 支持,重载 Gateway...')
await api.reloadGateway()
}
} catch (visionErr) {
console.warn('[main] patchModelVision 失败(非致命):', visionErr)
}
wsClient.connect(`127.0.0.1:${port}`, token)
console.log('[main] WebSocket 连接已启动')
} catch (e) {

View File

@@ -65,11 +65,26 @@ async function loadData(page) {
// 非 Tauri 环境或 API 不可用,使用 fallback
}
// 异步检查 ClawPanel 自身更新
let panelUpdateHtml = '<span style="color:var(--text-tertiary)">检查更新中...</span>'
api.checkPanelUpdate().then(info => {
const panelCard = cards.querySelector('#panel-update-meta')
if (!panelCard) return
if (info.latest && info.latest !== panelVersion && compareVersions(info.latest, panelVersion) > 0) {
panelCard.innerHTML = `<span style="color:var(--accent)">新版本: ${info.latest}</span> <a class="btn btn-primary btn-sm" href="${info.url}" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">下载更新</a>`
} else {
panelCard.innerHTML = '<span style="color:var(--success)">已是最新</span>'
}
}).catch(() => {
const panelCard = cards.querySelector('#panel-update-meta')
if (panelCard) panelCard.innerHTML = '<span style="color:var(--text-tertiary)">检查更新失败</span>'
})
cards.innerHTML = `
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">ClawPanel</span></div>
<div class="stat-card-value">${panelVersion}</div>
<div class="stat-card-meta">Tauri v2 桌面应用</div>
<div class="stat-card-meta" id="panel-update-meta" style="display:flex;align-items:center;gap:8px">${panelUpdateHtml}</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">OpenClaw · ${version.source === 'official' ? '官方版' : '汉化版'}</span></div>
@@ -114,6 +129,18 @@ async function loadData(page) {
}
}
function compareVersions(a, b) {
const pa = a.split('.').map(Number)
const pb = b.split('.').map(Number)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] || 0
const nb = pb[i] || 0
if (na > nb) return 1
if (na < nb) return -1
}
return 0
}
function renderCommunity(page) {
const el = page.querySelector('#community-section')
el.innerHTML = `

View File

@@ -27,12 +27,12 @@ export async function render() {
<div id="cftunnel-card" class="config-section">
<div class="config-section-title">cftunnel 内网穿透</div>
<div class="form-hint" style="margin-bottom:var(--space-md)">通过 Cloudflare Tunnel 将本地服务暴露到公网,无需公网 IP 和端口映射。</div>
<div id="cftunnel-content"></div>
<div id="cftunnel-content"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
</div>
<div id="clawapp-card" class="config-section">
<div class="config-section-title">ClawApp 移动客户端</div>
<div class="form-hint" style="margin-bottom:var(--space-md)">基于 LobeChat 的 AI 对话客户端,通过 Gateway 连接模型服务。支持本地和外网访问。</div>
<div id="clawapp-content"></div>
<div id="clawapp-content"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
</div>
`

View File

@@ -13,7 +13,11 @@ export async function render() {
<h1 class="page-title">Gateway 配置</h1>
<p class="page-desc">Gateway 是 AI 模型的统一入口,所有应用通过它来调用模型服务</p>
</div>
<div id="gateway-config"></div>
<div id="gateway-config">
<div class="config-section"><div class="stat-card loading-placeholder" style="height:80px"></div></div>
<div class="config-section"><div class="stat-card loading-placeholder" style="height:80px"></div></div>
<div class="config-section"><div class="stat-card loading-placeholder" style="height:80px"></div></div>
</div>
<div class="gw-save-bar">
<button class="btn btn-primary" id="btn-save-gw">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>

View File

@@ -33,7 +33,7 @@ export async function render() {
<input type="checkbox" id="log-autoscroll" checked> 自动滚动
</label>
</div>
<div class="log-viewer" id="log-content" style="height:calc(100vh - 280px)"></div>
<div class="log-viewer" id="log-content" style="height:calc(100vh - 280px)"><div class="stat-card loading-placeholder" style="height:16px;margin:8px 0"></div><div class="stat-card loading-placeholder" style="height:16px;margin:8px 0"></div><div class="stat-card loading-placeholder" style="height:16px;margin:8px 0"></div><div class="stat-card loading-placeholder" style="height:16px;margin:8px 0"></div></div>
`
let currentTab = 'gateway'

View File

@@ -36,7 +36,7 @@ export async function render() {
<div style="padding:0 var(--space-sm) var(--space-sm)">
<button class="btn btn-sm btn-secondary" id="btn-export-zip" style="width:100%">打包下载全部</button>
</div>
<div id="file-tree"></div>
<div id="file-tree"><div class="stat-card loading-placeholder" style="height:32px;margin:8px"></div><div class="stat-card loading-placeholder" style="height:32px;margin:8px"></div><div class="stat-card loading-placeholder" style="height:32px;margin:8px"></div></div>
</div>
<div class="memory-editor">
<div class="editor-toolbar">

View File

@@ -64,7 +64,10 @@ export async function render() {
<div style="margin-bottom:var(--space-md)">
<input class="form-input" id="model-search" placeholder="搜索模型(按 ID 或名称过滤)" style="max-width:360px">
</div>
<div id="providers-list"></div>
<div id="providers-list">
<div class="config-section"><div class="stat-card loading-placeholder" style="height:120px"></div></div>
<div class="config-section"><div class="stat-card loading-placeholder" style="height:120px"></div></div>
</div>
`
const state = { config: null, search: '', undoStack: [] }
@@ -349,6 +352,12 @@ async function undo(page, state) {
// 自动保存(防抖 300ms
let _saveTimer = null
let _batchTestAbort = null // 批量测试终止控制器
export function cleanup() {
clearTimeout(_saveTimer)
_saveTimer = null
if (_batchTestAbort) { _batchTestAbort.abort = true; _batchTestAbort = null }
}
function autoSave(state) {
clearTimeout(_saveTimer)
_saveTimer = setTimeout(() => doAutoSave(state), 300)
@@ -1074,7 +1083,7 @@ async function fetchRemoteModels(btn, page, state, providerKey) {
if (!selected.length) { toast('请至少选择一个模型', 'warning'); return }
pushUndo(state)
for (const id of selected) {
provider.models.push({ id, input: ['text'] })
provider.models.push({ id, input: ['text', 'image'] })
}
overlay.remove()
renderProviders(page, state)

View File

@@ -25,8 +25,8 @@ export async function render() {
<h1 class="page-title">服务管理</h1>
<p class="page-desc">管理 OpenClaw 服务、检查更新、配置备份</p>
</div>
<div id="version-bar"></div>
<div id="services-list"></div>
<div id="version-bar"><div class="stat-card loading-placeholder" style="height:80px;margin-bottom:var(--space-lg)"></div></div>
<div id="services-list"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
<div class="config-section" id="registry-section">
<div class="config-section-title">npm 源设置</div>
<div id="registry-bar"></div>
@@ -37,7 +37,7 @@ export async function render() {
<div id="backup-actions" style="margin-bottom:var(--space-md)">
<button class="btn btn-primary btn-sm" data-action="create-backup">创建备份</button>
</div>
<div id="backup-list"></div>
<div id="backup-list"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>
`