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:
晴天
2026-04-25 23:47:22 +08:00
parent 8a314ff64e
commit 9ee99ead24
35 changed files with 2348 additions and 230 deletions

View File

@@ -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)

View File

@@ -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 进程 PID0 = 没有)
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 builddashboard 文档说前端没构建会 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/grpWindows 上根本不存在
// 上游 issuehttps://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
// ---------------------------------------------------------------------------

View File

@@ -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,
])