mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-26 10:11:36 +08:00
fix: wsClient.close→disconnect, model vision input, memory leaks; feat: loading skeletons, panel update check; bump v0.2.0
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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 握手
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
// 备份管理
|
||||
|
||||
13
src/main.js
13
src/main.js
@@ -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) {
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user