chore: release v0.15.1

This commit is contained in:
晴天
2026-05-10 21:30:36 +08:00
parent 2a547e9603
commit 81c42dbfe2
30 changed files with 1525 additions and 373 deletions

View File

@@ -1,13 +1,9 @@
//! Hermes Agent 安装与管理命令
//!
//! 通过 uv (Astral) 实现零依赖安装:
//! 通过 uv 实现零依赖安装:
//! 1. 下载 uv 单文件二进制
//! 2. uv tool install hermes-agent --python 3.11
//! 3. 写入 ~/.hermes/config.yaml + .env
//!
//! 参考:
//! - uv docs: https://docs.astral.sh/uv/
//! - Hermes 官方安装: https://hermes-agent.nousresearch.com/docs/getting-started/installation/
use serde_json::Value;
use std::path::PathBuf;
@@ -652,7 +648,7 @@ pub fn check_python() -> Result<Value, String> {
};
result.insert("hasUv".into(), Value::Bool(has_uv));
// 检测 git(从 GitHub 安装 hermes-agent 需要 git
// 检测 git
let has_git = run_at_path("git", &["--version"], &enhanced).is_ok();
result.insert("hasGit".into(), Value::Bool(has_git));
@@ -914,22 +910,59 @@ fn hermes_dashboard_port() -> u16 {
9119 // Hermes Dashboard 默认端口
}
fn hermes_dashboard_cli_status(port: u16) -> Option<(bool, String)> {
let output = run_silent("hermes", &["dashboard", "--status"])
.or_else(|_| run_silent("hermes", &["dashboard", "status"]))
.ok()?;
let lower = output.to_ascii_lowercase();
if lower.contains("not running")
|| lower.contains("stopped")
|| lower.contains("inactive")
|| lower.contains("no dashboard")
{
return Some((false, output));
}
if lower.contains("running")
|| lower.contains("listening")
|| lower.contains("http://")
|| lower.contains("https://")
|| lower.contains(&port.to_string())
{
return Some((true, output));
}
None
}
fn hermes_dashboard_tcp_running(port: u16, timeout_ms: u64) -> bool {
let addr = format!("127.0.0.1:{port}");
let Ok(socket_addr) = addr.parse::<std::net::SocketAddr>() else {
return false;
};
std::net::TcpStream::connect_timeout(&socket_addr, std::time::Duration::from_millis(timeout_ms))
.is_ok()
}
fn hermes_dashboard_cli_stop() -> bool {
run_silent("hermes", &["dashboard", "--stop"])
.or_else(|_| run_silent("hermes", &["dashboard", "stop"]))
.is_ok()
}
/// 探测 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 cli_status = hermes_dashboard_cli_status(port);
let cli_running = cli_status.as_ref().map(|(running, _)| *running);
let cli_output = cli_status.as_ref().map(|(_, output)| output.clone());
let running = tokio::task::spawn_blocking(move || {
std::net::TcpStream::connect_timeout(&socket_addr, std::time::Duration::from_millis(800))
.is_ok()
let tcp_running = hermes_dashboard_tcp_running(port, 800);
tcp_running || cli_running.unwrap_or(false)
})
.await
.unwrap_or(false);
Ok(serde_json::json!({ "running": running, "port": port }))
Ok(serde_json::json!({ "running": running, "port": port, "status": cli_output }))
}
/// 我们 spawn 的 Dashboard 进程 PID0 = 没有)
@@ -975,14 +1008,11 @@ fn kill_dashboard_pid() -> bool {
#[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()
if hermes_dashboard_tcp_running(port, 500)
|| hermes_dashboard_cli_status(port)
.map(|(running, _)| running)
.unwrap_or(false)
{
return Ok(serde_json::json!({
"started": true,
@@ -1057,17 +1087,6 @@ pub async fn hermes_dashboard_start() -> Result<Value, String> {
|| (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"))
@@ -1086,12 +1105,7 @@ pub async fn hermes_dashboard_start() -> Result<Value, String> {
}
Ok(None) => {
// 还活着,探端口
if std::net::TcpStream::connect_timeout(
&socket_addr,
std::time::Duration::from_millis(300),
)
.is_ok()
{
if hermes_dashboard_tcp_running(port, 300) {
// PID 仍记录在 DASH_PID供后续 stop 使用
return Ok(serde_json::json!({
"started": true,
@@ -1148,7 +1162,21 @@ pub async fn hermes_dashboard_start() -> Result<Value, String> {
/// 停止我们 spawn 的 Dashboard 进程
#[tauri::command]
pub async fn hermes_dashboard_stop() -> Result<bool, String> {
Ok(kill_dashboard_pid())
let port = hermes_dashboard_port();
let cli_stopped = tokio::task::spawn_blocking(hermes_dashboard_cli_stop)
.await
.unwrap_or(false);
let pid_stopped = kill_dashboard_pid();
if cli_stopped || pid_stopped {
for _ in 0..20 {
if !hermes_dashboard_tcp_running(port, 200) {
return Ok(true);
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
return Ok(true);
}
Ok(false)
}
// ---------------------------------------------------------------------------
@@ -1348,10 +1376,24 @@ fn extract_uv_tar_gz(data: &[u8], dest: &std::path::Path) -> Result<(), String>
Err("tar.gz 中未找到 uv".into())
}
/// Hermes Agent 的 GitHub 仓库地址(不在 PyPI 上发布,只能从 GitHub 安装)
const HERMES_GIT_URL: &str = "git+https://github.com/NousResearch/hermes-agent.git";
/// 通过 uv tool install 安装 Hermes Agent从 GitHub
fn sanitize_hermes_install_output(text: &str) -> String {
let mut out = text.replace(HERMES_GIT_URL, "hermes-agent");
out = out.replace(
"https://github.com/NousResearch/hermes-agent.git",
"hermes-agent",
);
out = out.replace(
"https://github.com/NousResearch/hermes-agent",
"hermes-agent",
);
out = out.replace("github.com/NousResearch/hermes-agent.git", "hermes-agent");
out = out.replace("github.com/NousResearch/hermes-agent", "hermes-agent");
out.replace("NousResearch/hermes-agent", "hermes-agent")
}
/// 通过 uv tool install 安装 Hermes Agent
async fn install_via_uv_tool(
app: &tauri::AppHandle,
uv_path: &str,
@@ -1359,12 +1401,11 @@ async fn install_via_uv_tool(
) -> Result<(), String> {
let _ = app.emit(
"hermes-install-log",
"📦 通过 uv tool install 从 GitHub 安装 Hermes Agent...",
"📦 通过 uv tool install 安装 Hermes Agent...",
);
let _ = app.emit("hermes-install-progress", 25u32);
// 构造包名PEP 508 格式: "pkg[extras] @ git+url"
// hermes-agent 未发布到 PyPI必须从 GitHub 安装
// 构造安装规格
let pkg = if extras.is_empty() {
format!("hermes-agent @ {}", HERMES_GIT_URL)
} else {
@@ -1396,7 +1437,7 @@ async fn install_via_uv_tool(
let _ = app.emit(
"hermes-install-log",
format!("> uv tool install \"{}\" --python 3.11", pkg),
"uv tool install hermes-agent --python 3.11",
);
let child = cmd.spawn().map_err(|e| format!("启动安装进程失败: {e}"))?;
@@ -1411,7 +1452,10 @@ async fn install_via_uv_tool(
// 逐行输出日志
for line in stdout.lines().chain(stderr.lines()) {
if !line.trim().is_empty() {
let _ = app.emit("hermes-install-log", line.trim());
let _ = app.emit(
"hermes-install-log",
sanitize_hermes_install_output(line.trim()),
);
}
}
@@ -1428,7 +1472,7 @@ async fn install_via_uv_tool(
Err(format!(
"安装失败 (exit {}): {}",
output.status.code().unwrap_or(-1),
stderr.trim()
sanitize_hermes_install_output(stderr.trim())
))
}
}
@@ -1470,13 +1514,13 @@ async fn install_via_uv_pip(
let _ = app.emit("hermes-install-log", "✓ Python 虚拟环境创建完成");
let _ = app.emit("hermes-install-progress", 40u32);
// pip install(从 GitHub
// pip install
let pkg = if extras.is_empty() {
format!("hermes-agent @ {}", HERMES_GIT_URL)
} else {
format!("hermes-agent[{}] @ {}", extras.join(","), HERMES_GIT_URL)
};
let _ = app.emit("hermes-install-log", format!("> uv pip install \"{pkg}\""));
let _ = app.emit("hermes-install-log", "> uv pip install hermes-agent");
let mut pip_cmd = tokio::process::Command::new(uv_path);
pip_cmd.args(["pip", "install", &pkg]);
@@ -1498,12 +1542,18 @@ async fn install_via_uv_pip(
let stderr = String::from_utf8_lossy(&pip_out.stderr);
for line in stdout.lines().chain(stderr.lines()) {
if !line.trim().is_empty() {
let _ = app.emit("hermes-install-log", line.trim());
let _ = app.emit(
"hermes-install-log",
sanitize_hermes_install_output(line.trim()),
);
}
}
if !pip_out.status.success() {
return Err(format!("pip install 失败: {}", stderr.trim()));
return Err(format!(
"pip install 失败: {}",
sanitize_hermes_install_output(stderr.trim())
));
}
let _ = app.emit("hermes-install-log", "✓ pip install 完成");
@@ -1575,8 +1625,8 @@ pub async fn configure_hermes(
}
// ---- Provider-aware key routing ----
// ClawPanel 使用 HERMES_PROVIDER_REGISTRY (22 providers) 决定 .env key 名和
// config.yaml 的 model.provider 字段。详见 hermes_providers.rs 的文档。
// ClawPanel 根据内置 provider registry 决定 .env key 名和
// config.yaml 的 model.provider 字段。
use super::hermes_providers;
let pcfg = hermes_providers::get_provider(&provider);
@@ -1599,8 +1649,8 @@ pub async fn configure_hermes(
Some(url) if !url.trim().is_empty() => format!(" base_url: {}\n", url.trim()),
_ => String::new(),
};
// Provider 字段Hermes v0.14+ 的 model_switch 依赖该字段决定 env_var
// `custom` 不写 provider 行,让 Hermes 从 base_url 自动推断。
// Provider 字段用于稳定选择凭证来源
// `custom` 不写 provider 行,让 Hermes Agent 从 base_url 自动推断。
let provider_line = if provider == "custom" || provider.is_empty() {
String::new()
} else {
@@ -2734,7 +2784,6 @@ pub async fn update_hermes(app: tauri::AppHandle) -> Result<String, String> {
"uv".into()
};
// hermes-agent 从 GitHub 安装upgrade 不可用,改用 reinstall
let pkg = format!("hermes-agent[web] @ {}", HERMES_GIT_URL);
let mut cmd = tokio::process::Command::new(&uv);
cmd.args([
@@ -2750,7 +2799,7 @@ pub async fn update_hermes(app: tauri::AppHandle) -> Result<String, String> {
let _ = app.emit("hermes-install-progress", 20u32);
let _ = app.emit(
"hermes-install-log",
format!("> uv tool install --reinstall \"{pkg}\" --python 3.11 --with croniter"),
"uv tool install --reinstall hermes-agent --python 3.11 --with croniter",
);
cmd.env("GIT_TERMINAL_PROMPT", "0");
if let Some(mirror) = pypi_mirror_url() {
@@ -2767,7 +2816,10 @@ pub async fn update_hermes(app: tauri::AppHandle) -> Result<String, String> {
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stdout.lines().chain(stderr.lines()) {
if !line.trim().is_empty() {
let _ = app.emit("hermes-install-log", line.trim());
let _ = app.emit(
"hermes-install-log",
sanitize_hermes_install_output(line.trim()),
);
}
}
@@ -2776,7 +2828,10 @@ pub async fn update_hermes(app: tauri::AppHandle) -> Result<String, String> {
let _ = app.emit("hermes-install-progress", 100u32);
Ok("升级完成".into())
} else {
Err(format!("升级失败: {}", stderr.trim()))
Err(format!(
"升级失败: {}",
sanitize_hermes_install_output(stderr.trim())
))
}
}
@@ -2957,9 +3012,347 @@ pub async fn hermes_api_proxy(
}
// ---------------------------------------------------------------------------
// hermes_agent_run — 通过 /v1/runs + SSE 事件流驱动 Agent工具调用可见
// hermes_agent_run — streaming compatibility layer for Hermes Agent
// ---------------------------------------------------------------------------
fn hermes_response_text(value: &Value) -> String {
let response = value.get("response").unwrap_or(value);
if let Some(text) = response.get("output_text").and_then(|v| v.as_str()) {
return text.to_string();
}
if let Some(text) = response.get("text").and_then(|v| v.as_str()) {
return text.to_string();
}
let mut out = String::new();
if let Some(items) = response.get("output").and_then(|v| v.as_array()) {
for item in items {
if let Some(parts) = item.get("content").and_then(|v| v.as_array()) {
for part in parts {
let kind = part.get("type").and_then(|v| v.as_str()).unwrap_or("");
if matches!(kind, "output_text" | "text") {
if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
out.push_str(text);
}
}
}
}
}
}
out
}
fn hermes_response_delta(evt: &Value) -> String {
evt.get("delta")
.and_then(|v| v.as_str())
.or_else(|| evt.get("text").and_then(|v| v.as_str()))
.or_else(|| evt.get("content").and_then(|v| v.as_str()))
.or_else(|| {
evt.get("delta")
.and_then(|v| v.get("text"))
.and_then(|v| v.as_str())
})
.or_else(|| {
evt.get("delta")
.and_then(|v| v.get("value"))
.and_then(|v| v.as_str())
})
.unwrap_or("")
.to_string()
}
fn normalize_hermes_stream_event(
evt: &Value,
run_id: &str,
session_id: Option<&str>,
) -> Option<Value> {
let event_type = evt
.get("event")
.and_then(|v| v.as_str())
.or_else(|| evt.get("type").and_then(|v| v.as_str()))
.unwrap_or("");
if event_type.is_empty() {
return None;
}
let sid = session_id
.map(|s| Value::String(s.to_string()))
.unwrap_or(Value::Null);
match event_type {
"message.delta" | "run.completed" | "run.failed" | "tool.started" | "tool.completed"
| "tool.progress" | "tool.error" => {
let mut out = evt.clone();
if out.get("run_id").is_none() {
out["run_id"] = Value::String(run_id.to_string());
}
if out.get("session_id").is_none() {
out["session_id"] = sid;
}
Some(out)
}
"response.output_text.delta" | "response.text.delta" => {
let delta = hermes_response_delta(evt);
if delta.is_empty() {
None
} else {
Some(serde_json::json!({
"event": "message.delta",
"run_id": run_id,
"session_id": sid,
"delta": delta,
}))
}
}
"response.output_item.added" => {
let item = evt
.get("item")
.or_else(|| evt.get("output_item"))
.unwrap_or(&Value::Null);
let kind = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
if !matches!(kind, "function_call" | "tool_call") {
return None;
}
let tool = item
.get("name")
.and_then(|v| v.as_str())
.or_else(|| {
item.get("function")
.and_then(|v| v.get("name"))
.and_then(|v| v.as_str())
})
.unwrap_or("tool");
Some(serde_json::json!({
"event": "tool.started",
"run_id": run_id,
"session_id": sid,
"tool": tool,
"input": item.get("arguments").or_else(|| item.get("input")).cloned().unwrap_or(Value::Null),
}))
}
"response.function_call_arguments.delta" => Some(serde_json::json!({
"event": "tool.progress",
"run_id": run_id,
"session_id": sid,
"tool": evt.get("name").and_then(|v| v.as_str()).unwrap_or("tool"),
"preview": hermes_response_delta(evt),
})),
"response.output_item.done" | "response.function_call_arguments.done" => {
let item = evt
.get("item")
.or_else(|| evt.get("output_item"))
.unwrap_or(&Value::Null);
let kind = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
if event_type == "response.output_item.done"
&& !matches!(kind, "function_call" | "tool_call")
{
return None;
}
Some(serde_json::json!({
"event": "tool.completed",
"run_id": run_id,
"session_id": sid,
"tool": item.get("name").and_then(|v| v.as_str()).or_else(|| evt.get("name").and_then(|v| v.as_str())).unwrap_or("tool"),
"input": item.get("arguments").or_else(|| evt.get("arguments")).cloned().unwrap_or(Value::Null),
}))
}
"response.completed" => Some(serde_json::json!({
"event": "run.completed",
"run_id": run_id,
"session_id": sid,
"output": hermes_response_text(evt),
})),
"response.failed" | "response.error" => Some(serde_json::json!({
"event": "run.failed",
"run_id": run_id,
"session_id": sid,
"error": evt.get("error").and_then(|v| v.get("message")).and_then(|v| v.as_str())
.or_else(|| evt.get("error").and_then(|v| v.as_str()))
.or_else(|| evt.get("message").and_then(|v| v.as_str()))
.unwrap_or("unknown error"),
})),
_ => {
let mut out = evt.clone();
out["event"] = Value::String(event_type.to_string());
if out.get("run_id").is_none() {
out["run_id"] = Value::String(run_id.to_string());
}
if out.get("session_id").is_none() {
out["session_id"] = sid;
}
Some(out)
}
}
}
fn emit_hermes_stream_event(
app: &tauri::AppHandle,
evt: Value,
run_id: &str,
final_output: &mut String,
) -> Result<bool, String> {
let event_type = evt["event"].as_str().unwrap_or("");
match event_type {
"message.delta" => {
if let Some(delta) = evt["delta"].as_str() {
final_output.push_str(delta);
let _ = app.emit(
"hermes-run-delta",
serde_json::json!({
"run_id": run_id,
"delta": delta,
}),
);
}
}
"tool.started" | "tool.completed" | "tool.progress" | "tool.error" => {
let _ = app.emit("hermes-run-tool", evt.clone());
}
"reasoning.available" => {
let _ = app.emit("hermes-run-reasoning", evt.clone());
}
"run.completed" => {
if let Some(output) = evt["output"].as_str() {
if !output.is_empty() {
*final_output = output.to_string();
}
}
let _ = app.emit(
"hermes-run-done",
serde_json::json!({
"run_id": run_id,
"output": final_output.as_str(),
}),
);
return Ok(true);
}
"run.failed" => {
let err = evt["error"].as_str().unwrap_or("unknown error");
let _ = app.emit(
"hermes-run-error",
serde_json::json!({
"run_id": run_id,
"error": err,
}),
);
return Err(format!("Agent run failed: {err}"));
}
_ => {
let _ = app.emit("hermes-run-event", evt.clone());
}
}
Ok(false)
}
async fn try_hermes_responses_run(
app: &tauri::AppHandle,
gw_url: &str,
api_key: &str,
payload: &Value,
session_id: Option<&str>,
) -> Result<Option<String>, String> {
let client = hermes_gateway_http_client(std::time::Duration::from_secs(300))
.map_err(|e| format!("HTTP 客户端创建失败: {e}"))?;
let mut response_payload = payload.clone();
response_payload["stream"] = Value::Bool(true);
let mut req = client
.post(format!("{gw_url}/v1/responses"))
.header("Content-Type", "application/json")
.body(response_payload.to_string());
if !api_key.is_empty() {
req = req.header("Authorization", format!("Bearer {api_key}"));
}
let resp = match req.send().await {
Ok(resp) => resp,
Err(_) => return Ok(None),
};
let status = resp.status();
if !status.is_success() {
if status.as_u16() == 401 || status.as_u16() == 403 {
let text = resp.text().await.unwrap_or_default();
return Err(format!("HTTP {}: {text}", status.as_u16()));
}
return Ok(None);
}
let run_id = resp
.headers()
.get("x-request-id")
.or_else(|| resp.headers().get("x-response-id"))
.and_then(|v| v.to_str().ok())
.map(String::from)
.unwrap_or_else(|| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or_default();
format!("response-{now}")
});
let _ = app.emit(
"hermes-run-started",
serde_json::json!({ "run_id": &run_id }),
);
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
if content_type.contains("application/json") {
let body: Value = resp.json().await.unwrap_or(Value::Null);
let output = hermes_response_text(&body);
let _ = app.emit(
"hermes-run-done",
serde_json::json!({
"run_id": &run_id,
"output": output,
}),
);
return Ok(Some(run_id));
}
use futures_util::StreamExt;
let mut stream = resp.bytes_stream();
let mut buffer = String::new();
let mut final_output = String::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("SSE 读取失败: {e}"))?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(newline_pos) = buffer.find('\n') {
let line = buffer[..newline_pos].trim().to_string();
buffer = buffer[newline_pos + 1..].to_string();
let data = if let Some(rest) = line.strip_prefix("data:") {
rest.trim()
} else if line.starts_with('{') {
line.as_str()
} else {
continue;
};
if data.is_empty() || data == "[DONE]" {
let _ = app.emit(
"hermes-run-done",
serde_json::json!({
"run_id": &run_id,
"output": &final_output,
}),
);
return Ok(Some(run_id));
}
if let Ok(evt) = serde_json::from_str::<Value>(data) {
if let Some(normalized) = normalize_hermes_stream_event(&evt, &run_id, session_id) {
if emit_hermes_stream_event(app, normalized, &run_id, &mut final_output)? {
return Ok(Some(run_id));
}
}
}
}
}
let _ = app.emit(
"hermes-run-done",
serde_json::json!({
"run_id": &run_id,
"output": &final_output,
}),
);
Ok(Some(run_id))
}
#[tauri::command]
pub async fn hermes_agent_run(
app: tauri::AppHandle,
@@ -3001,6 +3394,12 @@ pub async fn hermes_agent_run(
payload["instructions"] = Value::String(inst.clone());
}
if let Some(response_run_id) =
try_hermes_responses_run(&app, &gw_url, &api_key, &payload, session_id.as_deref()).await?
{
return Ok(response_run_id);
}
let client = hermes_gateway_http_client(std::time::Duration::from_secs(10))
.map_err(|e| format!("HTTP 客户端创建失败: {e}"))?;
@@ -3071,7 +3470,6 @@ pub async fn hermes_agent_run(
return Err(format!("SSE HTTP {status}: {text}"));
}
// 流式读取 SSE 事件并转发到前端
use futures_util::StreamExt;
let mut stream = sse_resp.bytes_stream();
let mut buffer = String::new();
@@ -3086,11 +3484,14 @@ pub async fn hermes_agent_run(
buffer = buffer[newline_pos + 1..].to_string();
let trimmed = line.trim();
if !trimmed.starts_with("data: ") {
let data = if let Some(rest) = trimmed.strip_prefix("data:") {
rest.trim()
} else if trimmed.starts_with('{') {
trimmed
} else {
continue;
}
let data = trimmed[6..].trim();
if data == "[DONE]" {
};
if data.is_empty() || data == "[DONE]" {
let _ = app.emit(
"hermes-run-done",
serde_json::json!({
@@ -3102,54 +3503,12 @@ pub async fn hermes_agent_run(
}
if let Ok(evt) = serde_json::from_str::<Value>(data) {
let event_type = evt["event"].as_str().unwrap_or("");
match event_type {
"message.delta" => {
if let Some(delta) = evt["delta"].as_str() {
final_output.push_str(delta);
let _ = app.emit(
"hermes-run-delta",
serde_json::json!({
"run_id": &run_id,
"delta": delta,
}),
);
}
}
"tool.started" | "tool.completed" | "tool.progress" | "tool.error" => {
let _ = app.emit("hermes-run-tool", evt.clone());
}
"reasoning.available" => {
let _ = app.emit("hermes-run-reasoning", evt.clone());
}
"run.completed" => {
if let Some(output) = evt["output"].as_str() {
final_output = output.to_string();
}
let _ = app.emit(
"hermes-run-done",
serde_json::json!({
"run_id": &run_id,
"output": &final_output,
}),
);
if let Some(normalized) =
normalize_hermes_stream_event(&evt, &run_id, session_id.as_deref())
{
if emit_hermes_stream_event(&app, normalized, &run_id, &mut final_output)? {
return Ok(run_id);
}
"run.failed" => {
let err = evt["error"].as_str().unwrap_or("unknown error");
let _ = app.emit(
"hermes-run-error",
serde_json::json!({
"run_id": &run_id,
"error": err,
}),
);
return Err(format!("Agent run failed: {err}"));
}
_ => {
// 其他事件类型也转发
let _ = app.emit("hermes-run-event", evt.clone());
}
}
}
}
@@ -3819,9 +4178,7 @@ fn parse_log_line(line: &str) -> ParsedLogLine {
}
/// Extract the first `# Heading` or the first long prose line from Markdown,
/// used as a skill's canonical name/description. Mirrors hermes-web-ui's
/// `extractDescription()` behaviour — first non-empty/non-heading line,
/// truncated to 200 chars.
/// used as a skill's canonical name/description.
fn md_first_heading(content: &str) -> Option<String> {
content
.lines()
@@ -3847,7 +4204,7 @@ fn md_first_description(content: &str) -> String {
/// Read `config.yaml` and return the list of `skills.disabled` entries.
/// Gracefully handles missing file / missing section → empty list.
///
/// The disable mechanism matches upstream `hermes-web-ui`:
/// The disable mechanism uses the `skills.disabled` list:
///
/// ```yaml
/// skills:
@@ -3900,9 +4257,7 @@ fn read_disabled_skills() -> Vec<String> {
disabled
}
/// Shape returned to the frontend — kept compatible with the previous
/// version (file/name/description/path) while adding `enabled` and the
/// optional `isDir`/`category` fields that `hermes-web-ui` also uses.
/// Shape returned to the frontend.
#[tauri::command]
pub async fn hermes_skills_list() -> Result<Value, String> {
let skills_dir = hermes_home().join("skills");
@@ -3948,7 +4303,7 @@ pub async fn hermes_skills_list() -> Result<Value, String> {
Err(_) => continue,
};
// v0.14.1 structured skill: <category>/<skill>/SKILL.md
// Structured skill: <category>/<skill>/SKILL.md
if ftype.is_dir() {
let skill_md = fpath.join("SKILL.md");
if !skill_md.exists() {
@@ -4055,8 +4410,7 @@ pub async fn hermes_skill_detail(file_path: String) -> Result<String, String> {
// ============================================================================
/// Toggle a skill's enabled state by mutating `config.yaml`'s
/// `skills.disabled` list. Matches the behaviour of hermes-web-ui's
/// `PUT /api/hermes/skills/toggle`.
/// `skills.disabled` list.
///
/// * `enabled = true` → remove `name` from disabled list
/// * `enabled = false` → add `name` to disabled list
@@ -4332,8 +4686,7 @@ pub async fn hermes_memory_write(
/// + last-modified UNIX timestamp (seconds) for each. A missing file yields an
/// empty string and `None` mtime — the caller shows "not yet written" state.
///
/// Shape matches `hermes-web-ui`'s `GET /api/hermes/memory` response so the
/// frontend can mirror the official UI's three-column layout.
/// Shape is optimized for the frontend memory layout.
#[tauri::command]
pub async fn hermes_memory_read_all() -> Result<Value, String> {
let mem_dir = hermes_home().join("memories");
@@ -4416,15 +4769,12 @@ pub async fn hermes_logs_download(name: String) -> Result<Value, String> {
}
// ============================================================================
// api_server guardian (Step 5 / G7)
// api_server guardian
//
// ClawPanel's Hermes integration requires `platforms.api_server.enabled: true`
// in ~/.hermes/config.yaml so that `hermes gateway run` exposes the
// /v1/runs endpoint we depend on. The setting is written once by
// `configure_hermes`, but several real-world scenarios can remove it:
// * User upgrades Hermes and the new default config.yaml is merged
// without the api_server platform entry.
// * User manually edits config.yaml (via Hermes CLI or text editor).
// `configure_hermes`, but config changes can remove it.
// * Migration scripts accidentally drop the section.
//
// Rather than silently failing at Gateway start time with an opaque
@@ -4595,7 +4945,7 @@ fn ensure_api_server_enabled(app: &tauri::AppHandle) -> Result<(), String> {
}
// ============================================================================
// .env editor commands (Step 4 / G6)
// .env editor commands
//
// Users may need to set custom environment variables for Hermes (e.g.
// `TAVILY_API_KEY` for the tavily skill, `HTTP_PROXY`, etc.). These keys

View File

@@ -1,21 +1,7 @@
//! Hermes Provider Registry — authoritative catalog of 22 providers supported
//! Hermes Provider Registry — ClawPanel's built-in provider catalog
//! by Hermes Agent, with their auth schemes, env vars, base URLs, and known
//! model catalogs.
//!
//! Source of truth: upstream `hermes-agent` repository
//! - Auth / env vars: `hermes_cli/auth.py::PROVIDER_REGISTRY`
//! - Model catalogs: `hermes_cli/models.py::_PROVIDER_MODELS`
//!
//! Synced from upstream at:
//! - hermes_cli/auth.py (v0.14.x series)
//! - hermes_cli/models.py (v0.14.x series)
//!
//! When syncing a new Hermes release, verify:
//! 1. Each provider's `api_key_env_vars` matches upstream tuple ordering
//! 2. `models` list reflects the latest _PROVIDER_MODELS entries
//! 3. `base_url` mirrors the default inference URL (users can override via
//! `base_url_env_var`)
//!
//! This module is intentionally self-contained: it must NOT depend on any
//! runtime state. The static data is queried by commands in `hermes.rs`
//! and surfaced to the frontend via `hermes_list_providers`.
@@ -26,8 +12,6 @@ use serde::Serialize;
// Data model
// =============================================================================
/// Auth scheme matching upstream `auth.py::ProviderConfig.auth_type`.
///
/// - `api_key`: traditional env-var based key (`<PROVIDER>_API_KEY`, etc.)
/// - `oauth_device_code`: interactive device-code OAuth flow (Nous)
/// - `oauth_external`: OAuth handled by external process (Codex, Qwen)
@@ -36,6 +20,8 @@ pub const AUTH_API_KEY: &str = "api_key";
pub const AUTH_OAUTH_DEVICE: &str = "oauth_device_code";
pub const AUTH_OAUTH_EXTERNAL: &str = "oauth_external";
pub const AUTH_EXTERNAL_PROCESS: &str = "external_process";
pub const AUTH_AWS_SDK: &str = "aws_sdk";
pub const AUTH_OAUTH_MINIMAX: &str = "oauth_minimax";
/// Transport negotiated with the provider.
pub const TRANSPORT_OPENAI_CHAT: &str = "openai_chat";
@@ -56,7 +42,7 @@ pub const PROBE_NONE: &str = "none";
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HermesProvider {
/// Stable identifier (matches upstream PROVIDER_REGISTRY keys).
/// Stable provider identifier.
pub id: &'static str,
/// Human-readable display name.
pub name: &'static str,
@@ -72,7 +58,7 @@ pub struct HermesProvider {
pub transport: &'static str,
/// See PROBE_* constants above.
pub models_probe: &'static str,
/// Known static model list (subset of upstream _PROVIDER_MODELS).
/// Known static model list.
pub models: &'static [&'static str],
/// True for aggregators/routers (OpenRouter, AI Gateway, etc.) — users
/// must explicitly specify a model since there is no sensible default.
@@ -82,7 +68,7 @@ pub struct HermesProvider {
}
// =============================================================================
// Static registry — 22 providers
// Static registry
// =============================================================================
const P_ANTHROPIC: HermesProvider = HermesProvider {
@@ -192,6 +178,26 @@ const P_KIMI_CODING: HermesProvider = HermesProvider {
cli_auth_hint: "",
};
const P_KIMI_CODING_CN: HermesProvider = HermesProvider {
id: "kimi-coding-cn",
name: "Kimi / Moonshot (China)",
auth_type: AUTH_API_KEY,
base_url: "https://api.moonshot.cn/v1",
base_url_env_var: "",
api_key_env_vars: &["KIMI_CN_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"kimi-for-coding",
"kimi-k2.6",
"kimi-k2.5",
"kimi-k2-thinking",
"kimi-k2-turbo-preview",
],
is_aggregator: false,
cli_auth_hint: "",
};
const P_XAI: HermesProvider = HermesProvider {
id: "xai",
name: "xAI",
@@ -252,6 +258,29 @@ const P_MINIMAX_CN: HermesProvider = HermesProvider {
cli_auth_hint: "",
};
const P_MINIMAX_OAUTH: HermesProvider = HermesProvider {
id: "minimax-oauth",
name: "MiniMax (OAuth)",
auth_type: AUTH_OAUTH_MINIMAX,
base_url: "https://api.minimax.io/anthropic",
base_url_env_var: "",
api_key_env_vars: &[],
transport: TRANSPORT_ANTHROPIC,
models_probe: PROBE_NONE,
models: &[
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
"MiniMax-M2.1",
"MiniMax-M2.1-highspeed",
"MiniMax-M2",
"MiniMax-M2-highspeed",
],
is_aggregator: false,
cli_auth_hint: "hermes auth login minimax-oauth",
};
const P_ALIBABA: HermesProvider = HermesProvider {
id: "alibaba",
name: "Alibaba Cloud (DashScope)",
@@ -274,6 +303,25 @@ const P_ALIBABA: HermesProvider = HermesProvider {
cli_auth_hint: "",
};
const P_ALIBABA_CODING_PLAN: HermesProvider = HermesProvider {
id: "alibaba-coding-plan",
name: "Alibaba Cloud (Coding Plan)",
auth_type: AUTH_API_KEY,
base_url: "https://coding-intl.dashscope.aliyuncs.com/v1",
base_url_env_var: "ALIBABA_CODING_PLAN_BASE_URL",
api_key_env_vars: &["ALIBABA_CODING_PLAN_API_KEY", "DASHSCOPE_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[
"qwen3-coder-plus",
"qwen3-coder-next",
"qwen3.5-plus",
"qwen3.5-coder",
],
is_aggregator: false,
cli_auth_hint: "",
};
const P_HUGGINGFACE: HermesProvider = HermesProvider {
id: "huggingface",
name: "Hugging Face",
@@ -311,6 +359,104 @@ const P_XIAOMI: HermesProvider = HermesProvider {
cli_auth_hint: "",
};
const P_ARCEE: HermesProvider = HermesProvider {
id: "arcee",
name: "Arcee AI",
auth_type: AUTH_API_KEY,
base_url: "https://api.arcee.ai/api/v1",
base_url_env_var: "ARCEE_BASE_URL",
api_key_env_vars: &["ARCEEAI_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[],
is_aggregator: false,
cli_auth_hint: "",
};
const P_AZURE_FOUNDRY: HermesProvider = HermesProvider {
id: "azure-foundry",
name: "Azure Foundry",
auth_type: AUTH_API_KEY,
base_url: "",
base_url_env_var: "AZURE_FOUNDRY_BASE_URL",
api_key_env_vars: &["AZURE_FOUNDRY_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[],
is_aggregator: true,
cli_auth_hint: "",
};
const P_BEDROCK: HermesProvider = HermesProvider {
id: "bedrock",
name: "AWS Bedrock",
auth_type: AUTH_AWS_SDK,
base_url: "https://bedrock-runtime.us-east-1.amazonaws.com",
base_url_env_var: "BEDROCK_BASE_URL",
api_key_env_vars: &[],
transport: TRANSPORT_ANTHROPIC,
models_probe: PROBE_NONE,
models: &[],
is_aggregator: false,
cli_auth_hint: "",
};
const P_GMI: HermesProvider = HermesProvider {
id: "gmi",
name: "GMI Cloud",
auth_type: AUTH_API_KEY,
base_url: "https://api.gmi-serving.com/v1",
base_url_env_var: "GMI_BASE_URL",
api_key_env_vars: &["GMI_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[],
is_aggregator: false,
cli_auth_hint: "",
};
const P_LMSTUDIO: HermesProvider = HermesProvider {
id: "lmstudio",
name: "LM Studio",
auth_type: AUTH_API_KEY,
base_url: "http://127.0.0.1:1234/v1",
base_url_env_var: "LM_BASE_URL",
api_key_env_vars: &["LM_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[],
is_aggregator: false,
cli_auth_hint: "",
};
const P_NVIDIA: HermesProvider = HermesProvider {
id: "nvidia",
name: "NVIDIA NIM",
auth_type: AUTH_API_KEY,
base_url: "https://integrate.api.nvidia.com/v1",
base_url_env_var: "NVIDIA_BASE_URL",
api_key_env_vars: &["NVIDIA_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[],
is_aggregator: false,
cli_auth_hint: "",
};
const P_OLLAMA_CLOUD: HermesProvider = HermesProvider {
id: "ollama-cloud",
name: "Ollama Cloud",
auth_type: AUTH_API_KEY,
base_url: "https://ollama.com/v1",
base_url_env_var: "OLLAMA_BASE_URL",
api_key_env_vars: &["OLLAMA_API_KEY"],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_OPENAI,
models: &[],
is_aggregator: false,
cli_auth_hint: "",
};
const P_AI_GATEWAY: HermesProvider = HermesProvider {
id: "ai-gateway",
name: "Vercel AI Gateway",
@@ -506,6 +652,20 @@ const P_QWEN_OAUTH: HermesProvider = HermesProvider {
cli_auth_hint: "hermes auth login qwen-oauth",
};
const P_GOOGLE_GEMINI_CLI: HermesProvider = HermesProvider {
id: "google-gemini-cli",
name: "Google Gemini (OAuth)",
auth_type: AUTH_OAUTH_EXTERNAL,
base_url: "https://generativelanguage.googleapis.com/v1beta/openai",
base_url_env_var: "",
api_key_env_vars: &[],
transport: TRANSPORT_OPENAI_CHAT,
models_probe: PROBE_NONE,
models: &["gemini-2.5-pro", "gemini-2.5-flash"],
is_aggregator: false,
cli_auth_hint: "hermes auth login google-gemini-cli",
};
const P_COPILOT_ACP: HermesProvider = HermesProvider {
id: "copilot-acp",
name: "GitHub Copilot ACP",
@@ -550,13 +710,23 @@ pub const ALL_PROVIDERS: &[HermesProvider] = &[
P_XAI,
P_MINIMAX,
P_HUGGINGFACE,
P_ARCEE,
P_AZURE_FOUNDRY,
P_GMI,
P_LMSTUDIO,
P_NVIDIA,
P_OLLAMA_CLOUD,
P_COPILOT,
// API-key providers — China
P_ZAI,
P_KIMI_CODING,
P_KIMI_CODING_CN,
P_ALIBABA,
P_ALIBABA_CODING_PLAN,
P_MINIMAX_CN,
P_XIAOMI,
// SDK-backed providers
P_BEDROCK,
// Aggregators / routers
P_OPENROUTER,
P_AI_GATEWAY,
@@ -567,6 +737,8 @@ pub const ALL_PROVIDERS: &[HermesProvider] = &[
P_NOUS,
P_OPENAI_CODEX,
P_QWEN_OAUTH,
P_GOOGLE_GEMINI_CLI,
P_MINIMAX_OAUTH,
P_COPILOT_ACP,
// Custom (frontend placeholder)
P_CUSTOM,
@@ -680,9 +852,12 @@ mod tests {
#[test]
fn registry_has_expected_providers() {
assert_eq!(ALL_PROVIDERS.len(), 22);
assert_eq!(ALL_PROVIDERS.len(), 33);
assert!(get_provider("anthropic").is_some());
assert!(get_provider("gemini").is_some());
assert!(get_provider("alibaba-coding-plan").is_some());
assert!(get_provider("bedrock").is_some());
assert!(get_provider("lmstudio").is_some());
assert!(get_provider("nous").is_some());
assert!(get_provider("custom").is_some());
assert!(get_provider("nonexistent").is_none());
@@ -693,6 +868,7 @@ mod tests {
assert_eq!(primary_api_key_env("anthropic"), Some("ANTHROPIC_API_KEY"));
assert_eq!(primary_api_key_env("gemini"), Some("GOOGLE_API_KEY"));
assert_eq!(primary_api_key_env("zai"), Some("GLM_API_KEY"));
assert_eq!(primary_api_key_env("bedrock"), None);
assert_eq!(primary_api_key_env("nous"), None);
}
@@ -704,6 +880,8 @@ mod tests {
assert!(keys.contains(&"GOOGLE_API_KEY"));
assert!(keys.contains(&"GEMINI_API_KEY"));
assert!(keys.contains(&"GEMINI_BASE_URL"));
assert!(keys.contains(&"ALIBABA_CODING_PLAN_API_KEY"));
assert!(keys.contains(&"LM_API_KEY"));
assert!(keys.contains(&"GATEWAY_ALLOW_ALL_USERS"));
assert!(keys.contains(&"API_SERVER_KEY"));
// No duplicates
@@ -737,11 +915,7 @@ mod tests {
#[test]
fn find_provider_by_model_is_unambiguous() {
assert_eq!(find_provider_by_model("deepseek-chat"), Some("deepseek"));
assert_eq!(
find_provider_by_model("kimi-for-coding"),
Some("kimi-coding")
);
// Unknown model
assert_eq!(find_provider_by_model("kimi-for-coding"), None);
assert_eq!(find_provider_by_model("nonexistent"), None);
}
}