fix: resolve 6 issues (#32 #31 #25 #33 #29 #23)

- #32: parseCookies decodeURIComponent crash with malformed cookies (Authelia)
- #31: Gateway restart no longer overwrites user CORS allowedOrigins (merge instead)
- #25: Windows terminal flashing - add CREATE_NO_WINDOW to skills.rs + assistant.rs
- #33: Model test tolerates non-auth HTTP errors (Ali Coding Plan compatibility)
- #29: Auto-detect ws/wss protocol for reverse proxy + protocol-aware Docker URLs
- #23: Chat session sidebar stays open when switching sessions
This commit is contained in:
晴天
2026-03-10 22:51:15 +08:00
parent 044d866b57
commit 91f96ac96b
9 changed files with 112 additions and 60 deletions

1
.gitignore vendored
View File

@@ -46,3 +46,4 @@ docs/promo-video.mp4
# Rust 开发工具
src-tauri/.cargo/
.codex/

View File

@@ -153,7 +153,7 @@ function parseCookies(req) {
const obj = {}
;(req.headers.cookie || '').split(';').forEach(pair => {
const [k, ...v] = pair.trim().split('=')
if (k) obj[k] = decodeURIComponent(v.join('='))
if (k) try { obj[k] = decodeURIComponent(v.join('=')) } catch (_) { obj[k] = v.join('=') }
})
return obj
}

View File

@@ -2,6 +2,9 @@ use base64::{engine::general_purpose, Engine as _};
/// AI 助手工具命令
/// 提供终端执行、文件读写、目录列表等能力
/// 仅在用户主动开启工具后由 AI 调用
#[cfg(target_os = "windows")]
#[allow(unused_imports)]
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
/// 审计日志:记录 AI 助手的敏感操作exec / read / write
@@ -267,18 +270,23 @@ pub async fn assistant_system_info() -> Result<String, String> {
/// 列出运行中的进程(按名称过滤)
#[tauri::command]
pub async fn assistant_list_processes(filter: Option<String>) -> Result<String, String> {
let output = if cfg!(target_os = "windows") {
tokio::process::Command::new("powershell")
let output;
#[cfg(target_os = "windows")]
{
output = tokio::process::Command::new("powershell")
.args(["-NoProfile", "-Command",
"Get-Process | Select-Object Id, ProcessName, CPU, WorkingSet64 | Sort-Object ProcessName | Format-Table -AutoSize | Out-String -Width 200"])
.creation_flags(0x08000000)
.output()
.await
} else {
tokio::process::Command::new("ps")
.await;
}
#[cfg(not(target_os = "windows"))]
{
output = tokio::process::Command::new("ps")
.args(["aux", "--sort=-%mem"])
.output()
.await
};
.await;
}
let output = output.map_err(|e| format!("获取进程列表失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
@@ -331,18 +339,23 @@ pub async fn assistant_check_port(port: u16) -> Result<String, String> {
}
async fn get_port_process(port: u16) -> String {
let output = if cfg!(target_os = "windows") {
tokio::process::Command::new("powershell")
let output;
#[cfg(target_os = "windows")]
{
output = tokio::process::Command::new("powershell")
.args(["-NoProfile", "-Command",
&format!("Get-NetTCPConnection -LocalPort {} -ErrorAction SilentlyContinue | Select-Object OwningProcess | ForEach-Object {{ (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName }}", port)])
.creation_flags(0x08000000)
.output()
.await
} else {
tokio::process::Command::new("lsof")
.await;
}
#[cfg(not(target_os = "windows"))]
{
output = tokio::process::Command::new("lsof")
.args(["-i", &format!(":{}", port), "-t"])
.output()
.await
};
.await;
}
match output {
Ok(o) => {

View File

@@ -1309,25 +1309,37 @@ pub async fn test_model(
.map(String::from)
})
.unwrap_or_else(|| format!("HTTP {status}"));
return Err(msg);
// 401/403 是认证错误,一定要报错
if status.as_u16() == 401 || status.as_u16() == 403 {
return Err(msg);
}
// 其他错误400/422 等):服务器可达、认证通过,仅模型对简单测试不兼容
// 返回成功但带提示,避免误导用户认为模型不可用
return Ok(format!("⚠ 连接正常API 返回 {status},部分模型对简单测试不兼容,不影响实际使用)"));
}
// 提取回复内容(兼容 reasoning 模型的 reasoning_content 字段
// 提取回复内容(兼容多种响应格式
let reply = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| {
let msg = v.get("choices")?.get(0)?.get("message")?;
// 优先取 content为空则取 reasoning_content
let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
if !content.is_empty() {
return Some(content.to_string());
// 标准 OpenAI 格式: choices[0].message.content
if let Some(msg) = v.get("choices").and_then(|c| c.get(0)).and_then(|c| c.get("message")) {
let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
if !content.is_empty() {
return Some(content.to_string());
}
// reasoning 模型
if let Some(rc) = msg.get("reasoning_content").and_then(|c| c.as_str()).filter(|s| !s.is_empty()) {
return Some(format!("[reasoning] {rc}"));
}
}
msg.get("reasoning_content")
.and_then(|c| c.as_str())
.filter(|s| !s.is_empty())
.map(|s| format!("[reasoning] {s}"))
// DashScope 格式: output.text
if let Some(t) = v.get("output").and_then(|o| o.get("text")).and_then(|t| t.as_str()).filter(|s| !s.is_empty()) {
return Some(t.to_string());
}
None
})
.unwrap_or_else(|| "无回复内容".into());
.unwrap_or_else(|| "模型已响应".into());
Ok(reply)
}

View File

@@ -115,14 +115,14 @@ fn patch_gateway_origins() {
return;
};
// 仅允许 Tauri 应用 + 本地开发服务器的 origin
let origins = serde_json::json!([
"tauri://localhost",
"https://tauri.localhost",
"http://tauri.localhost",
"http://localhost:1420",
"http://127.0.0.1:1420"
]);
// Tauri 应用 + 本地开发服务器必须存在的 origin
let required: Vec<String> = vec![
"tauri://localhost".into(),
"https://tauri.localhost".into(),
"http://tauri.localhost".into(),
"http://localhost:1420".into(),
"http://127.0.0.1:1420".into(),
];
if let Some(obj) = config.as_object_mut() {
let gateway = obj
@@ -133,7 +133,26 @@ fn patch_gateway_origins() {
.entry("controlUi")
.or_insert_with(|| serde_json::json!({}));
if let Some(cui) = control_ui.as_object_mut() {
cui.insert("allowedOrigins".to_string(), origins);
// 合并:保留用户已有的 origin追加缺失的 Tauri origin
let existing: Vec<String> = cui
.get("allowedOrigins")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let mut merged = existing;
for r in &required {
if !merged.iter().any(|e| e == r) {
merged.push(r.clone());
}
}
cui.insert(
"allowedOrigins".to_string(),
serde_json::json!(merged),
);
}
}
}

View File

@@ -1,6 +1,10 @@
use crate::utils::openclaw_command_async;
use serde_json::Value;
#[cfg(target_os = "windows")]
#[allow(unused_imports)]
use std::os::windows::process::CommandExt;
/// 列出所有 Skills 及其状态openclaw skills list --json
#[tauri::command]
pub async fn skills_list() -> Result<Value, String> {
@@ -104,11 +108,11 @@ pub async fn skills_install_dep(kind: String, spec: Value) -> Result<Value, Stri
other => return Err(format!("不支持的安装类型: {other}")),
};
let output = tokio::process::Command::new(&program)
.args(&args)
.env("PATH", &path_env)
.output()
.await
let mut cmd = tokio::process::Command::new(&program);
cmd.args(&args).env("PATH", &path_env);
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000);
let output = cmd.output().await
.map_err(|e| format!("执行 {program} 失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
@@ -140,12 +144,13 @@ pub async fn skills_clawhub_install(slug: String) -> Result<Value, String> {
std::fs::create_dir_all(&skills_dir).map_err(|e| format!("创建 skills 目录失败: {e}"))?;
}
let output = tokio::process::Command::new("npx")
.args(["-y", "clawhub", "install", &slug])
let mut cmd = tokio::process::Command::new("npx");
cmd.args(["-y", "clawhub", "install", &slug])
.env("PATH", &path_env)
.current_dir(&home)
.output()
.await
.current_dir(&home);
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000);
let output = cmd.output().await
.map_err(|e| format!("执行 clawhub 失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
@@ -171,11 +176,12 @@ pub async fn skills_clawhub_search(query: String) -> Result<Value, String> {
}
let path_env = super::enhanced_path();
let output = tokio::process::Command::new("npx")
.args(["-y", "clawhub", "search", &q])
.env("PATH", &path_env)
.output()
.await
let mut cmd = tokio::process::Command::new("npx");
cmd.args(["-y", "clawhub", "search", &q])
.env("PATH", &path_env);
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000);
let output = cmd.output().await
.map_err(|e| format!("执行 clawhub 失败: {e}"))?;
if !output.status.success() {

View File

@@ -66,11 +66,13 @@ export class WsClient {
return () => { this._readyCallbacks = this._readyCallbacks.filter(cb => cb !== fn) }
}
connect(host, token) {
connect(host, token, opts = {}) {
this._intentionalClose = false
this._autoPairAttempts = 0
this._token = token || ''
this._url = `ws://${host}/ws?token=${encodeURIComponent(this._token)}`
// 自动检测协议:如果页面通过 HTTPS 加载(反代场景),使用 wss://
const proto = opts.secure ?? (typeof location !== 'undefined' && location.protocol === 'https:') ? 'wss' : 'ws'
this._url = `${proto}://${host}/ws?token=${encodeURIComponent(this._token)}`
this._doConnect()
}

View File

@@ -484,7 +484,6 @@ function switchSession(newKey) {
clearMessages()
loadHistory()
refreshSessionList()
_page?.querySelector('#chat-sidebar')?.classList.remove('open')
}
async function showNewSessionDialog() {

View File

@@ -332,7 +332,7 @@ function _renderUnitCard(c, showAdopt) {
</div>
${isRunning && (ports.panel || ports.gateway) ? `
<div class="unit-links">
${ports.panel ? `<a href="http://${host}:${ports.panel}" target="_blank" rel="noopener" class="unit-link panel">${icon('monitor', 12)} 面板 :${ports.panel}</a>` : ''}
${ports.panel ? `<a href="${location.protocol}//${host}:${ports.panel}" target="_blank" rel="noopener" class="unit-link panel">${icon('monitor', 12)} 面板 :${ports.panel}</a>` : ''}
${ports.gateway ? `<span class="unit-link gateway" data-action="quick-chat" data-container-id="${esc(c.id)}" data-node-id="${esc(c.nodeId || '')}" data-name="${esc(c.name)}" title="发送测试消息">${icon('zap', 12)} 通讯 :${ports.gateway}</span>` : ''}
</div>
` : ''}
@@ -1474,7 +1474,7 @@ async function showDeployDialog(page, nodeId) {
// 成功页面
const host = location.hostname || 'localhost'
const panelUrl = `http://${host}:${panelPort}`
const panelUrl = `${location.protocol}//${host}:${panelPort}`
const selectedRole = overlay.querySelector('#dd-role')?.value || 'general'
const roleInfo = MILITARY.roles[selectedRole] || MILITARY.roles.general
@@ -1488,7 +1488,7 @@ async function showDeployDialog(page, nodeId) {
<button class="btn" data-dismiss>关闭</button>
</div>
<div style="margin-top:16px;font-size:11px;color:var(--text-tertiary);font-family:var(--font-mono)">
Panel: ${panelUrl} · Gateway: ws://${host}:${gatewayPort}
Panel: ${panelUrl} · Gateway: ${location.protocol === 'https:' ? 'wss' : 'ws'}://${host}:${gatewayPort}
</div>
</div>
`
@@ -1543,18 +1543,18 @@ async function showInspectDialog(page, nodeId, containerId) {
<div class="inspect-section">
<div class="inspect-section-title">指挥通道</div>
<div class="inspect-links">
${ports.panel ? `<a href="http://${host}:${ports.panel}" target="_blank" rel="noopener" class="inspect-link-card">
${ports.panel ? `<a href="${location.protocol}//${host}:${ports.panel}" target="_blank" rel="noopener" class="inspect-link-card">
<span class="inspect-link-icon">${icon('monitor', 20)}</span>
<span class="inspect-link-text">
<strong>指挥台</strong>
<span>http://${host}:${ports.panel}</span>
<span>${location.protocol}//${host}:${ports.panel}</span>
</span>
</a>` : ''}
${ports.gateway ? `<div class="inspect-link-card" style="cursor:default;opacity:0.85">
<span class="inspect-link-icon">${icon('zap', 20)}</span>
<span class="inspect-link-text">
<strong>通讯链路 (WebSocket)</strong>
<span>ws://${host}:${ports.gateway}/ws</span>
<span>${location.protocol === 'https:' ? 'wss' : 'ws'}://${host}:${ports.gateway}/ws</span>
</span>
</div>` : ''}
</div>