mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-27 03:10:03 +08:00
chore: release v0.14.0
集中发版: 新功能(10) - 心甜Claw 引擎入口(第 3 个引擎模式) - Hermes 22 个 Provider 注册表 + 安装/仪表盘动态加载 - Hermes .env 高级编辑(拒绝触碰托管 Provider 密钥) - Hermes 会话与用量分析增强 - Hermes Dashboard 自动拉起 + Windows POSIX-only 兼容模态 - Hermes Skills 工具集面板 - 官网 Hermes Agent 黑金特色区 + 图文指南 - Boot Manifest 启动页(双语 + 错峰动画) - 官网 Markdown 阅读器图片 lightbox - Hermes Memory 概览卡 改进(9) - Hermes 仪表盘/扩展页全面本地化 - 记忆编辑大尺寸模态 - 日志下载 Web/桌面分流 - 侧边栏导航补全 - 模型备选管理 UI(PR #232) - 模型加载错误 UX 重做(错误卡 + 详情 + 重试) - .page 布局 clamp + .page-narrow - Memory 单列断点提早到 1100px - Web 模式跳过前端热更新检查 修复(12) - Gateway 启动 platforms.api_server.enabled 自修复(含 7 unit test) - Memory 页 overview 卡穿模(旧 flex 列约束 → 自然块流) - Skills 页 hero/toolsets 被压缩(flex-shrink:0) - Web 模式 Skills ReferenceError(补 _readHermesDisabledSkills) - 日志/记忆下载行为分流 - src/pages/models.js 5 处 typo - 删除 56 行 .hm-memory-* 死代码 + line-clamp 标准属性 - Dependabot rustls-webpki / postcss / rand
This commit is contained in:
@@ -5124,6 +5124,102 @@ fn normalize_base_url_for_api(raw: &str, api_type: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_env_key(key: &str) -> bool {
|
||||
let mut chars = key.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
if !(first == '_' || first.is_ascii_alphabetic()) {
|
||||
return false;
|
||||
}
|
||||
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn model_api_key_env_ref(raw: &str) -> Result<Option<String>, String> {
|
||||
let value = raw.trim();
|
||||
if value.starts_with("${") && value.ends_with('}') {
|
||||
let key = &value[2..value.len() - 1];
|
||||
if is_valid_env_key(key) {
|
||||
return Ok(Some(key.to_string()));
|
||||
}
|
||||
return Err(format!("无效的环境变量引用: {value}"));
|
||||
}
|
||||
if let Some(key) = value.strip_prefix('$') {
|
||||
if !key.is_empty() && is_valid_env_key(key) {
|
||||
return Ok(Some(key.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn parse_dotenv_line(line: &str) -> Option<(String, String)> {
|
||||
let line = line.trim().trim_start_matches('\u{feff}');
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
let line = line.strip_prefix("export ").unwrap_or(line).trim();
|
||||
let (key, value) = line.split_once('=')?;
|
||||
let key = key.trim();
|
||||
if !is_valid_env_key(key) {
|
||||
return None;
|
||||
}
|
||||
let mut value = value.trim().to_string();
|
||||
if value.len() >= 2 {
|
||||
let bytes = value.as_bytes();
|
||||
if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
|
||||
|| (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
|
||||
{
|
||||
value = value[1..value.len() - 1].to_string();
|
||||
}
|
||||
}
|
||||
Some((key.to_string(), value))
|
||||
}
|
||||
|
||||
fn model_env_values() -> HashMap<String, String> {
|
||||
let mut values = HashMap::new();
|
||||
if let Ok(cfg) = load_openclaw_json() {
|
||||
if let Some(env) = cfg.get("env").and_then(|v| v.as_object()) {
|
||||
for (key, value) in env {
|
||||
if !is_valid_env_key(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = value.as_str() {
|
||||
values.insert(key.clone(), s.to_string());
|
||||
} else if value.is_number() || value.is_boolean() {
|
||||
values.insert(key.clone(), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let env_path = super::openclaw_dir().join(".env");
|
||||
if let Ok(content) = fs::read_to_string(env_path) {
|
||||
for line in content.lines() {
|
||||
if let Some((key, value)) = parse_dotenv_line(line) {
|
||||
values.entry(key).or_insert(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn resolve_model_api_key(api_key: &str) -> Result<String, String> {
|
||||
let Some(key) = model_api_key_env_ref(api_key)? else {
|
||||
return Ok(api_key.to_string());
|
||||
};
|
||||
let values = model_env_values();
|
||||
if let Some(value) = values.get(&key).filter(|v| !v.is_empty()) {
|
||||
return Ok(value.clone());
|
||||
}
|
||||
if let Ok(value) = std::env::var(&key) {
|
||||
if !value.is_empty() {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"API Key 引用了环境变量 {key},但未在 openclaw.json env、~/.openclaw/.env 或当前进程环境中找到"
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_error_message(text: &str, status: reqwest::StatusCode) -> String {
|
||||
serde_json::from_str::<serde_json::Value>(text)
|
||||
.ok()
|
||||
@@ -5147,6 +5243,7 @@ pub async fn test_model(
|
||||
) -> Result<String, String> {
|
||||
let api_type = normalize_model_api_type(api_type.as_deref().unwrap_or("openai-completions"));
|
||||
let base = normalize_base_url_for_api(&base_url, api_type);
|
||||
let api_key = resolve_model_api_key(&api_key)?;
|
||||
|
||||
let client =
|
||||
crate::commands::build_http_client_no_proxy(std::time::Duration::from_secs(30), None)
|
||||
@@ -5420,6 +5517,7 @@ pub async fn test_model_verbose(
|
||||
let api_type_norm =
|
||||
normalize_model_api_type(api_type.as_deref().unwrap_or("openai-completions"));
|
||||
let base = normalize_base_url_for_api(&base_url, api_type_norm);
|
||||
let api_key = resolve_model_api_key(&api_key)?;
|
||||
let start = Instant::now();
|
||||
|
||||
let client =
|
||||
@@ -5649,6 +5747,7 @@ pub async fn list_remote_models(
|
||||
) -> Result<Vec<String>, String> {
|
||||
let api_type = normalize_model_api_type(api_type.as_deref().unwrap_or("openai-completions"));
|
||||
let base = normalize_base_url_for_api(&base_url, api_type);
|
||||
let api_key = resolve_model_api_key(&api_key)?;
|
||||
|
||||
let client =
|
||||
crate::commands::build_http_client_no_proxy(std::time::Duration::from_secs(15), None)
|
||||
|
||||
@@ -775,6 +775,270 @@ fn hermes_gateway_port() -> u16 {
|
||||
8642 // Hermes 默认端口
|
||||
}
|
||||
|
||||
/// Hermes Dashboard 端口 - 从 config.yaml 的 dashboard.port 读取,默认 9119
|
||||
fn hermes_dashboard_port() -> u16 {
|
||||
let config_path = hermes_home().join("config.yaml");
|
||||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||
let mut in_dashboard = false;
|
||||
for line in content.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() || t.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let indent = line.len() - line.trim_start().len();
|
||||
if indent == 0 {
|
||||
in_dashboard = t == "dashboard:" || t.starts_with("dashboard:");
|
||||
continue;
|
||||
}
|
||||
if in_dashboard && t.starts_with("port:") {
|
||||
if let Ok(port) = t.trim_start_matches("port:").trim().parse::<u16>() {
|
||||
if port > 0 {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9119 // Hermes Dashboard 默认端口
|
||||
}
|
||||
|
||||
/// 探测 Hermes Dashboard 是否在运行(TCP 连接 127.0.0.1 上的 dashboard 端口)
|
||||
/// 返回 { running: bool, port: u16 },前端据此决定是否打开浏览器或提示用户启动
|
||||
#[tauri::command]
|
||||
pub async fn hermes_dashboard_probe() -> Result<Value, String> {
|
||||
let port = hermes_dashboard_port();
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
let socket_addr: std::net::SocketAddr = addr
|
||||
.parse()
|
||||
.map_err(|e| format!("address parse error: {e}"))?;
|
||||
let running = tokio::task::spawn_blocking(move || {
|
||||
std::net::TcpStream::connect_timeout(&socket_addr, std::time::Duration::from_millis(800))
|
||||
.is_ok()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
Ok(serde_json::json!({ "running": running, "port": port }))
|
||||
}
|
||||
|
||||
/// 我们 spawn 的 Dashboard 进程 PID(0 = 没有)
|
||||
static DASH_PID: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// 精准杀掉我们 spawn 的 Dashboard 进程(taskkill /F /PID)
|
||||
fn kill_dashboard_pid() -> bool {
|
||||
let pid = DASH_PID.load(Ordering::SeqCst);
|
||||
if pid == 0 {
|
||||
return false;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let mut cmd = std::process::Command::new("taskkill");
|
||||
cmd.args(["/F", "/PID", &pid.to_string()]);
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
let ok = cmd.output().map(|o| o.status.success()).unwrap_or(false);
|
||||
if ok {
|
||||
DASH_PID.store(0, Ordering::SeqCst);
|
||||
}
|
||||
ok
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let ok = std::process::Command::new("kill")
|
||||
.args(["-9", &pid.to_string()])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
DASH_PID.store(0, Ordering::SeqCst);
|
||||
}
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 Hermes Dashboard 服务(`hermes dashboard`),idempotent
|
||||
/// 行为:
|
||||
/// 1. 端口已可达 → 直接返回 `started: true, already_running: true`
|
||||
/// 2. 否则 spawn `hermes dashboard`,等最多 90s(首次会 npm build 前端)
|
||||
/// 3. 进程提前退出 → 读日志尾部检测 deps_missing / port_in_use
|
||||
/// 返回 `{ started, kind?, port, pid?, exit_code?, log_tail? }`
|
||||
#[tauri::command]
|
||||
pub async fn hermes_dashboard_start() -> Result<Value, String> {
|
||||
let port = hermes_dashboard_port();
|
||||
let addr_str = format!("127.0.0.1:{port}");
|
||||
let socket_addr: std::net::SocketAddr = addr_str
|
||||
.parse()
|
||||
.map_err(|e| format!("address parse error: {e}"))?;
|
||||
|
||||
// 1. 已运行?
|
||||
if std::net::TcpStream::connect_timeout(&socket_addr, std::time::Duration::from_millis(500))
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(serde_json::json!({
|
||||
"started": true,
|
||||
"already_running": true,
|
||||
"port": port,
|
||||
}));
|
||||
}
|
||||
|
||||
// 2. 清掉残留 PID(来自上一次 spawn)
|
||||
let _ = kill_dashboard_pid();
|
||||
|
||||
let home = hermes_home();
|
||||
let log_path = home.join("dashboard-run.log");
|
||||
let log_file = std::fs::File::create(&log_path)
|
||||
.map_err(|e| format!("创建日志文件失败: {e}"))?;
|
||||
let log_err = log_file
|
||||
.try_clone()
|
||||
.map_err(|e| format!("克隆日志句柄失败: {e}"))?;
|
||||
|
||||
let enhanced = hermes_enhanced_path();
|
||||
let mut cmd = std::process::Command::new("hermes");
|
||||
cmd.args(["dashboard"])
|
||||
.current_dir(&home)
|
||||
.env("PATH", &enhanced)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(log_file)
|
||||
.stderr(log_err);
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
// 注入 .env(与 gateway 启动一致)
|
||||
let env_path = home.join(".env");
|
||||
if let Ok(env_content) = std::fs::read_to_string(&env_path) {
|
||||
for line in env_content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, val)) = line.split_once('=') {
|
||||
cmd.env(key.trim(), val.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn hermes dashboard failed: {e}"))?;
|
||||
let pid = child.id();
|
||||
DASH_PID.store(pid, Ordering::SeqCst);
|
||||
|
||||
// 3. 等待 - 端口起来 / 进程提前死 / 超时
|
||||
// 90s 是为了覆盖首次启动的 npm build(dashboard 文档说前端没构建会 auto build on first launch)
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(90);
|
||||
while std::time::Instant::now() < deadline {
|
||||
// 进程提前退出?
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
DASH_PID.store(0, Ordering::SeqCst);
|
||||
let log_raw = std::fs::read_to_string(&log_path).unwrap_or_default();
|
||||
let tail = log_raw
|
||||
.lines()
|
||||
.rev()
|
||||
.take(40)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let lower = log_raw.to_lowercase();
|
||||
let kind = if lower.contains("web ui dependencies not installed")
|
||||
|| lower.contains("no module named 'fastapi'")
|
||||
|| (lower.contains("import error") && lower.contains("fastapi"))
|
||||
{
|
||||
"deps_missing"
|
||||
} else if lower.contains("no module named 'fcntl'")
|
||||
|| lower.contains("no module named 'termios'")
|
||||
|| lower.contains("no module named 'pty'")
|
||||
|| lower.contains("no module named 'tty'")
|
||||
|| lower.contains("no module named 'pwd'")
|
||||
|| lower.contains("no module named 'grp'")
|
||||
{
|
||||
// Hermes 在 pty_bridge.py / memory_tool.py 等处无条件 import POSIX-only
|
||||
// 标准库(fcntl/termios/pty/tty/pwd/grp),Windows 上根本不存在
|
||||
// 上游 issue:https://github.com/NousResearch/hermes-agent/issues/5246
|
||||
"posix_only_module"
|
||||
} else if lower.contains("address already in use")
|
||||
|| lower.contains("address in use")
|
||||
|| (lower.contains("port") && lower.contains("already in use"))
|
||||
{
|
||||
"port_in_use"
|
||||
} else {
|
||||
"spawn_failed"
|
||||
};
|
||||
return Ok(serde_json::json!({
|
||||
"started": false,
|
||||
"kind": kind,
|
||||
"exit_code": status.code(),
|
||||
"port": port,
|
||||
"log_tail": tail,
|
||||
}));
|
||||
}
|
||||
Ok(None) => {
|
||||
// 还活着,探端口
|
||||
if std::net::TcpStream::connect_timeout(
|
||||
&socket_addr,
|
||||
std::time::Duration::from_millis(300),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
// PID 仍记录在 DASH_PID,供后续 stop 使用
|
||||
return Ok(serde_json::json!({
|
||||
"started": true,
|
||||
"already_running": false,
|
||||
"port": port,
|
||||
"pid": pid,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// try_wait 异常:异常本身罕见,先记录并跳出
|
||||
let log_raw = std::fs::read_to_string(&log_path).unwrap_or_default();
|
||||
let tail = log_raw
|
||||
.lines()
|
||||
.rev()
|
||||
.take(40)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
return Ok(serde_json::json!({
|
||||
"started": false,
|
||||
"kind": "spawn_failed",
|
||||
"port": port,
|
||||
"log_tail": tail,
|
||||
"error": format!("try_wait error: {e}"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
// 4. 超时(进程还活着但端口没起来;常见于首次构建超过 90s)
|
||||
let log_raw = std::fs::read_to_string(&log_path).unwrap_or_default();
|
||||
let tail = log_raw
|
||||
.lines()
|
||||
.rev()
|
||||
.take(40)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(serde_json::json!({
|
||||
"started": false,
|
||||
"kind": "timeout",
|
||||
"port": port,
|
||||
"pid": pid,
|
||||
"log_tail": tail,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 停止我们 spawn 的 Dashboard 进程
|
||||
#[tauri::command]
|
||||
pub async fn hermes_dashboard_stop() -> Result<bool, String> {
|
||||
Ok(kill_dashboard_pid())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// install_hermes — 一键安装(下载 uv → uv tool install hermes-agent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -263,6 +263,9 @@ pub fn run() {
|
||||
hermes::hermes_dashboard_theme_set,
|
||||
hermes::hermes_dashboard_plugins,
|
||||
hermes::hermes_dashboard_plugins_rescan,
|
||||
hermes::hermes_dashboard_probe,
|
||||
hermes::hermes_dashboard_start,
|
||||
hermes::hermes_dashboard_stop,
|
||||
hermes::hermes_toolsets_list,
|
||||
hermes::hermes_cron_jobs_list,
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user