feat: Hermes Agent 多引擎架构核心代码

- 新增 src/engines/hermes/ 完整引擎(仪表盘/服务管理/模型配置/Agent管理/对话)
- 新增 src/lib/engine-manager.js 引擎管理器(切换/检测/状态)
- 新增 src-tauri/src/commands/hermes.rs 后端命令(Gateway控制/配置读写/Agent Run SSE)
- sidebar 引擎切换器 UI
- i18n 新增 engine 模块(中/英/繁体)
- 多安装清理工具(gateway-ownership.js)
- 晴辰助手文件访问开关
- Hermes 对话工具调用可视化、SSE 流式输出
- Cargo.lock / dev-api.js 同步更新
This commit is contained in:
晴天
2026-04-13 04:09:00 +08:00
parent 32190c8f27
commit 5575566806
36 changed files with 6694 additions and 424 deletions

View File

@@ -3195,10 +3195,12 @@ const handlers = {
const normalizedAccountId = typeof accountId === 'string' ? accountId.trim() : ''
const setRootChannelEntry = (entry) => {
const current = cfg.channels?.[storageKey]
if (current && typeof current === 'object' && current.accounts && typeof current.accounts === 'object') {
entry.accounts = current.accounts
// 合并模式:保留用户通过 CLI 或手动编辑的自定义字段streaming, retry, dmPolicy 等)
if (current && typeof current === 'object') {
cfg.channels[storageKey] = { ...current, ...entry }
} else {
cfg.channels[storageKey] = entry
}
cfg.channels[storageKey] = entry
}
const setAccountChannelEntry = (entry) => {
const current = cfg.channels?.[storageKey] && typeof cfg.channels[storageKey] === 'object'
@@ -3233,7 +3235,6 @@ const handlers = {
if (form.allowedUsers) entry.allowFrom = form.allowedUsers.split(',').map(s => s.trim()).filter(Boolean)
} else if (platform === 'discord') {
entry.token = form.token
entry.groupPolicy = 'allowlist'
if (form.guildId) {
const ck = form.channelId || '*'
entry.guilds = { [form.guildId]: { users: ['*'], requireMention: true, channels: { [ck]: { allow: true, requireMention: true } } } }
@@ -3261,7 +3262,18 @@ const handlers = {
}
if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector') {
cfg.channels[storageKey] = entry
// 合并模式:保留用户通过 CLI 或手动编辑的自定义字段
const existing = cfg.channels[storageKey]
cfg.channels[storageKey] = (existing && typeof existing === 'object')
? { ...existing, ...entry }
: entry
// Discord: 仅在首次创建时设置默认值,不覆盖用户已有的设置
if (platform === 'discord') {
const d = cfg.channels[storageKey]
if (!d.groupPolicy) d.groupPolicy = 'allowlist'
if (!d.dm) d.dm = { enabled: false }
if (!d.retry) d.retry = { attempts: 3, minDelayMs: 500, maxDelayMs: 30000, jitter: 0.1 }
}
}
writeOpenclawConfigFile(cfg)
@@ -3437,7 +3449,7 @@ const handlers = {
if (cfg.plugins.entries[pid]) cfg.plugins.entries[pid].enabled = false
}
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8')
writeOpenclawConfigFile(cfg)
return { ok: true, enabled, pluginId: pid }
},

69
src-tauri/Cargo.lock generated
View File

@@ -351,12 +351,13 @@ dependencies = [
[[package]]
name = "clawpanel"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"base64 0.22.1",
"chrono",
"dirs 6.0.0",
"ed25519-dalek",
"flate2",
"futures-util",
"rand 0.8.5",
"regex",
@@ -364,6 +365,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"tar",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
@@ -891,6 +893,17 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1898,8 +1911,15 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags 2.11.0",
"libc",
"redox_syscall 0.7.4",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.1"
@@ -2405,7 +2425,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link 0.2.1",
]
@@ -2944,6 +2964,15 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_syscall"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_users"
version = "0.4.6"
@@ -3119,6 +3148,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
@@ -3553,7 +3595,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"redox_syscall",
"redox_syscall 0.5.18",
"tracing",
"wasm-bindgen",
"web-sys",
@@ -3756,6 +3798,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tar"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -5440,6 +5493,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.1"

View File

@@ -1339,10 +1339,8 @@ pub fn calibrate_openclaw_config(mode: String) -> Result<Value, String> {
///
/// Issue #127: 修复配置合并时丢失 browser.* 等合法字段的问题
///
/// 保留的字段:
/// - `browser.*` - OpenClaw browser profiles
/// - `agents.list` - OpenClaw agent list
/// - 其他 OpenClaw schema 定义的字段
/// 策略:对所有顶级 Object 类型字段做浅合并(新值覆盖旧值,旧值中新配置没有的字段保留)。
/// 这样用户通过 CLI / 手动编辑添加的自定义子字段不会被前端的部分配置所覆盖掉。
///
/// 清理的字段:
/// - UI 专属字段(通过 strip_ui_fields 处理)
@@ -1354,27 +1352,22 @@ fn merge_configs_preserving_fields(existing: &Value, new: &Value) -> Value {
let mut merged = existing_obj.clone();
for (key, new_value) in new_obj {
if key == "browser" || key == "agents" {
// 保留现有配置中的 browser 和 agents
// 如果新配置有对应的值且是对象,进行深度合并
if let Some(existing_value) = existing_obj.get(key) {
if let (Value::Object(existing_sub), Value::Object(new_sub)) =
(existing_value, new_value)
{
let mut sub_merged = existing_sub.clone();
for (sub_key, sub_value) in new_sub {
sub_merged.insert(sub_key.clone(), sub_value.clone());
}
merged.insert(key.clone(), Value::Object(sub_merged));
} else {
// 新值不是对象,直接使用新值
merged.insert(key.clone(), new_value.clone());
if let Some(existing_value) = existing_obj.get(key) {
if let (Value::Object(existing_sub), Value::Object(new_sub)) =
(existing_value, new_value)
{
// 两边都是对象:浅合并(新值覆盖,旧值保留未覆盖的 key
let mut sub_merged = existing_sub.clone();
for (sub_key, sub_value) in new_sub {
sub_merged.insert(sub_key.clone(), sub_value.clone());
}
merged.insert(key.clone(), Value::Object(sub_merged));
} else {
// 类型不同或不是对象,直接使用新值
merged.insert(key.clone(), new_value.clone());
}
} else {
// 其他字段直接使用新配置的
// 现有配置没有此 key使用新
merged.insert(key.clone(), new_value.clone());
}
}
@@ -1703,37 +1696,9 @@ fn sync_providers_to_agent_models(config: &Value) {
}
}
}
// 清理已删除的 models
if let Some(dst_models) =
dst_obj.get_mut("models").and_then(|m| m.as_array_mut())
{
let src_model_ids: std::collections::HashSet<String> = src_provider
.get("models")
.and_then(|m| m.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| {
m.get("id")
.and_then(|v| v.as_str())
.or_else(|| m.as_str())
.map(|s| s.to_string())
})
.collect()
})
.unwrap_or_default();
let before = dst_models.len();
dst_models.retain(|m| {
let id = m
.get("id")
.and_then(|v| v.as_str())
.or_else(|| m.as_str())
.unwrap_or("");
src_model_ids.contains(id)
});
if dst_models.len() != before {
changed = true;
}
}
// 注意:不删除 agent models.json 中用户手动添加的模型。
// 只同步连接信息baseUrl/apiKey/api保留用户通过 CLI
// 或手动编辑添加的自定义模型。
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -120,6 +120,25 @@ fn put_csv_array_from_form(entry: &mut Map<String, Value>, key: &str, raw: &str)
}
}
/// 合并渠道配置:将新的表单字段覆盖到现有配置上,保留用户通过 CLI 或手动编辑的自定义字段。
/// 例如用户手动添加的 streaming / retry / dmPolicy 等不会被丢弃。
fn merge_channel_entry(
channels_map: &mut Map<String, Value>,
key: &str,
new_entry: Map<String, Value>,
) {
let merged = if let Some(Value::Object(existing)) = channels_map.get(key) {
let mut m = existing.clone();
for (k, v) in new_entry {
m.insert(k, v);
}
m
} else {
new_entry
};
channels_map.insert(key.to_string(), Value::Object(merged));
}
fn normalize_binding_match_value(value: &Value) -> Option<Value> {
match value {
Value::Null => None,
@@ -636,17 +655,6 @@ pub async fn save_messaging_platform(
entry.insert("token".into(), Value::String(t.trim().into()));
}
entry.insert("enabled".into(), Value::Bool(true));
entry.insert("groupPolicy".into(), Value::String("allowlist".into()));
entry.insert("dm".into(), json!({ "enabled": false }));
entry.insert(
"retry".into(),
json!({
"attempts": 3,
"minDelayMs": 500,
"maxDelayMs": 30000,
"jitter": 0.1
}),
);
// guildId + channelId 展开为 guilds 嵌套结构
let guild_id = form_obj
@@ -681,7 +689,19 @@ pub async fn save_messaging_platform(
);
}
channels_map.insert("discord".into(), Value::Object(entry));
// 合并到现有配置,保留用户通过 CLI 设置的 streaming / retry / dmPolicy 等
merge_channel_entry(channels_map, "discord", entry);
// 仅在首次创建时设置默认值,不覆盖用户已有的设置
if let Some(Value::Object(d)) = channels_map.get_mut("discord") {
d.entry("groupPolicy").or_insert(Value::String("allowlist".into()));
d.entry("dm").or_insert(json!({ "enabled": false }));
d.entry("retry").or_insert(json!({
"attempts": 3,
"minDelayMs": 500,
"maxDelayMs": 30000,
"jitter": 0.1
}));
}
}
"telegram" => {
let mut entry = Map::new();
@@ -704,7 +724,7 @@ pub async fn save_messaging_platform(
}
}
channels_map.insert("telegram".into(), Value::Object(entry));
merge_channel_entry(channels_map, "telegram", entry);
}
"qqbot" => {
let app_id = form_obj
@@ -808,10 +828,10 @@ pub async fn save_messaging_platform(
let accounts_obj = accounts.as_object_mut().ok_or("accounts 格式错误")?;
accounts_obj.insert(acct.clone(), Value::Object(entry));
} else {
channels_map.insert(storage_key.clone(), Value::Object(entry));
merge_channel_entry(channels_map, &storage_key, entry);
}
} else {
channels_map.insert(storage_key.clone(), Value::Object(entry));
merge_channel_entry(channels_map, &storage_key, entry);
}
ensure_plugin_allowed(&mut cfg, "openclaw-lark")?;
// 禁用旧版 feishu 插件,防止新旧插件同时运行冲突
@@ -863,7 +883,7 @@ pub async fn save_messaging_platform(
);
}
channels_map.insert(storage_key, Value::Object(entry));
merge_channel_entry(channels_map, &storage_key, entry);
ensure_plugin_allowed(&mut cfg, "dingtalk-connector")?;
ensure_chat_completions_enabled(&mut cfg)?;
let _ = cleanup_legacy_plugin_backup_dir("dingtalk-connector");
@@ -912,7 +932,7 @@ pub async fn save_messaging_platform(
form_string(form_obj, "groupPolicy"),
);
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
channels_map.insert(storage_key, Value::Object(entry));
merge_channel_entry(channels_map, &storage_key, entry);
}
"whatsapp" => {
let mut entry = Map::new();
@@ -925,7 +945,7 @@ pub async fn save_messaging_platform(
);
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
put_bool_from_form(&mut entry, "enabled", &form_string(form_obj, "enabled"));
channels_map.insert(storage_key, Value::Object(entry));
merge_channel_entry(channels_map, &storage_key, entry);
}
"signal" => {
let account = form_string(form_obj, "account");
@@ -947,7 +967,7 @@ pub async fn save_messaging_platform(
form_string(form_obj, "groupPolicy"),
);
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
channels_map.insert(storage_key, Value::Object(entry));
merge_channel_entry(channels_map, &storage_key, entry);
}
"matrix" => {
let homeserver = form_string(form_obj, "homeserver");
@@ -977,7 +997,7 @@ pub async fn save_messaging_platform(
);
put_bool_from_form(&mut entry, "e2ee", &form_string(form_obj, "e2ee"));
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
channels_map.insert(storage_key, Value::Object(entry));
merge_channel_entry(channels_map, &storage_key, entry);
ensure_plugin_allowed(&mut cfg, "matrix")?;
}
"msteams" => {
@@ -1009,7 +1029,7 @@ pub async fn save_messaging_platform(
form_string(form_obj, "groupPolicy"),
);
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
channels_map.insert(storage_key, Value::Object(entry));
merge_channel_entry(channels_map, &storage_key, entry);
ensure_plugin_allowed(&mut cfg, "msteams")?;
}
_ => {
@@ -1019,7 +1039,7 @@ pub async fn save_messaging_platform(
entry.insert(k.clone(), v.clone());
}
entry.insert("enabled".into(), Value::Bool(true));
channels_map.insert(storage_key, Value::Object(entry));
merge_channel_entry(channels_map, &storage_key, entry);
}
}
@@ -2219,7 +2239,6 @@ pub async fn toggle_plugin(plugin_id: String, enabled: bool) -> Result<Value, St
return Err("plugin_id 不能为空".into());
}
let config_path = super::openclaw_dir().join("openclaw.json");
let mut cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({}));
if enabled {
@@ -2228,8 +2247,8 @@ pub async fn toggle_plugin(plugin_id: String, enabled: bool) -> Result<Value, St
disable_legacy_plugin(&mut cfg, plugin_id);
}
let content = serde_json::to_string_pretty(&cfg).map_err(|e| format!("序列化失败: {e}"))?;
std::fs::write(&config_path, content).map_err(|e| format!("写入配置失败: {e}"))?;
// 使用 save_openclaw_json 写入(含备份和 UI 字段清理),而非直接 fs::write
super::config::save_openclaw_json(&cfg)?;
Ok(json!({ "ok": true, "enabled": enabled, "pluginId": plugin_id }))
}

View File

@@ -19,6 +19,7 @@ pub mod config;
pub mod device;
pub mod diagnose;
pub mod extensions;
pub mod hermes;
pub mod logs;
pub mod memory;
pub mod messaging;
@@ -28,9 +29,54 @@ pub mod skillhub;
pub mod skills;
pub mod update;
/// 默认 OpenClaw 配置目录ClawPanel 自身配置始终在此)
/// 默认 OpenClaw 配置目录
/// Windows 上优先使用 USERPROFILE与 Node.js os.homedir() 一致),
/// 并自动检测已有 openclaw.json 的目录,避免创建第二个 .openclaw
fn default_openclaw_dir() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(".openclaw")
#[cfg(target_os = "windows")]
{
let mut candidates: Vec<PathBuf> = Vec::new();
// 优先 USERPROFILE与 Node.js os.homedir() 一致)
if let Ok(up) = std::env::var("USERPROFILE") {
let p = PathBuf::from(up.trim());
if !p.as_os_str().is_empty() {
candidates.push(p);
}
}
// dirs::home_dir() 作为补充Windows API SHGetKnownFolderPath
if let Some(dh) = dirs::home_dir() {
if !candidates.iter().any(|c| panel_path_key(c) == panel_path_key(&dh)) {
candidates.push(dh);
}
}
// HOMEDRIVE+HOMEPATH域控/企业环境可能指向网络盘)
if let (Ok(hd), Ok(hp)) = (std::env::var("HOMEDRIVE"), std::env::var("HOMEPATH")) {
let combined = format!("{}{}", hd.trim(), hp.trim());
let p = PathBuf::from(&combined);
if !combined.is_empty()
&& !candidates.iter().any(|c| panel_path_key(c) == panel_path_key(&p))
{
candidates.push(p);
}
}
// 优先选已有 openclaw.json 的目录(自动对齐已安装的 OpenClaw
for home in &candidates {
let dir = home.join(".openclaw");
if dir.join("openclaw.json").exists() {
return dir;
}
}
// 都没有 → 用第一个候选USERPROFILE
candidates
.first()
.cloned()
.unwrap_or_default()
.join(".openclaw")
}
#[cfg(not(target_os = "windows"))]
{
dirs::home_dir().unwrap_or_default().join(".openclaw")
}
}
fn panel_path_key(path: &std::path::Path) -> String {

View File

@@ -4,8 +4,8 @@ mod tray;
mod utils;
use commands::{
agent, assistant, config, device, diagnose, extensions, logs, memory, messaging, pairing,
service, skills, update,
agent, assistant, config, device, diagnose, extensions, hermes, logs, memory, messaging,
pairing, service, skills, update,
};
pub fn run() {
@@ -215,6 +215,22 @@ pub fn run() {
update::download_frontend_update,
update::rollback_frontend_update,
update::get_update_status,
// Hermes Agent 管理
hermes::check_python,
hermes::check_hermes,
hermes::install_hermes,
hermes::configure_hermes,
hermes::hermes_gateway_action,
hermes::hermes_health_check,
hermes::hermes_api_proxy,
hermes::hermes_agent_run,
hermes::hermes_read_config,
hermes::hermes_fetch_models,
hermes::hermes_update_model,
hermes::hermes_detect_environments,
hermes::hermes_set_gateway_url,
hermes::update_hermes,
hermes::uninstall_hermes,
])
.on_window_event(|window, event| {
// 关闭窗口时最小化到托盘,不退出应用

View File

@@ -3,12 +3,13 @@
*/
import { navigate, getCurrentRoute, reloadCurrentRoute } from '../router.js'
import { toggleTheme, getTheme } from '../lib/theme.js'
import { isOpenclawReady, getActiveInstance, switchInstance, onInstanceChange } from '../lib/app-state.js'
import { isOpenclawReady } from '../lib/app-state.js'
import { api } from '../lib/tauri-api.js'
import { toast } from './toast.js'
import { version as APP_VERSION } from '../../package.json'
import { t, getLang, setLang, getAvailableLangs } from '../lib/i18n.js'
import { isFeatureAvailable } from '../lib/feature-gates.js'
import { getActiveEngine, getActiveEngineId, listEngines, switchEngine, onEngineChange } from '../lib/engine-manager.js'
function NAV_ITEMS_FULL() { return [
{
@@ -103,17 +104,39 @@ const ICONS = {
}
let _delegated = false
let _hasMultipleInstances = false
// 异步检测是否有多实例(首次渲染后触发,有多实例时重渲染)
function _checkMultiInstances(el) {
api.instanceList().then(data => {
const has = data.instances && data.instances.length > 1
if (has !== _hasMultipleInstances) {
_hasMultipleInstances = has
renderSidebar(el)
}
}).catch(() => {})
// === 引擎切换器 ===
function _renderEngineSwitcher() {
const engines = listEngines()
if (engines.length < 2) return '' // 只有一个引擎时不显示
const active = getActiveEngine()
if (!active) return ''
return `<div class="engine-switcher" id="engine-switcher">
<button class="engine-current" id="btn-engine-toggle">
<span class="engine-icon">${active.icon || ''}</span>
<span class="engine-label">${_escSidebar(active.name)}</span>
<svg class="engine-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="engine-dropdown" id="engine-dropdown">
${engines.map(e => `<div class="engine-option${e.id === active.id ? ' active' : ''}" data-engine="${e.id}">
<span class="engine-opt-icon">${e.icon || ''}</span>
<span class="engine-opt-name">${_escSidebar(e.name)}</span>
${e.id === active.id ? '<span class="engine-active-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="14" height="14"><polyline points="20 6 9 17 4 12"/></svg></span>' : ''}
</div>`).join('')}
</div>
</div>`
}
function _closeEngineDropdown() {
const dd = document.getElementById('engine-dropdown')
if (dd) dd.classList.remove('open')
}
function _toggleEngineDropdown() {
const dd = document.getElementById('engine-dropdown')
if (!dd) return
if (dd.classList.contains('open')) { dd.classList.remove('open'); return }
dd.classList.add('open')
}
const LS_SIDEBAR_COLLAPSED = 'clawpanel_sidebar_collapsed'
@@ -135,10 +158,6 @@ function _setDesktopSidebarCollapsed(collapsed) {
export function renderSidebar(el) {
const current = getCurrentRoute()
const inst = getActiveInstance()
const isLocal = inst.type === 'local'
const showSwitcher = !isLocal || _hasMultipleInstances
const collapsed = _isDesktopSidebarCollapsed()
let html = `
<div class="sidebar-header">
@@ -149,25 +168,21 @@ export function renderSidebar(el) {
<button class="sidebar-collapse-btn" id="btn-sidebar-collapse" title="${t('sidebar.collapse')}">${collapsed ? '»' : '«'}</button>
<button class="sidebar-close-btn" id="btn-sidebar-close" title="${t('sidebar.closeMenu')}">&times;</button>
</div>
${showSwitcher ? `<div class="instance-switcher" id="instance-switcher">
<button class="instance-current" id="btn-instance-toggle">
<span class="instance-dot ${isLocal ? 'local' : 'remote'}"></span>
<span class="instance-label">${_escSidebar(inst.name)}</span>
<svg class="instance-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="instance-dropdown" id="instance-dropdown"></div>
</div>` : ''}
${_renderEngineSwitcher()}
<nav class="sidebar-nav">
`
const navItems = isOpenclawReady() ? NAV_ITEMS_FULL() : NAV_ITEMS_SETUP()
// 从当前引擎获取菜单(回退到原有逻辑)
const engine = getActiveEngine()
const navItems = engine ? engine.getNavItems() : (isOpenclawReady() ? NAV_ITEMS_FULL() : NAV_ITEMS_SETUP())
for (const section of navItems) {
html += `<div class="nav-section">
<div class="nav-section-title">${section.section}</div>`
for (const item of section.items) {
if (item.gate && !isFeatureAvailable(item.gate)) continue
if (item.gate && engine && !engine.isFeatureAvailable(item.gate)) continue
if (item.gate && !engine && !isFeatureAvailable(item.gate)) continue
const active = current === item.route ? ' active' : ''
html += `<div class="nav-item${active}" data-route="${item.route}">
${ICONS[item.icon] || ''}
@@ -227,9 +242,6 @@ export function renderSidebar(el) {
// 应用折叠态(桌面端)
_setDesktopSidebarCollapsed(collapsed)
// 首次渲染时异步检测多实例
if (!_delegated) _checkMultiInstances(el)
// 事件委托:只绑定一次,避免重复绑定
if (!_delegated) {
_delegated = true
@@ -278,47 +290,40 @@ export function renderSidebar(el) {
}
return
}
// 实例切换器
const toggleBtn = e.target.closest('#btn-instance-toggle')
if (toggleBtn) {
_toggleInstanceDropdown(el)
// 引擎切换器:打开/关闭下拉
const engineBtn = e.target.closest('#btn-engine-toggle')
if (engineBtn) {
_toggleEngineDropdown()
return
}
// 选择实例
const opt = e.target.closest('.instance-option[data-id]')
if (opt) {
const id = opt.dataset.id
_closeInstanceDropdown()
if (id !== getActiveInstance().id) {
opt.style.opacity = '0.5'
switchInstance(id).then(() => {
const inst = getActiveInstance()
const desc = inst.type === 'local' ? t('instance.local') : inst.name
toast(t('instance.switchedTo', { name: desc }), 'success')
// 引擎选项点击
const engineOpt = e.target.closest('.engine-option[data-engine]')
if (engineOpt) {
const eid = engineOpt.dataset.engine
_closeEngineDropdown()
if (eid !== getActiveEngineId()) {
engineOpt.style.opacity = '0.5'
switchEngine(eid).then(() => {
toast(t('engine.switchedTo', { name: getActiveEngine()?.name || eid }), 'success')
renderSidebar(el)
reloadCurrentRoute()
// 跳转到新引擎的默认或 setup 页
const eng = getActiveEngine()
if (eng) {
navigate(eng.isReady() ? eng.getDefaultRoute() : eng.getSetupRoute())
}
})
}
return
}
// 添加实例
const addBtn = e.target.closest('#btn-instance-add')
if (addBtn) {
_closeInstanceDropdown()
_showAddInstanceDialog(el)
return
}
// 点击其他区域关闭下拉
if (!e.target.closest('.instance-switcher')) {
_closeInstanceDropdown()
if (!e.target.closest('.engine-switcher')) {
_closeEngineDropdown()
}
if (!e.target.closest('.lang-switcher')) {
_closeLangDropdown()
}
})
// 监听实例变化,刷新多实例标记后重新渲染
onInstanceChange(() => { _checkMultiInstances(el); renderSidebar(el) })
}
}
@@ -381,94 +386,3 @@ function _filterLangOptions(query) {
})
}
function _closeInstanceDropdown() {
const dd = document.getElementById('instance-dropdown')
if (dd) dd.classList.remove('open')
}
async function _toggleInstanceDropdown(sidebarEl) {
const dd = document.getElementById('instance-dropdown')
if (!dd) return
if (dd.classList.contains('open')) { dd.classList.remove('open'); return }
dd.innerHTML = `<div style="padding:8px;color:var(--text-tertiary);font-size:12px">${t('common.loading')}</div>`
dd.classList.add('open')
try {
const [data, health] = await Promise.all([api.instanceList(), api.instanceHealthAll()])
const healthMap = Object.fromEntries((health || []).map(h => [h.id, h]))
const activeId = getActiveInstance().id
let html = `<div class="instance-hint">${t('instance.switchHint')}</div>`
for (const inst of data.instances) {
const h = healthMap[inst.id] || {}
const active = inst.id === activeId ? ' active' : ''
const dot = h.online !== false ? 'online' : 'offline'
const badge = inst.type === 'docker' ? `<span class="instance-badge docker">${t('instance.docker')}</span>` : inst.type === 'remote' ? `<span class="instance-badge remote">${t('instance.remote')}</span>` : ''
const port = inst.endpoint ? inst.endpoint.match(/:(\d+)/)?.[1] : ''
const portTag = port ? `<span class="instance-port">:${port}</span>` : ''
html += `<div class="instance-option${active}" data-id="${inst.id}">
<span class="instance-dot ${dot}"></span>
<span class="instance-opt-name">${_escSidebar(inst.name)}</span>
${portTag}
${badge}
${active ? `<span class="instance-active-tag">${t('instance.current')}</span>` : ''}
</div>`
}
html += '<div class="instance-divider"></div>'
html += `<div class="instance-option instance-add" id="btn-instance-add">+ ${t('instance.addInstance')}</div>`
dd.innerHTML = html
} catch (e) {
dd.innerHTML = `<div style="padding:8px;color:var(--error);font-size:12px">${_escSidebar(e.message)}</div>`
}
}
async function _showAddInstanceDialog(sidebarEl) {
const overlay = document.createElement('div')
overlay.className = 'docker-dialog-overlay'
overlay.innerHTML = `
<div class="docker-dialog">
<div class="docker-dialog-title">${t('instance.addRemote')}</div>
<div class="form-group" style="margin-bottom:var(--space-md)">
<label class="form-label">${t('instance.nameLabel')}</label>
<input class="form-input" id="inst-name" placeholder="${t('instance.namePlaceholder')}" />
</div>
<div class="form-group" style="margin-bottom:var(--space-md)">
<label class="form-label">${t('instance.endpointLabel')}</label>
<input class="form-input" id="inst-endpoint" placeholder="http://192.168.1.100:1420" />
</div>
<div class="form-group" style="margin-bottom:var(--space-md)">
<label class="form-label">${t('instance.gwPortLabel')}</label>
<input class="form-input" id="inst-gw-port" type="number" value="18789" />
</div>
<div class="docker-dialog-hint">
${t('instance.remoteHint')}<br/>
${t('instance.example')}: <code>http://192.168.1.100:1420</code>
</div>
<div id="inst-add-error" style="color:var(--error);font-size:12px;margin-top:var(--space-sm)"></div>
<div class="docker-dialog-actions">
<button class="btn btn-secondary btn-sm" id="inst-cancel">${t('common.cancel')}</button>
<button class="btn btn-primary btn-sm" id="inst-confirm">${t('common.add')}</button>
</div>
</div>
`
document.body.appendChild(overlay)
overlay.querySelector('#inst-cancel').onclick = () => overlay.remove()
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
overlay.querySelector('#inst-confirm').onclick = async () => {
const name = overlay.querySelector('#inst-name').value.trim()
const endpoint = overlay.querySelector('#inst-endpoint').value.trim()
const gwPort = parseInt(overlay.querySelector('#inst-gw-port').value) || 18789
const errEl = overlay.querySelector('#inst-add-error')
if (!name || !endpoint) { errEl.textContent = t('instance.nameRequired'); return }
const btn = overlay.querySelector('#inst-confirm')
btn.disabled = true; btn.textContent = t('instance.adding')
try {
await api.instanceAdd({ name, type: 'remote', endpoint, gatewayPort: gwPort })
overlay.remove()
renderSidebar(sidebarEl)
} catch (e) {
errEl.textContent = e.message || String(e)
btn.disabled = false; btn.textContent = t('common.add')
}
}
}

125
src/engines/hermes/index.js Normal file
View File

@@ -0,0 +1,125 @@
/**
* Hermes Agent 引擎
*/
import { t } from '../../lib/i18n.js'
import { api } from '../../lib/tauri-api.js'
// Hermes 状态
let _ready = false
let _running = false
let _listeners = []
let _pollTimer = null
async function detectHermesStatus() {
try {
const info = await api.checkHermes()
_ready = !!info?.installed && !!info?.configExists
_running = !!info?.gatewayRunning
} catch (_) {
_ready = false
_running = false
}
_listeners.forEach(fn => { try { fn({ ready: _ready, running: _running }) } catch (_) {} })
return _ready
}
function startPoll() {
if (_pollTimer) return
_pollTimer = setInterval(detectHermesStatus, 15000)
}
function stopPoll() {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null }
}
export default {
id: 'hermes',
name: 'Hermes Agent',
description: 'Hermes AI Agent with tool-calling capabilities',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
async detect() {
await detectHermesStatus()
return { installed: _ready, ready: _ready }
},
async boot() {
await detectHermesStatus()
startPoll()
},
cleanup() {
stopPoll()
},
getNavItems() {
// 未就绪时显示 Setup 菜单
if (!_ready) {
return [{
section: '',
items: [
{ route: '/h/setup', label: t('sidebar.setup'), icon: 'setup' },
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
]
}, {
section: '',
items: [
{ route: '/settings', label: t('sidebar.settings'), icon: 'settings' },
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
]
}]
}
// 就绪后显示完整菜单
// 仅展示已开发的页面stub 页面暂时隐藏
return [{
section: t('sidebar.sectionMonitor'),
items: [
{ route: '/h/dashboard', label: t('sidebar.dashboard'), icon: 'dashboard' },
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
{ route: '/h/chat', label: t('sidebar.chat'), icon: 'chat' },
]
}, {
section: '',
items: [
{ route: '/settings', label: t('sidebar.settings'), icon: 'settings' },
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
]
}]
},
getRoutes() {
return [
// Hermes 专属页面(/h/ 前缀)— Phase 2 实现
{ path: '/h/setup', loader: () => import('./pages/setup.js') },
{ path: '/h/dashboard', loader: () => import('./pages/dashboard.js') },
{ path: '/h/chat', loader: () => import('./pages/chat.js') },
{ path: '/h/services', loader: () => import('./pages/services.js') },
{ path: '/h/config', loader: () => import('./pages/config.js') },
{ path: '/h/channels', loader: () => import('./pages/channels.js') },
{ path: '/h/cron', loader: () => import('./pages/cron.js') },
{ path: '/h/skills', loader: () => import('./pages/skills.js') },
// 共用页面(引擎无关)
{ path: '/assistant', loader: () => import('../../pages/assistant.js') },
{ path: '/settings', loader: () => import('../../pages/settings.js') },
{ path: '/about', loader: () => import('../../pages/about.js') },
]
},
getSetupRoute() { return '/h/setup' },
getDefaultRoute() { return '/h/dashboard' },
isReady() { return _ready },
isGatewayRunning() { return _running },
isGatewayForeign() { return false },
onStateChange(fn) {
_listeners.push(fn)
return () => { _listeners = _listeners.filter(cb => cb !== fn) }
},
onReadyChange(fn) {
_listeners.push(fn)
return () => { _listeners = _listeners.filter(cb => cb !== fn) }
},
isFeatureAvailable() { return true },
}

View File

@@ -0,0 +1,16 @@
/**
* Hermes Agent 渠道配置
*/
import { t } from '../../../lib/i18n.js'
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.innerHTML = `
<div class="page-header"><h1>${t('engine.hermesChannelsTitle')}</h1></div>
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
${t('engine.comingSoonPhase2')}
</div></div>
`
return el
}

View File

@@ -0,0 +1,627 @@
/**
* Hermes Agent 对话页面
* 通过 /v1/runs + SSE 事件流驱动,支持工具调用可视化和流式文本
* 支持多会话管理、/xxx 快捷指令
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { PROVIDER_PRESETS } from '../../../lib/model-presets.js'
const STORAGE_KEY = 'hermes_chat_sessions'
const FILE_ACCESS_KEY = 'hermes_chat_file_access'
const SLASH_COMMANDS = [
{ cmd: '/help', desc: '显示可用命令' },
{ cmd: '/status', desc: '查看 Agent 状态' },
{ cmd: '/memory', desc: '管理记忆' },
{ cmd: '/skills', desc: '查看技能列表' },
{ cmd: '/clear', desc: '清空当前会话' },
{ cmd: '/new', desc: '新建会话' },
]
const TOOL_ICONS = {
web_search: '🔍', browse: '🌐', web_browse: '🌐', google: '🔍',
code: '💻', execute_code: '💻', run_code: '💻', python: '🐍',
terminal: '⌨️', shell: '⌨️', bash: '⌨️', command: '⌨️',
file: '📁', read_file: '📁', write_file: '📝',
memory: '🧠', recall: '🧠',
default: '🔧',
}
function toolIcon(name) {
const n = (name || '').toLowerCase()
for (const [k, v] of Object.entries(TOOL_ICONS)) {
if (n.includes(k)) return v
}
return TOOL_ICONS.default
}
function mdToHtml(text) {
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/\n/g, '<br>')
}
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function genId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) }
// Lazy Tauri event listen (avoid top-level await for vite build)
let _listenFn = null
async function tauriListen(event, cb) {
if (!_listenFn) {
const mod = await import('@tauri-apps/api/event')
_listenFn = mod.listen
}
return _listenFn(event, cb)
}
// --- Session persistence ---
function loadSessions() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]') } catch { return [] }
}
function saveSessions(sessions) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions))
}
function sessionTitle(s) {
if (s.title) return s.title
const first = s.messages.find(m => m.role === 'user')
return first ? first.content.slice(0, 30) : t('engine.chatNewSession')
}
export function render() {
const el = document.createElement('div')
el.className = 'page hermes-chat-page'
let sessions = loadSessions()
let activeId = sessions[0]?.id || null
let streaming = false
let gwOnline = false
let showSlash = false
let slashFilter = ''
let currentModel = '' // 当前模型名
let modelList = [] // 已获取的模型列表
let showModelDropdown = false
let fileAccessEnabled = localStorage.getItem(FILE_ACCESS_KEY) === 'true'
// 流式状态
let pendingText = '' // 累积的 delta 文本
let activeTools = [] // 当前活跃的工具调用 [{ name, status, detail, input, output, error }]
let unlisteners = [] // Tauri 事件监听取消函数
function active() { return sessions.find(s => s.id === activeId) }
function newSession() {
const s = { id: genId(), title: '', messages: [], createdAt: Date.now() }
sessions.unshift(s)
activeId = s.id
saveSessions(sessions)
}
if (!sessions.length) newSession()
async function init() {
try {
const info = await api.checkHermes()
gwOnline = !!info?.gatewayRunning
} catch (_) {}
// Load current model config
try {
const cfg = await api.hermesReadConfig()
if (cfg?.model) currentModel = cfg.model
if (cfg?.base_url && cfg?.api_key) {
// Pre-fetch model list for quick switch
try {
const base = cfg.base_url.replace(/\/+$/, '').replace(/\/(chat\/completions|completions|responses|messages|models)\/?$/, '')
const resp = await fetch(base + '/models', { headers: { 'Authorization': `Bearer ${cfg.api_key}` }, signal: AbortSignal.timeout(8000) })
if (resp.ok) {
const data = await resp.json()
modelList = (data.data || []).map(m => m.id).filter(Boolean).sort()
}
} catch (_) {}
}
} catch (_) {}
draw()
}
// --- 工具调用卡片渲染 ---
function formatToolData(data) {
if (!data) return ''
if (typeof data === 'string') {
// 尝试解析 JSON 以美化显示
try { const obj = JSON.parse(data); return JSON.stringify(obj, null, 2) } catch { return data }
}
return JSON.stringify(data, null, 2)
}
function renderToolCard(t, collapsed = true) {
const icon = toolIcon(t.name)
const statusCls = t.status === 'complete' ? 'done' : t.status === 'error' ? 'err' : 'active'
const statusText = t.status === 'complete' ? '✓ 完成' : t.status === 'error' ? '✗ 失败' : '⟳ 运行中'
const detail = t.detail && t.detail !== '失败' && t.detail !== '完成' ? `${escHtml(t.detail)}` : ''
const inputStr = formatToolData(t.input)
const outputStr = formatToolData(t.output)
const errorStr = t.error ? (typeof t.error === 'string' ? t.error : JSON.stringify(t.error)) : ''
// fallback: 用 raw 快照显示原始事件数据
const rawStr = (!inputStr && !outputStr && !errorStr) ? formatToolData(t._raw || t._rawCompleted) : ''
const hasDetails = inputStr || outputStr || errorStr || rawStr
const cardId = 'tc-' + genId()
let detailsHtml = ''
if (hasDetails) {
detailsHtml = `<div class="hm-tool-details" id="${cardId}-details" style="${collapsed ? 'display:none' : ''}">
${inputStr ? `<div class="hm-tool-section"><div class="hm-tool-section-label">输入</div><pre class="hm-tool-pre">${escHtml(inputStr)}</pre></div>` : ''}
${errorStr ? `<div class="hm-tool-section hm-tool-section-err"><div class="hm-tool-section-label">错误</div><pre class="hm-tool-pre">${escHtml(errorStr)}</pre></div>` : ''}
${outputStr ? `<div class="hm-tool-section"><div class="hm-tool-section-label">输出</div><pre class="hm-tool-pre">${escHtml(outputStr)}</pre></div>` : ''}
${rawStr ? `<div class="hm-tool-section"><div class="hm-tool-section-label">详情</div><pre class="hm-tool-pre">${escHtml(rawStr)}</pre></div>` : ''}
</div>`
}
return `<div class="hm-tool-card ${statusCls}" data-tool-card="${cardId}">
<div class="hm-tool-card-header">${icon} <span class="hm-tool-name">${escHtml(t.name)}</span><span class="hm-tool-status">${statusText}${detail}</span>${hasDetails ? `<span class="hm-tool-toggle">▶</span>` : ''}</div>
${detailsHtml}
</div>`
}
// --- 增量更新流式区域(避免全量 draw 导致闪烁)---
function updateStreamArea() {
const msgsEl = el.querySelector('#hm-chat-msgs')
if (!msgsEl) return
let streamEl = msgsEl.querySelector('.hm-stream-area')
if (!streaming) {
if (streamEl) streamEl.remove()
return
}
if (!streamEl) {
streamEl = document.createElement('div')
streamEl.className = 'hm-stream-area'
msgsEl.appendChild(streamEl)
}
const toolsHtml = activeTools.map(t => renderToolCard(t, false)).join('')
const textHtml = pendingText
? `<div class="hermes-chat-msg assistant"><div class="hermes-chat-bubble assistant">${mdToHtml(pendingText)}</div></div>`
: (activeTools.length === 0 ? `<div class="hermes-chat-msg assistant"><div class="hermes-chat-bubble assistant"><span class="hermes-chat-typing">${t('engine.chatThinking')}</span></div></div>` : '')
streamEl.innerHTML = toolsHtml + textHtml
msgsEl.scrollTop = msgsEl.scrollHeight
}
// --- Draw ---
function draw() {
const cur = active()
const msgs = cur?.messages || []
el.innerHTML = `
<div class="hm-chat-layout">
<div class="hm-chat-sidebar">
<div class="hm-chat-sidebar-header">
<span>${t('engine.hermesChatTitle')}</span>
<button class="hm-new-btn" title="${t('engine.chatNewSession')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="hm-chat-session-list">
${sessions.map(s => `
<div class="hm-session-item ${s.id === activeId ? 'active' : ''}" data-sid="${s.id}">
<span class="hm-session-title">${escHtml(sessionTitle(s))}</span>
<button class="hm-session-del" data-del="${s.id}" title="${t('common.delete')}">&times;</button>
</div>
`).join('')}
</div>
</div>
<div class="hm-chat-main">
<div class="hm-chat-model-bar">
<span class="hm-model-label">${t('engine.configModel')}:</span>
<div style="position:relative;flex:1;max-width:240px">
<input type="text" id="hm-chat-model" class="hm-model-input" value="${escHtml(currentModel)}" placeholder="QC-B01" readonly>
${showModelDropdown && modelList.length ? `<div id="hm-chat-model-dd" class="hm-model-dropdown">${modelList.map(m => `<div class="hm-chat-model-opt${m === currentModel ? ' active' : ''}" data-model="${escHtml(m)}">${escHtml(m)}</div>`).join('')}</div>` : ''}
</div>
<button class="hm-file-access-toggle ${fileAccessEnabled ? 'active' : ''}" id="hm-file-access-btn" title="${fileAccessEnabled ? t('engine.fileAccessOn') : t('engine.fileAccessOff')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
<span>${t('engine.fileAccess')}</span>
</button>
<a href="#/h/dashboard" class="hm-model-link">${t('engine.dashModelConfig')} →</a>
</div>
<div class="hermes-chat-messages" id="hm-chat-msgs">
${msgs.length === 0 ? `<div class="hermes-chat-empty">${t('engine.chatEmptyHint')}</div>` : ''}
${msgs.map(m => renderMessage(m)).join('')}
</div>
<div class="hermes-chat-input-area">
${!gwOnline ? `<div class="hm-gw-offline">${t('engine.chatGatewayOffline')}</div>` : ''}
<div style="position:relative">
${showSlash ? renderSlashMenu() : ''}
<div class="hm-chat-input-wrap">
<textarea id="hm-chat-input" rows="1" placeholder="${t('engine.chatPlaceholder')}" ${!gwOnline ? 'disabled' : ''}></textarea>
<button class="btn btn-primary hm-chat-send" ${!gwOnline || streaming ? 'disabled' : ''}>${streaming ? '...' : t('engine.chatSend')}</button>
</div>
</div>
</div>
</div>
</div>
`
bind()
if (streaming) updateStreamArea()
scrollToBottom()
}
function renderMessage(m) {
const isUser = m.role === 'user'
// 工具摘要行(存储在 messages 中的已完成工具记录)
if (m.role === 'tool-summary') {
return `<div class="hm-tool-summary">${m.tools.map(t => renderToolCard(t, true)).join('')}</div>`
}
return `<div class="hermes-chat-msg ${isUser ? 'user' : 'assistant'}">
<div class="hermes-chat-bubble ${isUser ? 'user' : 'assistant'}">${isUser ? escHtml(m.content) : mdToHtml(m.content)}</div>
</div>`
}
function renderSlashMenu() {
const cmds = SLASH_COMMANDS.filter(c => !slashFilter || c.cmd.includes(slashFilter))
if (!cmds.length) return ''
return `<div class="hm-slash-menu">${cmds.map(c =>
`<div class="hm-slash-item" data-cmd="${c.cmd}"><span class="hm-slash-cmd">${c.cmd}</span><span class="hm-slash-desc">${c.desc}</span></div>`
).join('')}</div>`
}
function scrollToBottom() {
const msgsEl = el.querySelector('#hm-chat-msgs')
if (msgsEl) msgsEl.scrollTop = msgsEl.scrollHeight
}
// 事件委托:工具卡片展开/折叠(对静态和动态流式卡片都生效)
el.addEventListener('click', (e) => {
const header = e.target.closest('.hm-tool-card-header')
if (!header) return
const card = header.closest('.hm-tool-card')
const details = card?.querySelector('.hm-tool-details')
const toggle = header.querySelector('.hm-tool-toggle')
if (details) {
const open = details.style.display !== 'none'
details.style.display = open ? 'none' : 'block'
if (toggle) toggle.textContent = open ? '▶' : '▼'
}
})
function bind() {
// Model quick-switch
el.querySelector('#hm-chat-model')?.addEventListener('click', () => {
if (modelList.length) { showModelDropdown = !showModelDropdown; draw() }
})
el.querySelectorAll('.hm-chat-model-opt').forEach(opt => {
opt.addEventListener('click', async () => {
const m = opt.dataset.model
if (m && m !== currentModel) {
try {
await api.hermesUpdateModel(m)
currentModel = m
} catch (_) {}
}
showModelDropdown = false; draw()
})
})
document.addEventListener('click', (e) => {
if (showModelDropdown && !e.target.closest('#hm-chat-model') && !e.target.closest('#hm-chat-model-dd')) {
showModelDropdown = false; draw()
}
})
// File access toggle
el.querySelector('#hm-file-access-btn')?.addEventListener('click', () => {
fileAccessEnabled = !fileAccessEnabled
localStorage.setItem(FILE_ACCESS_KEY, fileAccessEnabled ? 'true' : 'false')
draw()
})
// Session sidebar
el.querySelector('.hm-new-btn')?.addEventListener('click', () => { newSession(); draw() })
el.querySelectorAll('.hm-session-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.hm-session-del')) return
activeId = item.dataset.sid
draw()
})
})
el.querySelectorAll('.hm-session-del').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation()
const sid = btn.dataset.del
sessions = sessions.filter(s => s.id !== sid)
if (activeId === sid) {
if (!sessions.length) newSession()
activeId = sessions[0].id
}
saveSessions(sessions)
draw()
})
})
// Slash menu clicks
el.querySelectorAll('.hm-slash-item').forEach(item => {
item.addEventListener('click', () => {
const input = el.querySelector('#hm-chat-input')
if (input) { input.value = item.dataset.cmd + ' '; input.focus() }
showSlash = false
draw()
})
})
// Send
el.querySelector('.hm-chat-send')?.addEventListener('click', sendMessage)
const input = el.querySelector('#hm-chat-input')
if (input) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() }
if (e.key === 'Escape') { showSlash = false; draw() }
})
input.addEventListener('input', () => {
input.style.height = 'auto'
input.style.height = Math.min(input.scrollHeight, 120) + 'px'
const val = input.value
if (val.startsWith('/') && !val.includes(' ')) {
showSlash = true; slashFilter = val
const parent = input.closest('.hermes-chat-input-area')?.querySelector('[style*="position:relative"]')
if (parent) {
const existing = parent.querySelector('.hm-slash-menu')
if (existing) existing.remove()
const cmds = SLASH_COMMANDS.filter(c => c.cmd.includes(val))
if (cmds.length) {
const div = document.createElement('div')
div.className = 'hm-slash-menu'
div.innerHTML = cmds.map(c =>
`<div class="hm-slash-item" data-cmd="${c.cmd}"><span class="hm-slash-cmd">${c.cmd}</span><span class="hm-slash-desc">${c.desc}</span></div>`
).join('')
div.querySelectorAll('.hm-slash-item').forEach(item => {
item.addEventListener('click', () => {
input.value = item.dataset.cmd + ' '
input.focus()
showSlash = false
div.remove()
})
})
parent.prepend(div)
}
}
} else if (showSlash) {
showSlash = false
el.querySelector('.hm-slash-menu')?.remove()
}
})
input.focus()
}
}
// --- 清理事件监听 ---
function cleanupListeners() {
for (const fn of unlisteners) fn()
unlisteners = []
}
// --- 设置 Tauri 事件监听 ---
async function setupRunListeners() {
cleanupListeners()
const u1 = await tauriListen('hermes-run-delta', (e) => {
pendingText += e.payload?.delta || ''
updateStreamArea()
})
const u2 = await tauriListen('hermes-run-tool', (e) => {
const evt = e.payload || {}
const evtType = evt.event || ''
const toolName = evt.tool || evt.tool_name || evt.name || 'tool'
const preview = evt.preview || evt.detail || evt.message || ''
// 提取 input/output 时兼容多种字段名
const extractData = (obj, keys) => {
for (const k of keys) {
if (obj[k] != null && obj[k] !== '') return obj[k]
}
return null
}
// 构建去掉元字段后的 raw 快照,作为 fallback
const rawSnapshot = (exclude) => {
const copy = {}
for (const [k, v] of Object.entries(evt)) {
if (!exclude.includes(k) && v != null && v !== '') copy[k] = v
}
return Object.keys(copy).length ? copy : null
}
if (evtType === 'tool.started') {
const inputData = extractData(evt, ['input', 'args', 'arguments', 'parameters', 'params', 'data'])
activeTools.push({ name: toolName, status: 'active', detail: preview, input: inputData, output: null, error: null, _raw: rawSnapshot(['event', 'tool', 'tool_name', 'name']) })
} else if (evtType === 'tool.completed') {
const t = activeTools.find(t => t.name === toolName && t.status === 'active')
if (t) {
t.status = evt.error ? 'error' : 'complete'
t.detail = evt.error ? '失败' : (evt.duration ? `${evt.duration}s` : '完成')
t.output = extractData(evt, ['output', 'result', 'content', 'data', 'response'])
if (evt.error) t.error = typeof evt.error === 'string' ? evt.error : JSON.stringify(evt.error)
// 合并 started 时可能没有的 input
if (!t.input) t.input = extractData(evt, ['input', 'args', 'arguments', 'parameters', 'params'])
t._rawCompleted = rawSnapshot(['event', 'tool', 'tool_name', 'name', 'error', 'duration'])
}
} else if (evtType === 'tool.error') {
const t = activeTools.find(t => t.name === toolName && t.status === 'active')
if (t) {
t.status = 'error'
t.detail = preview || '失败'
t.error = evt.error || preview || '未知错误'
}
} else if (evtType === 'tool.progress') {
const t = activeTools.find(t => t.name === toolName && t.status === 'active')
if (t && preview) t.detail = preview
}
updateStreamArea()
})
const u3 = await tauriListen('hermes-run-done', (e) => {
const cur = active()
if (!cur) return
const output = e.payload?.output || pendingText || '(empty)'
// 存储工具摘要(含输入输出详情)
if (activeTools.length > 0) {
cur.messages.push({ role: 'tool-summary', tools: activeTools.map(t => ({
name: t.name, status: t.status, detail: t.detail,
input: t.input, output: t.output, error: t.error,
_raw: t._raw, _rawCompleted: t._rawCompleted
})) })
}
cur.messages.push({ role: 'assistant', content: output })
streaming = false
pendingText = ''
activeTools = []
saveSessions(sessions)
cleanupListeners()
draw()
})
const u4 = await tauriListen('hermes-run-error', (e) => {
const cur = active()
if (!cur) return
const err = e.payload?.error || 'unknown error'
cur.messages.push({ role: 'assistant', content: `⚠️ Agent 运行失败: ${escHtml(err)}` })
streaming = false
pendingText = ''
activeTools = []
saveSessions(sessions)
cleanupListeners()
draw()
})
unlisteners.push(u1, u2, u3, u4)
}
async function sendMessage() {
const input = el.querySelector('#hm-chat-input')
const text = input?.value?.trim()
if (!text || streaming) return
const cur = active()
if (!cur) return
// 本地命令处理(不走 Gateway
if (text === '/clear') {
cur.messages = []; cur.title = ''
saveSessions(sessions)
input.value = ''; draw(); return
}
if (text === '/new') {
newSession(); input.value = ''; draw(); return
}
if (text === '/help') {
cur.messages.push({ role: 'user', content: text })
cur.messages.push({ role: 'assistant', content:
'**可用命令:**\n' +
'`/help` — 显示此帮助\n' +
'`/status` — 查看 Gateway 状态\n' +
'`/memory` — 管理 Agent 记忆\n' +
'`/skills` — 查看可用技能\n' +
'`/clear` — 清空当前会话\n' +
'`/new` — 新建会话\n\n' +
'直接输入问题即可与 Hermes Agent 对话。'
})
saveSessions(sessions)
input.value = ''; draw(); return
}
if (text === '/status') {
input.value = ''
cur.messages.push({ role: 'user', content: text })
try {
const info = await api.checkHermes()
const gw = info?.gatewayRunning ? '✅ 运行中' : '❌ 未运行'
const model = info?.model || '-'
const port = info?.gatewayPort || 8642
cur.messages.push({ role: 'assistant', content:
`**Gateway 状态:** ${gw}\n**端口:** ${port}\n**模型:** ${model}`
})
} catch (e) {
cur.messages.push({ role: 'assistant', content: `⚠️ 获取状态失败: ${e}` })
}
saveSessions(sessions)
draw(); return
}
cur.messages.push({ role: 'user', content: text })
if (!cur.title && cur.messages.length === 1) {
cur.title = text.slice(0, 30)
}
input.value = ''
input.style.height = 'auto'
streaming = true
pendingText = ''
activeTools = []
showSlash = false
draw()
try {
// 构建会话历史(只取 user/assistant 消息)
const history = cur.messages
.filter(m => m.role === 'user' || m.role === 'assistant')
.slice(0, -1) // 排除刚加的这条 user 消息
.map(m => ({ role: m.role, content: m.content }))
// 设置事件监听
await setupRunListeners()
// 构建 instructions文件系统访问开关
let instructions = null
if (fileAccessEnabled) {
const isWin = navigator.platform?.startsWith('Win') || /windows/i.test(navigator.userAgent)
const osHint = isWin
? '当前系统是 Windows。请使用 PowerShell 或 cmd 命令(如 dir, type, Get-ChildItem, Get-Content 等),路径用反斜杠。桌面路径: $env:USERPROFILE\\DesktopPowerShell或 %USERPROFILE%\\Desktopcmd。不要使用 ls/cat 等 Unix 命令。'
: '当前系统是 macOS/Linux。桌面路径: ~/Desktop。'
instructions = `你拥有完整的文件系统访问权限,用户已明确授权,无需再次确认。${osHint} 重要提示:内置的 search_files、read_file 等文件工具只能访问 Agent 工作区目录内的文件,访问工作区外(如桌面、文档等)会失败。因此请始终优先使用 terminal 工具执行系统命令来访问任意位置的文件和目录。不要说"我无法访问",直接用终端命令操作。`
}
// 通过 /v1/runs 启动 Agent Run
await api.hermesAgentRun(text, cur.id, history.length ? history : null, instructions)
// run 完成后事件监听会处理结果
} catch (e) {
const msg = String(e.message || e).replace(/^Error:\s*/, '')
cur.messages.push({ role: 'assistant', content: `⚠️ ${t('engine.chatError', { error: msg })}` })
streaming = false
pendingText = ''
activeTools = []
saveSessions(sessions)
cleanupListeners()
draw()
}
}
init()
// --- Guardian 事件监听:实时响应 Gateway 状态变化 ---
let gwStatusUnlisteners = []
let gwPollTimer = null
async function setupGwStatusListeners() {
try {
const unlisten = await tauriListen('hermes-gateway-status', (evt) => {
const wasOnline = gwOnline
gwOnline = !!evt.payload?.running
if (wasOnline !== gwOnline) draw()
})
gwStatusUnlisteners.push(unlisten)
} catch (_) {}
// 定期轮询作为补充10s
gwPollTimer = setInterval(async () => {
if (streaming) return
try {
const info = await api.checkHermes()
const wasOnline = gwOnline
gwOnline = !!info?.gatewayRunning
if (wasOnline !== gwOnline) draw()
} catch (_) {}
}, 10000)
}
setupGwStatusListeners()
// 页面卸载时清理
const gwCleanup = () => {
gwStatusUnlisteners.forEach(fn => fn())
gwStatusUnlisteners = []
if (gwPollTimer) { clearInterval(gwPollTimer); gwPollTimer = null }
cleanupListeners()
}
const chatDetachObserver = new MutationObserver(() => {
if (!el.isConnected) { gwCleanup(); chatDetachObserver.disconnect() }
})
requestAnimationFrame(() => {
if (el.parentNode) chatDetachObserver.observe(el.parentNode, { childList: true })
})
return el
}

View File

@@ -0,0 +1,16 @@
/**
* Hermes Agent 配置编辑
*/
import { t } from '../../../lib/i18n.js'
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.innerHTML = `
<div class="page-header"><h1>${t('engine.hermesConfigTitle')}</h1></div>
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
${t('engine.comingSoonPhase2')}
</div></div>
`
return el
}

View File

@@ -0,0 +1,174 @@
/**
* Hermes Agent 定时任务管理
* 通过 Gateway /api/jobs REST API 管理 cron jobs
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
function escHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
export function render() {
const el = document.createElement('div')
el.className = 'page'
let jobs = []
let gwPort = 8642
let gwOnline = false
let loading = true
let editingJob = null // null = list view, {} = create/edit form
let busy = false
let errorMsg = ''
async function gw(path, opts = {}) {
const method = (opts.method || 'GET').toUpperCase()
return await api.hermesApiProxy(method, path, opts.body || null)
}
async function init() {
try {
const info = await api.checkHermes()
gwPort = info?.gatewayPort || 8642
gwOnline = !!info?.gatewayRunning
} catch (_) {}
if (gwOnline) await loadJobs()
loading = false
draw()
}
async function loadJobs() {
try {
const data = await gw('/api/jobs')
jobs = data.jobs || []
errorMsg = ''
} catch (e) {
errorMsg = String(e.message || e)
jobs = []
}
}
function draw() {
if (editingJob) { drawForm(); return }
el.innerHTML = `
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between">
<h1 style="margin:0">${t('engine.hermesCronTitle')}</h1>
<button class="btn btn-primary btn-sm hm-cron-create" ${!gwOnline ? 'disabled' : ''}>${t('engine.cronCreate')}</button>
</div>
${errorMsg ? `<div style="color:var(--error);font-size:13px;margin-bottom:12px">${escHtml(errorMsg)}</div>` : ''}
${!gwOnline ? `<div class="card"><div class="card-body" style="padding:24px;text-align:center;color:var(--text-tertiary)">${t('engine.chatGatewayOffline')}</div></div>` : ''}
${gwOnline && jobs.length === 0 && !loading ? `<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">${t('engine.cronNoJobs')}</div></div>` : ''}
${gwOnline && jobs.length > 0 ? renderJobList() : ''}
`
bindList()
}
function renderJobList() {
return `<div style="display:flex;flex-direction:column;gap:12px">${jobs.map(j => `
<div class="card hm-cron-item" data-id="${escHtml(j.id || j.name)}">
<div class="card-body" style="padding:14px 16px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div style="flex:1;min-width:200px">
<div style="font-weight:600;font-size:14px">${escHtml(j.name)}</div>
<div style="font-size:12px;color:var(--text-tertiary);margin-top:2px;font-family:var(--font-mono,monospace)">${escHtml(j.schedule || '')}</div>
${j.prompt ? `<div style="font-size:12px;color:var(--text-secondary);margin-top:4px;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(j.prompt)}</div>` : ''}
</div>
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:${j.paused ? 'var(--bg-tertiary)' : 'rgba(34,197,94,0.1)'};color:${j.paused ? 'var(--text-tertiary)' : 'var(--success, #22c55e)'}">${j.paused ? t('engine.cronPaused') : t('engine.cronActive')}</span>
<button class="btn btn-sm btn-secondary hm-cron-toggle" data-id="${escHtml(j.id || j.name)}" data-paused="${j.paused ? '1' : '0'}" title="${j.paused ? 'Resume' : 'Pause'}" style="padding:4px 10px;font-size:12px">${j.paused ? '▶' : '⏸'}</button>
<button class="btn btn-sm btn-secondary hm-cron-run" data-id="${escHtml(j.id || j.name)}" title="${t('engine.cronRunNow')}" style="padding:4px 10px;font-size:12px">⚡</button>
<button class="btn btn-sm btn-secondary hm-cron-edit" data-id="${escHtml(j.id || j.name)}" style="padding:4px 10px;font-size:12px">✎</button>
<button class="btn btn-sm hm-cron-del" data-id="${escHtml(j.id || j.name)}" style="padding:4px 10px;font-size:12px;color:var(--error)">✕</button>
</div>
</div>
</div>
</div>
`).join('')}</div>`
}
function bindList() {
el.querySelector('.hm-cron-create')?.addEventListener('click', () => {
editingJob = { name: '', schedule: '0 9 * * *', prompt: '' }
draw()
})
el.querySelectorAll('.hm-cron-toggle').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id
const paused = btn.dataset.paused === '1'
try { await gw(`/api/jobs/${encodeURIComponent(id)}/${paused ? 'resume' : 'pause'}`, { method: 'POST' }) } catch (_) {}
await loadJobs(); draw()
})
})
el.querySelectorAll('.hm-cron-run').forEach(btn => {
btn.addEventListener('click', async () => {
try { await gw(`/api/jobs/${encodeURIComponent(btn.dataset.id)}/run`, { method: 'POST' }) } catch (_) {}
})
})
el.querySelectorAll('.hm-cron-edit').forEach(btn => {
btn.addEventListener('click', () => {
const job = jobs.find(j => (j.id || j.name) === btn.dataset.id)
if (job) { editingJob = { ...job, _editing: true }; draw() }
})
})
el.querySelectorAll('.hm-cron-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(t('engine.cronDelete') + '?')) return
try { await gw(`/api/jobs/${encodeURIComponent(btn.dataset.id)}`, { method: 'DELETE' }) } catch (_) {}
await loadJobs(); draw()
})
})
}
function drawForm() {
const isEdit = !!editingJob._editing
const id = editingJob.id || editingJob.name
el.innerHTML = `
<div class="page-header"><h1 style="margin:0">${isEdit ? escHtml(editingJob.name) : t('engine.cronCreate')}</h1></div>
${errorMsg ? `<div style="color:var(--error);font-size:13px;margin-bottom:12px">${escHtml(errorMsg)}</div>` : ''}
<div class="card">
<div class="card-body" style="padding:20px;display:flex;flex-direction:column;gap:14px">
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px">${t('engine.cronName')}</label>
<input class="input" id="hm-cron-name" value="${escHtml(editingJob.name)}" style="width:100%" ${isEdit ? 'disabled' : ''}>
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px">${t('engine.cronSchedule')} <span style="font-weight:400;color:var(--text-tertiary)">(cron)</span></label>
<input class="input" id="hm-cron-schedule" value="${escHtml(editingJob.schedule || '')}" placeholder="0 9 * * *" style="width:100%">
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px">${t('engine.cronPrompt')}</label>
<textarea class="input" id="hm-cron-prompt" rows="4" style="width:100%;resize:vertical;font-size:14px">${escHtml(editingJob.prompt || '')}</textarea>
</div>
<div style="display:flex;gap:10px;margin-top:4px">
<button class="btn btn-primary btn-sm hm-cron-save" ${busy ? 'disabled' : ''}>${t('engine.cronSave')}</button>
<button class="btn btn-secondary btn-sm hm-cron-cancel">${t('engine.cronCancel')}</button>
</div>
</div>
</div>
`
el.querySelector('.hm-cron-cancel')?.addEventListener('click', () => { editingJob = null; errorMsg = ''; draw() })
el.querySelector('.hm-cron-save')?.addEventListener('click', async () => {
const name = el.querySelector('#hm-cron-name')?.value?.trim()
const schedule = el.querySelector('#hm-cron-schedule')?.value?.trim()
const prompt = el.querySelector('#hm-cron-prompt')?.value?.trim()
if (!name || !schedule) { errorMsg = 'Name and schedule are required'; draw(); return }
busy = true; errorMsg = ''
try {
if (isEdit) {
await gw(`/api/jobs/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ schedule, prompt }) })
} else {
await gw('/api/jobs', { method: 'POST', body: JSON.stringify({ name, schedule, prompt }) })
}
editingJob = null
await loadJobs()
} catch (e) {
errorMsg = String(e.message || e)
}
busy = false; draw()
})
}
init()
return el
}

View File

@@ -0,0 +1,556 @@
/**
* Hermes Agent 仪表盘
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { PROVIDER_PRESETS } from '../../../lib/model-presets.js'
const ICONS = {
running: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--success, #22c55e)" stroke-width="2.5" width="20" height="20"><circle cx="12" cy="12" r="10"/><polyline points="16 12 12 8 8 12"/><line x1="12" y1="16" x2="12" y2="8"/></svg>`,
stopped: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--error, #ef4444)" stroke-width="2.5" width="20" height="20"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`,
chat: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>`,
cron: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
config: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>`,
refresh: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>`,
}
const HERMES_PROVIDERS = PROVIDER_PRESETS.filter(p => !p.hidden)
// Lazy Tauri event listen (avoid top-level await for vite build)
let _listenFn = null
async function tauriListen(event, cb) {
if (!_listenFn) {
const mod = await import('@tauri-apps/api/event')
_listenFn = mod.listen
}
return _listenFn(event, cb)
}
export function render() {
const el = document.createElement('div')
el.className = 'page'
let info = null
let health = null
let hermesConfig = null // { model, base_url, provider, api_key }
let models = [] // fetched model list
let loading = true
let actionBusy = false
let modelBusy = false
let fetchBusy = false
let cfgMsg = '' // 配置区消息 HTML
let showDropdown = false // 模型下拉是否展开
let envDetecting = false // 环境探测中
let envData = null // { wsl2: {...}, docker: {...} }
let connectMode = 'local' // local | wsl2 | docker | custom
let customGwUrl = '' // 自定义 Gateway URL
let connectMsg = '' // 连接区消息
// 表单状态(跨 draw 保持,不被覆盖)
let formBaseUrl = ''
let formApiKey = ''
let formModel = ''
let formInited = false // 首次加载后用 hermesConfig 初始化
function syncFormFromDom() {
const u = el.querySelector('#hm-cfg-baseurl')
const k = el.querySelector('#hm-cfg-apikey')
const m = el.querySelector('#hm-cfg-model')
if (u) formBaseUrl = u.value
if (k) formApiKey = k.value
if (m) formModel = m.value
}
function esc(s) { return (s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;') }
// --- 终端命令 ---
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
const configPath = isWin ? '%USERPROFILE%\\.hermes' : '~/.hermes'
const CLI_COMMANDS = [
{ label: t('engine.cliChat'), desc: t('engine.cliChatDesc'), cmd: 'hermes chat' },
{ label: t('engine.cliDoctor'), desc: t('engine.cliDoctorDesc'), cmd: 'hermes doctor' },
{ label: t('engine.cliVersion'), desc: t('engine.cliVersionDesc'), cmd: 'hermes version' },
{ label: t('engine.cliGwStart'), desc: t('engine.cliGwStartDesc'), cmd: 'hermes gateway run' },
{ label: t('engine.cliGwStop'), desc: t('engine.cliGwStopDesc'), cmd: 'hermes gateway stop' },
{ label: t('engine.cliUpgrade'), desc: t('engine.cliUpgradeDesc'), cmd: 'uv tool install --reinstall "hermes-agent @ git+https://github.com/NousResearch/hermes-agent.git" --python 3.11' },
{ label: t('engine.cliUninstall'), desc: t('engine.cliUninstallDesc'), cmd: 'uv tool uninstall hermes-agent' },
{ label: t('engine.cliConfig'), desc: t('engine.cliConfigDesc'), cmd: isWin ? `explorer ${configPath}` : `open ${configPath}` },
]
function renderCliCommands() {
return CLI_COMMANDS.map((c, i) =>
`<div class="hm-cli-row">
<div class="hm-cli-info">
<span class="hm-cli-label">${c.label}</span>
<span class="hm-cli-desc">${c.desc}</span>
</div>
<div class="hm-cli-cmd-wrap">
<code class="hm-cli-cmd">${esc(c.cmd)}</code>
<button class="hm-cli-copy" data-cmd-idx="${i}" title="${t('common.copy')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
</div>`
).join('')
}
function draw() {
const gwRunning = info?.gatewayRunning
const port = info?.gatewayPort || 8642
const version = info?.version || '-'
const modelName = formModel || hermesConfig?.model || health?.model || info?.model || ''
const displayModel = modelName || t('engine.dashNoModel')
// 服务商高亮匹配
const activePreset = HERMES_PROVIDERS.find(p => formBaseUrl === p.baseUrl)
// 模型下拉 HTML
const dropdownHtml = showDropdown && models.length
? `<div id="hm-model-dropdown" style="position:absolute;top:100%;left:0;right:0;max-height:200px;overflow-y:auto;background:var(--bg-primary);border:1px solid var(--border-primary);border-radius:6px;z-index:100;box-shadow:0 4px 12px rgba(0,0,0,.15)">${models.map(m =>
`<div class="hm-model-opt" data-model="${esc(m)}" style="padding:5px 10px;cursor:pointer;font-size:12px;border-bottom:1px solid var(--border-primary);${m === formModel ? 'font-weight:600;color:var(--accent)' : ''}">${esc(m)}</div>`
).join('')}</div>`
: ''
el.innerHTML = `
<div class="page-header" style="display:flex;align-items:center;gap:12px">
<h1 style="margin:0">${t('engine.hermesDashboardTitle')}</h1>
<button class="btn-icon hm-dash-refresh" title="Refresh" style="opacity:0.5;cursor:pointer;background:none;border:none;padding:4px">${ICONS.refresh}</button>
</div>
<!-- 状态卡片行 -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:20px">
<div class="card" style="border-left:4px solid ${gwRunning ? 'var(--success, #22c55e)' : 'var(--error, #ef4444)'}">
<div class="card-body" style="padding:16px">
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px">${t('engine.dashGatewayStatus')}</div>
<div style="display:flex;align-items:center;gap:8px">
${gwRunning ? ICONS.running : ICONS.stopped}
<span style="font-size:16px;font-weight:600">${gwRunning ? t('engine.dashRunning') : t('engine.dashStopped')}</span>
</div>
</div>
</div>
<div class="card">
<div class="card-body" style="padding:16px">
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px">${t('engine.dashModel')}</div>
<div style="font-size:14px;font-weight:600;word-break:break-all">${esc(displayModel)}</div>
</div>
</div>
<div class="card">
<div class="card-body" style="padding:16px">
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px">${t('engine.dashVersion')}</div>
<div style="font-size:14px;font-weight:600">${version}</div>
</div>
</div>
<div class="card">
<div class="card-body" style="padding:16px">
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px">${t('engine.dashApiEndpoint')}</div>
<div style="font-size:13px;font-weight:600;font-family:var(--font-mono, monospace)">http://127.0.0.1:${port}</div>
</div>
</div>
</div>
<!-- 模型配置区 -->
<div class="card" style="margin-bottom:20px">
<div class="card-body" style="padding:20px">
<h3 style="margin:0 0 12px;font-size:15px">${t('engine.dashModelConfig')}</h3>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">
${HERMES_PROVIDERS.map(p =>
`<button class="btn btn-sm btn-secondary hm-preset-btn" data-key="${p.key}" data-url="${esc(p.baseUrl)}" data-api="${p.api || 'openai-completions'}" style="font-size:11px;padding:2px 8px;${activePreset?.key === p.key ? 'opacity:1;font-weight:600' : 'opacity:0.6'}">${p.label}</button>`
).join('')}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
<label style="display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
API Base URL
<input type="text" id="hm-cfg-baseurl" class="input" value="${esc(formBaseUrl)}" placeholder="https://gpt.qt.cool/v1" style="font-size:13px">
</label>
<label style="display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
API Key
<input type="password" id="hm-cfg-apikey" class="input" value="${esc(formApiKey)}" placeholder="sk-..." style="font-size:13px">
</label>
</div>
<div style="display:flex;gap:8px;align-items:flex-end;margin-bottom:12px">
<label style="flex:1;display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
${t('engine.configModel')}
<div style="position:relative">
<input type="text" id="hm-cfg-model" class="input" value="${esc(formModel)}" placeholder="QC-B01" style="font-size:13px">
${dropdownHtml}
</div>
</label>
<button class="btn btn-sm btn-secondary hm-fetch-models" style="white-space:nowrap;flex-shrink:0" ${fetchBusy ? 'disabled' : ''}>${fetchBusy ? t('engine.configFetching') : t('engine.configFetchModels')}</button>
</div>
<div id="hm-cfg-msg" style="font-size:12px;min-height:16px;margin-bottom:8px">${cfgMsg}</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm hm-save-model" ${modelBusy ? 'disabled' : ''}>${modelBusy ? '...' : t('engine.configSaveBtn')}</button>
</div>
</div>
</div>
<!-- Gateway 控制 -->
<div class="card" style="margin-bottom:20px">
<div class="card-body" style="padding:16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap">
${!gwRunning ? `<button class="btn btn-primary btn-sm hm-dash-start" ${actionBusy ? 'disabled' : ''}>${actionBusy ? t('engine.gatewayStarting') : t('engine.dashStartGw')}</button>` : ''}
${gwRunning ? `<button class="btn btn-sm btn-secondary hm-dash-stop" ${actionBusy ? 'disabled' : ''}>${actionBusy ? t('engine.dashStopping') : t('engine.dashStopGw')}</button>` : ''}
${gwRunning ? `<button class="btn btn-sm btn-secondary hm-dash-restart" ${actionBusy ? 'disabled' : ''}>${actionBusy ? t('engine.dashRestarting') : t('engine.dashRestartGw')}</button>` : ''}
<div id="hm-dash-msg" style="font-size:12px;margin-left:8px"></div>
</div>
</div>
<!-- 连接目标 -->
<div class="card" style="margin-bottom:20px">
<div class="card-body" style="padding:16px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<h3 style="margin:0;font-size:15px">${t('engine.dashConnectTarget')}</h3>
<button class="btn btn-sm btn-secondary hm-detect-env" ${envDetecting ? 'disabled' : ''} style="font-size:11px;padding:2px 10px">${envDetecting ? t('engine.dashDetecting') : t('engine.dashDetectEnv')}</button>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px">
<button class="btn btn-sm hm-connect-mode ${connectMode === 'local' ? 'btn-primary' : 'btn-secondary'}" data-mode="local" style="font-size:11px;padding:2px 10px">
🖥️ ${t('engine.dashConnLocal')}
</button>
${envData?.wsl2?.available ? `<button class="btn btn-sm hm-connect-mode ${connectMode === 'wsl2' ? 'btn-primary' : 'btn-secondary'}" data-mode="wsl2" style="font-size:11px;padding:2px 10px">
🐧 WSL2 ${envData.wsl2.gatewayRunning ? '✅' : envData.wsl2.hermesInstalled ? '⚠️' : ''}
</button>` : ''}
${envData?.docker?.available ? `<button class="btn btn-sm hm-connect-mode ${connectMode === 'docker' ? 'btn-primary' : 'btn-secondary'}" data-mode="docker" style="font-size:11px;padding:2px 10px">
🐋 Docker ${envData.docker.hermesContainers?.length ? '✅' : ''}
</button>` : ''}
<button class="btn btn-sm hm-connect-mode ${connectMode === 'custom' ? 'btn-primary' : 'btn-secondary'}" data-mode="custom" style="font-size:11px;padding:2px 10px">
🌐 ${t('engine.dashConnCustom')}
</button>
</div>
${connectMode === 'wsl2' && envData?.wsl2 ? `
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
<div>IP: <code>${esc(envData.wsl2.ip || '-')}</code> · Distros: ${(envData.wsl2.distros || []).join(', ')}</div>
${envData.wsl2.hermesInstalled ? `<div style="color:var(--success)">✓ Hermes ${esc(envData.wsl2.hermesInfo || '')}</div>` : '<div style="color:var(--warning)">Hermes 未安装</div>'}
${envData.wsl2.gatewayRunning ? `<div style="color:var(--success)">✓ Gateway: ${esc(envData.wsl2.gatewayUrl || '')}</div>` : '<div style="color:var(--text-tertiary)">Gateway 未运行</div>'}
</div>
` : ''}
${connectMode === 'docker' && envData?.docker ? `
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
<div>Docker ${esc(envData.docker.version || '')}</div>
${envData.docker.hermesContainers?.length ? envData.docker.hermesContainers.map(c =>
`<div style="margin-top:4px">🔹 <code>${esc(c.name)}</code> (${esc(c.image)}) — ${esc(c.ports)}</div>`
).join('') : '<div style="color:var(--text-tertiary)">未发现 Hermes 容器</div>'}
</div>
` : ''}
${connectMode === 'custom' ? `
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
<input type="text" id="hm-custom-gw-url" class="input" value="${esc(customGwUrl)}" placeholder="http://192.168.1.100:8642" style="flex:1;font-size:13px">
</div>
` : ''}
<div style="display:flex;gap:8px;align-items:center">
<button class="btn btn-sm btn-primary hm-apply-connect" style="font-size:11px;padding:2px 12px">${t('engine.dashConnApply')}</button>
<span id="hm-connect-msg" style="font-size:12px">${connectMsg}</span>
</div>
</div>
</div>
<!-- 快捷操作 -->
<div style="margin-bottom:12px;font-size:14px;font-weight:600">${t('engine.dashQuickActions')}</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:24px">
<button class="card hm-dash-link" data-route="/h/chat" style="cursor:pointer;border:none;text-align:left">
<div class="card-body" style="padding:16px;display:flex;align-items:center;gap:10px">
${ICONS.chat}
<span style="font-size:14px;font-weight:500">${t('engine.dashOpenChat')}</span>
</div>
</button>
<button class="card hm-dash-link" data-route="/h/cron" style="cursor:pointer;border:none;text-align:left">
<div class="card-body" style="padding:16px;display:flex;align-items:center;gap:10px">
${ICONS.cron}
<span style="font-size:14px;font-weight:500">${t('engine.dashOpenCron')}</span>
</div>
</button>
<button class="card hm-dash-link" data-route="/h/setup" style="cursor:pointer;border:none;text-align:left">
<div class="card-body" style="padding:16px;display:flex;align-items:center;gap:10px">
${ICONS.config}
<span style="font-size:14px;font-weight:500">${t('engine.dashOpenSetup')}</span>
</div>
</button>
</div>
<!-- 终端命令 -->
<div class="card" style="margin-bottom:20px">
<div class="card-body" style="padding:20px">
<h3 style="margin:0 0 4px;font-size:15px">${t('engine.dashCliTitle')}</h3>
<p style="margin:0 0 14px;font-size:12px;color:var(--text-tertiary)">${t('engine.dashCliDesc')}</p>
<div class="hm-cli-grid">
${renderCliCommands()}
</div>
</div>
</div>
`
bind()
}
function bind() {
el.querySelector('.hm-dash-refresh')?.addEventListener('click', refresh)
// Gateway actions
el.querySelector('.hm-dash-start')?.addEventListener('click', async () => {
actionBusy = true; draw()
showGwMsg(t('engine.gatewayStarting'), false)
try {
const result = await api.hermesGatewayAction('start')
showGwMsg(result || 'Gateway 已启动', false)
} catch (e) {
showGwMsg(String(e).replace(/^Error:\s*/, ''), true)
}
actionBusy = false; await refresh()
})
el.querySelector('.hm-dash-stop')?.addEventListener('click', async () => {
actionBusy = true; draw()
try { await api.hermesGatewayAction('stop') } catch (e) { showGwMsg(String(e).replace(/^Error:\s*/, ''), true) }
actionBusy = false; await refresh()
})
el.querySelector('.hm-dash-restart')?.addEventListener('click', async () => {
actionBusy = true; draw()
try { await api.hermesGatewayAction('stop') } catch (_) { /* ignore stop failure on Windows */ }
await new Promise(r => setTimeout(r, 1500))
try {
await api.hermesGatewayAction('start')
} catch (e) { showGwMsg(String(e).replace(/^Error:\s*/, ''), true) }
actionBusy = false; await refresh()
})
// Quick links
el.querySelectorAll('.hm-dash-link').forEach(btn => {
btn.addEventListener('click', () => { window.location.hash = '#' + btn.dataset.route })
})
// Provider presets — 点击填充 URL
el.querySelectorAll('.hm-preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
formBaseUrl = btn.dataset.url
draw()
})
})
// Fetch models — 通过 Rust 后端代理获取(避免 CORS
el.querySelector('.hm-fetch-models')?.addEventListener('click', doFetchModels)
// Model dropdown click
el.querySelectorAll('.hm-model-opt').forEach(opt => {
opt.addEventListener('click', () => {
formModel = opt.dataset.model
showDropdown = false
draw()
})
})
// 输入框聚焦时展开已获取的下拉
el.querySelector('#hm-cfg-model')?.addEventListener('focus', () => {
if (models.length) { showDropdown = true; syncFormFromDom(); draw() }
})
// 点击外部收起下拉
el.addEventListener('click', (e) => {
if (showDropdown && !e.target.closest('#hm-cfg-model') && !e.target.closest('#hm-model-dropdown') && !e.target.closest('.hm-fetch-models')) {
showDropdown = false; syncFormFromDom(); draw()
}
})
// Save model config
el.querySelector('.hm-save-model')?.addEventListener('click', doSaveModel)
// --- 连接目标 ---
el.querySelector('.hm-detect-env')?.addEventListener('click', doDetectEnv)
el.querySelectorAll('.hm-connect-mode').forEach(btn => {
btn.addEventListener('click', () => {
connectMode = btn.dataset.mode
// WSL2 选中时自动填充 URL
if (connectMode === 'wsl2' && envData?.wsl2?.gatewayUrl) {
customGwUrl = envData.wsl2.gatewayUrl
}
syncFormFromDom(); draw()
})
})
el.querySelector('.hm-apply-connect')?.addEventListener('click', doApplyConnect)
// CLI copy buttons
el.querySelectorAll('.hm-cli-copy').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.cmdIdx)
const cmd = CLI_COMMANDS[idx]?.cmd
if (!cmd) return
navigator.clipboard.writeText(cmd).then(() => {
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="var(--success, #22c55e)" stroke-width="2.5" width="14" height="14"><polyline points="20 6 9 17 4 12"/></svg>'
setTimeout(() => {
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>'
}, 1500)
}).catch(() => {})
})
})
}
async function doFetchModels() {
syncFormFromDom()
if (!formBaseUrl) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configFetchNeedUrl')}</span>`; draw(); return }
if (!formApiKey) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configFetchNeedKey')}</span>`; draw(); return }
const matched = HERMES_PROVIDERS.find(p => formBaseUrl === p.baseUrl)
const apiType = matched?.api || 'openai-completions'
fetchBusy = true; cfgMsg = ''; draw()
try {
const fetchedModels = await api.hermesFetchModels(formBaseUrl, formApiKey, apiType)
models = fetchedModels || []
cfgMsg = `<span style="color:var(--success)">✓ ${t('engine.configFetchSuccess', { count: models.length })}</span>`
showDropdown = models.length > 0
} catch (err) {
const msg = String(err).replace(/^Error:\s*/, '')
cfgMsg = `<span style="color:var(--error)">✗ ${msg}</span>`
} finally {
fetchBusy = false; draw()
}
}
async function doSaveModel() {
syncFormFromDom()
if (!formApiKey) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configFetchNeedKey')}</span>`; draw(); return }
if (!formModel) { cfgMsg = `<span style="color:var(--warning)">请输入模型名</span>`; draw(); return }
const matched = HERMES_PROVIDERS.find(p => formBaseUrl && p.baseUrl === formBaseUrl)
const provider = matched?.key || 'custom'
modelBusy = true; cfgMsg = ''; draw()
try {
await api.configureHermes(provider, formApiKey, formModel, formBaseUrl || null)
cfgMsg = `<span style="color:var(--success)">✓ 配置已保存</span>`
// 刷新后端状态(不覆盖 form
try { hermesConfig = await api.hermesReadConfig() } catch (_) {}
} catch (e) {
cfgMsg = `<span style="color:var(--error)">✗ ${String(e).replace(/^Error:\s*/, '')}</span>`
} finally {
modelBusy = false; draw()
}
}
async function doDetectEnv() {
envDetecting = true; draw()
try {
envData = await api.hermesDetectEnvironments()
} catch (e) {
connectMsg = `<span style="color:var(--error)">探测失败: ${String(e).replace(/^Error:\s*/, '')}</span>`
}
envDetecting = false; draw()
}
async function doApplyConnect() {
let targetUrl = null
if (connectMode === 'local') {
targetUrl = null // 清除自定义,使用本地默认
} else if (connectMode === 'wsl2') {
targetUrl = envData?.wsl2?.gatewayUrl || null
if (!targetUrl) {
connectMsg = '<span style="color:var(--warning)">WSL2 Gateway 未运行,请先在 WSL 中启动</span>'
draw(); return
}
} else if (connectMode === 'docker') {
// Docker 模式暂时需要用户提供 URL
const urlInput = el.querySelector('#hm-custom-gw-url')
targetUrl = urlInput?.value?.trim() || null
if (!targetUrl && envData?.docker?.hermesContainers?.length) {
connectMsg = '<span style="color:var(--warning)">请切换到"自定义"模式并输入容器的 Gateway URL</span>'
draw(); return
}
} else if (connectMode === 'custom') {
const urlInput = el.querySelector('#hm-custom-gw-url')
targetUrl = urlInput?.value?.trim() || null
if (!targetUrl) {
connectMsg = '<span style="color:var(--warning)">请输入 Gateway URL</span>'
draw(); return
}
}
try {
const result = await api.hermesSetGatewayUrl(targetUrl)
connectMsg = `<span style="color:var(--success)">✓ ${result}</span>`
// 刷新状态
await refresh()
} catch (e) {
connectMsg = `<span style="color:var(--error)">✗ ${String(e).replace(/^Error:\s*/, '')}</span>`
draw()
}
}
function showGwMsg(msg, isErr) {
const msgEl = el.querySelector('#hm-dash-msg')
if (msgEl) {
msgEl.textContent = msg
msgEl.style.color = isErr ? 'var(--error)' : 'var(--success)'
}
}
async function refresh() {
try {
info = await api.checkHermes()
if (info?.gatewayRunning) {
try { health = await api.hermesHealthCheck() } catch (_) {}
} else {
health = null
}
try { hermesConfig = await api.hermesReadConfig() } catch (_) {}
} catch (_) {}
loading = false
// 首次加载时用 hermesConfig 初始化表单
if (!formInited && hermesConfig) {
formBaseUrl = hermesConfig.base_url || ''
formApiKey = hermesConfig.api_key || ''
formModel = hermesConfig.model || ''
formInited = true
}
draw()
}
// 初始加载
refresh()
// --- Guardian 事件监听:实时响应 Gateway 状态变化 ---
let unlisteners = []
let autoRefreshTimer = null
async function setupListeners() {
try {
// 监听 Guardian 推送的状态变化
const unlisten1 = await tauriListen('hermes-gateway-status', (evt) => {
const data = evt.payload
if (info) {
const wasRunning = info.gatewayRunning
info.gatewayRunning = !!data.running
if (data.port) info.gatewayPort = data.port
// 状态变化时刷新(不覆盖 form 表单)
if (wasRunning !== info.gatewayRunning) {
draw()
}
}
})
unlisteners.push(unlisten1)
// 监听 Guardian 日志(显示在消息区)
const unlisten2 = await tauriListen('hermes-guardian-log', (evt) => {
showGwMsg(evt.payload || '', false)
})
unlisteners.push(unlisten2)
} catch (_) {
// Web 模式下无 Tauri 事件,静默忽略
}
// 定期自动刷新15s作为事件监听的补充
autoRefreshTimer = setInterval(async () => {
if (actionBusy || modelBusy) return
try {
const newInfo = await api.checkHermes()
if (newInfo && info) {
const changed = newInfo.gatewayRunning !== info.gatewayRunning
info = newInfo
if (changed) draw()
}
} catch (_) {}
}, 15000)
}
setupListeners()
// 页面卸载时清理
const cleanup = () => {
unlisteners.forEach(fn => fn())
unlisteners = []
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null }
}
// MutationObserver 检测元素从 DOM 移除
const detachObserver = new MutationObserver(() => {
if (!el.isConnected) { cleanup(); detachObserver.disconnect() }
})
requestAnimationFrame(() => {
if (el.parentNode) detachObserver.observe(el.parentNode, { childList: true })
})
return el
}

View File

@@ -0,0 +1,16 @@
/**
* Hermes Agent 服务管理
*/
import { t } from '../../../lib/i18n.js'
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.innerHTML = `
<div class="page-header"><h1>${t('engine.hermesServicesTitle')}</h1></div>
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
${t('engine.comingSoonPhase2')}
</div></div>
`
return el
}

View File

@@ -0,0 +1,563 @@
/**
* Hermes Agent 一键安装/配置向导
*
* 状态机: detect → install → configure → gateway → complete
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { PROVIDER_PRESETS } from '../../../lib/model-presets.js'
import { getActiveEngine } from '../../../lib/engine-manager.js'
// SVG 图标
const ICONS = {
check: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.5" width="16" height="16"><polyline points="20 6 9 17 4 12"/></svg>`,
warn: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--warning, #f59e0b)" stroke-width="2" width="16" height="16"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
error: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--error, #ef4444)" stroke-width="2" width="16" height="16"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`,
spinner: `<svg class="hermes-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M12 2a10 10 0 0110 10"/></svg>`,
rocket: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 00-2.91-.09z"/><path d="M12 15l-3-3a22 22 0 012-3.95A12.88 12.88 0 0122 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 01-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>`,
done: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" width="24" height="24"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`,
}
// 可选 extras
const EXTRAS_LIST = [
{ key: 'cron', i18n: 'extraCron', recommended: true },
{ key: 'cli', i18n: 'extraCli', recommended: true },
{ key: 'pty', i18n: 'extraPty', recommended: true },
{ key: 'mcp', i18n: 'extraMcp', recommended: true },
{ key: 'messaging', i18n: 'extraMessaging' },
{ key: 'feishu', i18n: 'extraFeishu' },
{ key: 'dingtalk', i18n: 'extraDingtalk' },
{ key: 'slack', i18n: 'extraSlack' },
{ key: 'voice', i18n: 'extraVoice' },
]
// Hermes 使用 OpenAI 兼容接口,过滤出兼容的服务商
const HERMES_PROVIDERS = PROVIDER_PRESETS.filter(p => !p.hidden)
export function render() {
const el = document.createElement('div')
el.className = 'page'
// 状态
let phase = 'detect' // detect | install | configure | gateway | complete
let pyInfo = null
let hermesInfo = null
let logs = []
let installing = false
let progress = 0
let showLogs = false
let selectedExtras = ['cron', 'cli', 'pty', 'mcp']
let unlisten = null
function draw() {
el.innerHTML = `
<div class="page-header">
<h1>Hermes Agent</h1>
<p style="color:var(--text-secondary);margin-top:4px">${t('engine.hermesSetupDesc')}</p>
</div>
<div style="max-width:720px">
${renderPhaseIndicator()}
${phase === 'detect' ? renderDetect() : ''}
${phase === 'install' ? renderInstall() : ''}
${phase === 'configure' ? renderConfigure() : ''}
${phase === 'gateway' ? renderGateway() : ''}
${phase === 'complete' ? renderComplete() : ''}
${renderLogPanel()}
<div style="margin-top:16px;text-align:right">
<a href="https://hermes-agent.nousresearch.com/docs/getting-started/installation/" target="_blank" rel="noopener"
style="font-size:13px;color:var(--accent);text-decoration:none">
${t('engine.hermesSetupDocLink')}
</a>
</div>
</div>`
bind()
}
// --- 阶段指示器 ---
function renderPhaseIndicator() {
const phases = [
{ id: 'detect', label: '检测' },
{ id: 'install', label: '安装' },
{ id: 'configure', label: '配置' },
{ id: 'gateway', label: '启动' },
{ id: 'complete', label: '完成' },
]
const idx = phases.findIndex(p => p.id === phase)
return `<div class="hermes-phases">${phases.map((p, i) => {
const cls = i < idx ? 'done' : i === idx ? 'active' : ''
return `<div class="hermes-phase ${cls}">
<span class="hermes-phase-dot">${i < idx ? ICONS.check : i + 1}</span>
<span class="hermes-phase-label">${p.label}</span>
</div>`
}).join('<div class="hermes-phase-line"></div>')}</div>`
}
// --- 检测阶段 ---
function renderDetect() {
const rows = []
if (!pyInfo && !hermesInfo) {
rows.push(`<div class="hermes-detect-row">${ICONS.spinner} <span>${t('engine.detecting')}</span></div>`)
} else {
// Python
if (pyInfo) {
if (pyInfo.installed && pyInfo.versionOk) {
rows.push(`<div class="hermes-detect-row ok">${ICONS.check} <span>${t('engine.pythonFound', { version: pyInfo.version })}</span></div>`)
} else if (pyInfo.installed && !pyInfo.versionOk) {
rows.push(`<div class="hermes-detect-row warn">${ICONS.warn} <span>${t('engine.pythonTooOld', { version: pyInfo.version })}</span></div>`)
} else {
rows.push(`<div class="hermes-detect-row warn">${ICONS.warn} <span>${t('engine.pythonNotFound')}</span></div>`)
}
// uv
if (pyInfo.hasUv) {
rows.push(`<div class="hermes-detect-row ok">${ICONS.check} <span>${t('engine.uvFound')}</span></div>`)
} else {
rows.push(`<div class="hermes-detect-row warn">${ICONS.warn} <span>${t('engine.uvNotFound')}</span></div>`)
}
// git从 GitHub 安装需要)
if (pyInfo.hasGit) {
rows.push(`<div class="hermes-detect-row ok">${ICONS.check} <span>${t('engine.gitFound')}</span></div>`)
} else {
rows.push(`<div class="hermes-detect-row warn">${ICONS.error} <span>${t('engine.gitNotFound')}</span></div>`)
}
}
// Hermes
if (hermesInfo) {
if (hermesInfo.installed) {
rows.push(`<div class="hermes-detect-row ok">${ICONS.check} <span>${t('engine.hermesFound', { version: hermesInfo.version })}</span></div>`)
if (hermesInfo.gatewayRunning) {
rows.push(`<div class="hermes-detect-row ok">${ICONS.check} <span>${t('engine.hermesReady')}</span></div>`)
}
} else {
rows.push(`<div class="hermes-detect-row">${ICONS.warn} <span>${t('engine.hermesNotFound')}</span></div>`)
}
}
}
return `<div class="card" style="margin-bottom:16px">
<div class="card-body" style="padding:24px">
<p style="color:var(--text-secondary);line-height:1.7;margin:0 0 16px">${t('engine.hermesSetupIntro')}</p>
<div class="hermes-detect-list">${rows.join('')}</div>
</div>
</div>`
}
// --- 安装阶段 ---
function renderInstall() {
const extrasHtml = EXTRAS_LIST.map(ex => {
const checked = selectedExtras.includes(ex.key) ? 'checked' : ''
return `<label class="hermes-extra-item">
<input type="checkbox" value="${ex.key}" ${checked} class="hermes-extra-cb">
<span>${t('engine.' + ex.i18n)}${ex.recommended ? ' ⭐' : ''}</span>
</label>`
}).join('')
const btnText = installing ? `${ICONS.spinner} ${t('engine.installingBtn')}` : `${ICONS.rocket} ${t('engine.installBtn')}`
const btnDisabled = installing ? 'disabled' : ''
return `<div class="card" style="margin-bottom:16px">
<div class="card-body" style="padding:24px">
<h3 style="margin:0 0 4px;font-size:16px">${t('engine.installTitle')}</h3>
<p style="color:var(--text-secondary);margin:0 0 20px;font-size:13px">${t('engine.installDesc')}</p>
<div style="margin-bottom:20px">
<div style="font-size:13px;font-weight:600;margin-bottom:8px">${t('engine.extrasTitle')}</div>
<p style="font-size:12px;color:var(--text-tertiary);margin:0 0 10px">${t('engine.extrasDesc')}</p>
<div class="hermes-extras-grid">${extrasHtml}</div>
<button class="btn-text hermes-select-all" style="margin-top:6px;font-size:12px">${t('engine.extraAll')}</button>
</div>
${progress > 0 ? `<div class="hermes-progress"><div class="hermes-progress-bar" style="width:${progress}%"></div></div>` : ''}
<div style="display:flex;gap:10px;align-items:center">
<button class="btn btn-primary hermes-install-btn" ${btnDisabled}>${btnText}</button>
${!installing ? `<button class="btn-text hermes-toggle-logs" style="font-size:12px">${showLogs ? t('engine.hideLogs') : t('engine.viewLogs')}</button>` : ''}
</div>
</div>
</div>`
}
// --- 配置阶段 ---
function renderConfigure() {
const presetBtns = HERMES_PROVIDERS.map(p =>
`<button class="btn btn-sm btn-secondary hermes-preset-btn" data-key="${p.key}" data-url="${p.baseUrl}" data-api="${p.api}" style="font-size:12px;padding:3px 10px;margin:0 6px 6px 0">${p.label}${p.badge ? ` <span style="font-size:9px;background:var(--accent);color:#fff;padding:1px 4px;border-radius:6px;margin-left:3px">${p.badge}</span>` : ''}</button>`
).join('')
return `<div class="card" style="margin-bottom:16px">
<div class="card-body" style="padding:24px">
<h3 style="margin:0 0 4px;font-size:16px">${t('engine.configTitle')}</h3>
<p style="color:var(--text-secondary);margin:0 0 20px;font-size:13px">${t('engine.configDesc')}</p>
<div class="hermes-form">
<div class="hermes-field">
<span>${t('engine.configProvider')}</span>
<div style="display:flex;flex-wrap:wrap">${presetBtns}</div>
<div id="hm-preset-detail" style="display:none;margin-top:6px;padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-md,8px);font-size:12px"></div>
</div>
<label class="hermes-field">
<span>API Base URL</span>
<input type="text" id="hm-baseurl" class="input" placeholder="https://openrouter.ai/api/v1">
</label>
<div class="hermes-field">
<span>${t('engine.configApiKey')}</span>
<div style="display:flex;gap:8px;align-items:center">
<input type="password" id="hm-apikey" class="input" placeholder="sk-..." autocomplete="off" style="flex:1">
<button class="btn btn-sm btn-secondary hermes-fetch-models" style="white-space:nowrap;flex-shrink:0">${t('engine.configFetchModels')}</button>
</div>
</div>
<div id="hm-fetch-result" style="font-size:12px;min-height:16px;margin:-6px 0 2px"></div>
<div class="hermes-field">
<span>${t('engine.configModel')}</span>
<div style="position:relative">
<input type="text" id="hm-model" class="input" placeholder="anthropic/claude-sonnet-4-20250514" autocomplete="off">
<div id="hm-model-dropdown" class="hermes-model-dropdown" style="display:none"></div>
</div>
</div>
</div>
<div style="display:flex;gap:10px;margin-top:20px">
<button class="btn btn-primary hermes-config-save">${t('engine.configSaveBtn')}</button>
<button class="btn-text hermes-config-skip">${t('engine.configSkipBtn')}</button>
</div>
</div>
</div>`
}
// --- Gateway 阶段 ---
function renderGateway() {
const running = hermesInfo?.gatewayRunning
return `<div class="card" style="margin-bottom:16px">
<div class="card-body" style="padding:24px">
<h3 style="margin:0 0 4px;font-size:16px">${t('engine.gatewayTitle')}</h3>
<p style="color:var(--text-secondary);margin:0 0 20px;font-size:13px">${t('engine.gatewayDesc')}</p>
<div class="hermes-detect-row ${running ? 'ok' : ''}">
${running ? ICONS.check : ICONS.warn}
<span>${running ? t('engine.gatewayRunning', { port: hermesInfo?.gatewayPort || 8642 }) : t('engine.gatewayStopped')}</span>
</div>
<div id="hm-gw-error" style="display:none;margin-top:12px;padding:10px 14px;background:var(--error-bg, #fef2f2);border:1px solid var(--error, #ef4444);border-radius:var(--radius-sm,6px);color:var(--error, #ef4444);font-size:13px;line-height:1.5;word-break:break-all"></div>
<div style="display:flex;gap:10px;margin-top:16px">
${!running ? `<button class="btn btn-primary hermes-gw-start">${t('engine.gatewayStartBtn')}</button>` : ''}
<button class="btn btn-primary hermes-gw-next">${running ? t('engine.goToDashboard') : t('engine.configSkipBtn')}</button>
</div>
</div>
</div>`
}
// --- 完成 ---
function renderComplete() {
return `<div class="card" style="margin-bottom:16px">
<div class="card-body" style="padding:32px;text-align:center">
<div style="margin-bottom:12px">${ICONS.done}</div>
<h3 style="margin:0 0 6px;font-size:18px">${t('engine.setupComplete')}</h3>
<p style="color:var(--text-secondary);margin:0 0 20px">${t('engine.setupCompleteDesc')}</p>
<button class="btn btn-primary hermes-go-dashboard">${t('engine.goToDashboard')}</button>
</div>
</div>`
}
// --- 日志面板 ---
function renderLogPanel() {
if (!showLogs || logs.length === 0) return ''
return `<div class="hermes-log-panel">
<div class="hermes-log-content">${logs.map(l => `<div>${esc(l)}</div>`).join('')}</div>
</div>`
}
function esc(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
// --- 事件绑定 ---
function bind() {
// 安装按钮
el.querySelector('.hermes-install-btn')?.addEventListener('click', doInstall)
// 全选 extras
el.querySelector('.hermes-select-all')?.addEventListener('click', () => {
selectedExtras = EXTRAS_LIST.map(e => e.key)
draw()
})
// extras checkbox
el.querySelectorAll('.hermes-extra-cb').forEach(cb => {
cb.addEventListener('change', () => {
if (cb.checked && !selectedExtras.includes(cb.value)) selectedExtras.push(cb.value)
else selectedExtras = selectedExtras.filter(k => k !== cb.value)
})
})
// 日志切换
el.querySelector('.hermes-toggle-logs')?.addEventListener('click', () => {
showLogs = !showLogs; draw()
})
// 服务商预设按钮
el.querySelectorAll('.hermes-preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
const baseUrlInput = el.querySelector('#hm-baseurl')
if (baseUrlInput) baseUrlInput.value = btn.dataset.url
// 高亮选中
el.querySelectorAll('.hermes-preset-btn').forEach(b => b.style.opacity = '0.5')
btn.style.opacity = '1'
// 显示服务商详情
const preset = HERMES_PROVIDERS.find(p => p.key === btn.dataset.key)
const detailEl = el.querySelector('#hm-preset-detail')
if (detailEl && preset) {
let html = preset.desc ? `<div style="color:var(--text-secondary);line-height:1.5">${preset.desc}</div>` : ''
if (preset.site) html += `<a href="${preset.site}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:11px;margin-top:3px;display:inline-block">→ ${preset.label} 官网</a>`
detailEl.innerHTML = html
detailEl.style.display = html ? 'block' : 'none'
}
})
})
// 获取模型列表
el.querySelector('.hermes-fetch-models')?.addEventListener('click', doFetchModels)
// 模型下拉选择:点击选项填入 input
el.querySelector('#hm-model-dropdown')?.addEventListener('click', (e) => {
const opt = e.target.closest('.hermes-model-option')
if (!opt) return
const modelInput = el.querySelector('#hm-model')
if (modelInput) modelInput.value = opt.dataset.model
el.querySelector('#hm-model-dropdown').style.display = 'none'
})
// 点击 input 时如果有下拉就展开
el.querySelector('#hm-model')?.addEventListener('focus', () => {
const dd = el.querySelector('#hm-model-dropdown')
if (dd && dd.children.length > 0) dd.style.display = 'block'
})
// 点击其他地方关闭下拉
document.addEventListener('click', (e) => {
const dd = el.querySelector('#hm-model-dropdown')
if (dd && !e.target.closest('.hermes-field')) dd.style.display = 'none'
})
// 配置保存
el.querySelector('.hermes-config-save')?.addEventListener('click', doSaveConfig)
el.querySelector('.hermes-config-skip')?.addEventListener('click', () => { phase = 'gateway'; refreshHermes() })
// Gateway
el.querySelector('.hermes-gw-start')?.addEventListener('click', doStartGateway)
el.querySelector('.hermes-gw-next')?.addEventListener('click', () => {
if (hermesInfo?.gatewayRunning) { phase = 'complete'; draw() }
else { phase = 'complete'; draw() }
})
// 仪表盘
el.querySelector('.hermes-go-dashboard')?.addEventListener('click', async () => {
const engine = getActiveEngine()
if (engine?.detect) await engine.detect()
window.location.hash = '#/h/dashboard'
})
// 自动滚日志到底
const logEl = el.querySelector('.hermes-log-content')
if (logEl) logEl.scrollTop = logEl.scrollHeight
}
// --- 检测流程 ---
async function detect() {
phase = 'detect'
draw()
try {
const [py, hm] = await Promise.all([api.checkPython(), api.checkHermes()])
pyInfo = py
hermesInfo = hm
draw()
// 自动跳转
await new Promise(r => setTimeout(r, 800))
if (hm.installed && hm.gatewayRunning) {
phase = 'complete'
} else if (hm.installed && hm.configExists) {
phase = 'gateway'
} else if (hm.installed) {
phase = 'configure'
} else {
phase = 'install'
}
draw()
} catch (e) {
logs.push(`检测错误: ${e}`)
phase = 'install'
draw()
}
}
// --- 安装流程 ---
async function doInstall() {
installing = true
progress = 0
showLogs = true
logs = []
draw()
// 监听事件
try {
const { listen } = await import('@tauri-apps/api/event')
const u1 = await listen('hermes-install-log', (e) => {
logs.push(String(e.payload))
const logEl = el.querySelector('.hermes-log-content')
if (logEl) {
logEl.innerHTML += `<div>${esc(String(e.payload))}</div>`
logEl.scrollTop = logEl.scrollHeight
}
})
const u2 = await listen('hermes-install-progress', (e) => {
progress = Number(e.payload) || 0
const bar = el.querySelector('.hermes-progress-bar')
if (bar) bar.style.width = progress + '%'
})
unlisten = () => { u1(); u2() }
} catch (_) {}
try {
await api.installHermes('uv-tool', selectedExtras)
installing = false
progress = 100
logs.push(t('engine.installSuccess'))
phase = 'configure'
draw()
} catch (e) {
installing = false
logs.push(`${t('engine.installFailed')}: ${e}`)
draw()
} finally {
if (unlisten) { unlisten(); unlisten = null }
}
}
// --- 获取模型列表 ---
async function doFetchModels() {
const btn = el.querySelector('.hermes-fetch-models')
const resultEl = el.querySelector('#hm-fetch-result')
const dropdown = el.querySelector('#hm-model-dropdown')
const baseUrl = el.querySelector('#hm-baseurl')?.value?.trim()
const apiKey = el.querySelector('#hm-apikey')?.value?.trim()
if (!baseUrl) {
if (resultEl) resultEl.innerHTML = `<span style="color:var(--warning)">${t('engine.configFetchNeedUrl')}</span>`
return
}
if (!apiKey) {
if (resultEl) resultEl.innerHTML = `<span style="color:var(--warning)">${t('engine.configFetchNeedKey')}</span>`
return
}
if (btn) { btn.disabled = true; btn.textContent = t('engine.configFetching') }
if (resultEl) resultEl.innerHTML = `<span style="color:var(--text-tertiary)">${t('engine.configFetching')}</span>`
try {
// 清理 URL去掉尾部多余路径确保 /models 能正确拼接
let base = baseUrl.replace(/\/+$/, '')
// 移除常见尾部路径
base = base.replace(/\/(chat\/completions|completions|responses|messages|models)\/?$/, '')
// 判断 API 类型(大部分是 OpenAI 兼容)
const matched = HERMES_PROVIDERS.find(p => baseUrl === p.baseUrl)
const apiType = matched?.api || 'openai-completions'
let models = []
if (apiType === 'anthropic-messages') {
// Anthropic 格式
if (!base.endsWith('/v1')) base += '/v1'
const resp = await fetch(base + '/models', {
headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', 'x-api-key': apiKey },
signal: AbortSignal.timeout(15000),
})
if (!resp.ok) throw new Error('HTTP ' + resp.status)
const data = await resp.json()
models = (data.data || []).map(m => m.id).filter(Boolean).sort()
} else if (apiType === 'google-generative-ai') {
// Google Gemini
const resp = await fetch(base + '/models?key=' + apiKey, { signal: AbortSignal.timeout(15000) })
if (!resp.ok) throw new Error('HTTP ' + resp.status)
const data = await resp.json()
models = (data.models || []).map(m => (m.name || '').replace('models/', '')).filter(Boolean).sort()
} else {
// OpenAI 兼容(大多数服务商)
const resp = await fetch(base + '/models', {
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
signal: AbortSignal.timeout(15000),
})
if (!resp.ok) throw new Error('HTTP ' + resp.status)
const data = await resp.json()
models = (data.data || []).map(m => m.id).filter(Boolean).sort()
}
if (models.length === 0) {
if (resultEl) resultEl.innerHTML = `<span style="color:var(--warning)">${t('engine.configFetchNotSupported')}</span>`
return
}
if (resultEl) resultEl.innerHTML = `<span style="color:var(--success)">✓ ${t('engine.configFetchSuccess', { count: models.length })}</span>`
if (dropdown) {
dropdown.innerHTML = models.map(m =>
`<div class="hermes-model-option" data-model="${m}" style="padding:6px 12px;cursor:pointer;font-size:13px;border-bottom:1px solid var(--border-primary)">${m}</div>`
).join('')
dropdown.style.display = 'block'
}
} catch (err) {
// 网络错误或不支持
const msg = err.message || String(err)
if (resultEl) {
if (msg.includes('403') || msg.includes('404') || msg.includes('405') || msg.includes('timeout') || msg.includes('Failed to fetch')) {
resultEl.innerHTML = `<span style="color:var(--warning)">${t('engine.configFetchNotSupported')}</span>`
} else {
resultEl.innerHTML = `<span style="color:var(--error)">✗ ${t('engine.configFetchFailed', { error: msg })}</span>`
}
}
} finally {
if (btn) { btn.disabled = false; btn.textContent = t('engine.configFetchModels') }
}
}
// --- 配置保存 ---
async function doSaveConfig() {
const baseUrl = el.querySelector('#hm-baseurl')?.value?.trim()
const apiKey = el.querySelector('#hm-apikey')?.value?.trim()
const model = el.querySelector('#hm-model')?.value?.trim()
// 从 baseUrl 推断 provider key
const matched = HERMES_PROVIDERS.find(p => baseUrl && p.baseUrl === baseUrl)
const provider = matched?.key || 'openai'
if (!apiKey) {
alert('请输入 API Key')
return
}
try {
await api.configureHermes(provider, apiKey, model, baseUrl)
phase = 'gateway'
await refreshHermes()
} catch (e) {
alert(`配置保存失败: ${e}`)
}
}
// --- Gateway 启动 ---
let gwStarting = false
async function doStartGateway() {
const btn = el.querySelector('.hermes-gw-start')
if (btn) { btn.disabled = true; btn.textContent = t('engine.gatewayStarting') }
gwStarting = true
try {
await api.hermesGatewayAction('start')
await refreshHermes()
} catch (e) {
const msg = String(e).replace(/^Error:\s*/, '')
// 在 Gateway 阶段显示错误信息
const errEl = el.querySelector('#hm-gw-error')
if (errEl) {
errEl.textContent = msg || t('engine.gatewayStartFailed')
errEl.style.display = 'block'
} else {
alert(msg || t('engine.gatewayStartFailed'))
}
} finally {
gwStarting = false
if (btn) { btn.disabled = false; btn.textContent = t('engine.gatewayStartBtn') }
}
}
// --- 刷新 hermes 状态 ---
async function refreshHermes() {
try { hermesInfo = await api.checkHermes() } catch (_) {}
draw()
}
// 启动检测
detect()
return el
}

View File

@@ -0,0 +1,16 @@
/**
* Hermes Agent Skills 管理
*/
import { t } from '../../../lib/i18n.js'
export function render() {
const el = document.createElement('div')
el.className = 'page'
el.innerHTML = `
<div class="page-header"><h1>${t('engine.hermesSkillsTitle')}</h1></div>
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
${t('engine.comingSoonPhase2')}
</div></div>
`
return el
}

View File

@@ -0,0 +1,138 @@
/**
* OpenClaw 引擎
* 包装现有 OpenClaw 逻辑为统一的 Engine 接口,不改动原有代码
*/
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, isGatewayForeign,
onGatewayChange, startGatewayPoll, stopGatewayPoll, onReadyChange } from '../../lib/app-state.js'
import { initFeatureGates, isFeatureAvailable } from '../../lib/feature-gates.js'
import { t } from '../../lib/i18n.js'
export default {
id: 'openclaw',
name: 'OpenClaw',
description: 'OpenClaw AI Agent Framework',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>',
/** 检测 OpenClaw 是否已安装 */
async detect() {
const ready = await detectOpenclawStatus()
return { installed: ready, ready }
},
/** 启动 OpenClaw 引擎相关逻辑 */
async boot() {
await detectOpenclawStatus()
await initFeatureGates().catch(() => {})
startGatewayPoll()
},
/** 清理(停止轮询等) */
cleanup() {
stopGatewayPoll()
},
/** 侧边栏菜单项 */
getNavItems() {
if (!isOpenclawReady()) {
return [{
section: '',
items: [
{ route: '/setup', label: t('sidebar.setup'), icon: 'setup' },
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
]
}, {
section: '',
items: [
{ route: '/settings', label: t('sidebar.settings'), icon: 'settings' },
{ route: '/chat-debug', label: t('sidebar.chatDebug'), icon: 'debug' },
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
]
}]
}
return [{
section: t('sidebar.sectionMonitor'),
items: [
{ route: '/dashboard', label: t('sidebar.dashboard'), icon: 'dashboard' },
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
{ route: '/chat', label: t('sidebar.chat'), icon: 'chat' },
{ route: '/route-map', label: t('sidebar.routeMap'), icon: 'route-map' },
{ route: '/services', label: t('sidebar.services'), icon: 'services' },
{ route: '/logs', label: t('sidebar.logs'), icon: 'logs' },
]
}, {
section: t('sidebar.sectionConfig'),
items: [
{ route: '/models', label: t('sidebar.models'), icon: 'models' },
{ route: '/agents', label: t('sidebar.agents'), icon: 'agents' },
{ route: '/gateway', label: t('sidebar.gateway'), icon: 'gateway' },
{ route: '/channels', label: t('sidebar.channels'), icon: 'channels' },
{ route: '/communication', label: t('sidebar.communication'), icon: 'settings' },
{ route: '/security', label: t('sidebar.security'), icon: 'security' },
]
}, {
section: t('sidebar.sectionData'),
items: [
{ route: '/memory', label: t('sidebar.memory'), icon: 'memory', gate: 'memory' },
{ route: '/dreaming', label: t('sidebar.dreaming'), icon: 'dreaming', gate: 'dreaming' },
{ route: '/cron', label: t('sidebar.cron'), icon: 'clock', gate: 'cron' },
{ route: '/usage', label: t('sidebar.usage'), icon: 'bar-chart' },
]
}, {
section: t('sidebar.sectionExtension'),
items: [
{ route: '/skills', label: t('sidebar.skills'), icon: 'skills', gate: 'skills' },
{ route: '/plugin-hub', label: t('sidebar.pluginHub'), icon: 'extensions' },
]
}, {
section: '',
items: [
{ route: '/settings', label: t('sidebar.settings'), icon: 'settings' },
{ route: '/chat-debug', label: t('sidebar.checkRepair'), icon: 'diagnose' },
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
]
}]
},
/** 路由注册表 */
getRoutes() {
return [
{ path: '/dashboard', loader: () => import('../../pages/dashboard.js') },
{ path: '/chat', loader: () => import('../../pages/chat.js') },
{ path: '/chat-debug', loader: () => import('../../pages/chat-debug.js') },
{ path: '/services', loader: () => import('../../pages/services.js') },
{ path: '/logs', loader: () => import('../../pages/logs.js') },
{ path: '/models', loader: () => import('../../pages/models.js') },
{ path: '/agents', loader: () => import('../../pages/agents.js') },
{ path: '/agent-detail', loader: () => import('../../pages/agent-detail.js') },
{ path: '/gateway', loader: () => import('../../pages/gateway.js') },
{ path: '/memory', loader: () => import('../../pages/memory.js') },
{ path: '/dreaming', loader: () => import('../../pages/dreaming.js') },
{ path: '/skills', loader: () => import('../../pages/skills.js') },
{ path: '/security', loader: () => import('../../pages/security.js') },
{ path: '/about', loader: () => import('../../pages/about.js') },
{ path: '/assistant', loader: () => import('../../pages/assistant.js') },
{ path: '/setup', loader: () => import('../../pages/setup.js') },
{ path: '/channels', loader: () => import('../../pages/channels.js') },
{ path: '/cron', loader: () => import('../../pages/cron.js') },
{ path: '/usage', loader: () => import('../../pages/usage.js') },
{ path: '/communication', loader: () => import('../../pages/communication.js') },
{ path: '/settings', loader: () => import('../../pages/settings.js') },
{ path: '/route-map', loader: () => import('../../pages/route-map.js') },
{ path: '/plugin-hub', loader: () => import('../../pages/plugin-hub.js') },
{ path: '/diagnose', loader: () => import('../../pages/chat-debug.js') },
]
},
getSetupRoute() { return '/setup' },
getDefaultRoute() { return '/dashboard' },
isReady() { return isOpenclawReady() },
isGatewayRunning() { return isGatewayRunning() },
isGatewayForeign() { return isGatewayForeign() },
onStateChange(fn) { return onGatewayChange(fn) },
onReadyChange(fn) { return onReadyChange(fn) },
/** 功能门控:基于 OpenClaw 版本号 */
isFeatureAvailable(featureId) { return isFeatureAvailable(featureId) },
}

114
src/lib/engine-manager.js Normal file
View File

@@ -0,0 +1,114 @@
/**
* 引擎管理器
* 管理多引擎OpenClaw / Hermes Agent / ...)的注册、切换和状态
*/
import { api } from './tauri-api.js'
import { registerRoute, setDefaultRoute } from '../router.js'
const _engines = {}
let _activeEngine = null
let _listeners = []
/** 注册引擎 */
export function registerEngine(engine) {
_engines[engine.id] = engine
}
/** 获取所有已注册引擎 */
export function listEngines() {
return Object.values(_engines).map(e => ({
id: e.id,
name: e.name,
icon: e.icon || '',
description: e.description || '',
}))
}
/** 获取当前激活的引擎 */
export function getActiveEngine() {
return _activeEngine
}
/** 获取引擎 ID */
export function getActiveEngineId() {
return _activeEngine?.id || 'openclaw'
}
/** 按 ID 获取引擎 */
export function getEngine(id) {
return _engines[id] || null
}
/** 监听引擎切换事件 */
export function onEngineChange(fn) {
_listeners.push(fn)
return () => { _listeners = _listeners.filter(cb => cb !== fn) }
}
/**
* 初始化引擎管理器:读取 clawpanel.json 中的 engineMode激活对应引擎
* 在 main.js boot() 中调用
*/
export async function initEngineManager() {
let mode = 'openclaw'
try {
const cfg = await api.readPanelConfig()
if (cfg?.engineMode && _engines[cfg.engineMode]) {
mode = cfg.engineMode
}
} catch {}
await activateEngine(mode, false)
}
/**
* 激活指定引擎(注册路由 + 启动)
* @param {string} id 引擎 ID
* @param {boolean} persist 是否写入 clawpanel.json
*/
export async function activateEngine(id, persist = true) {
const engine = _engines[id]
if (!engine) {
console.error(`[engine-manager] 未知引擎: ${id}`)
return
}
// 清理旧引擎
if (_activeEngine && _activeEngine.id !== id && _activeEngine.cleanup) {
try { _activeEngine.cleanup() } catch {}
}
_activeEngine = engine
// 注册引擎路由 + 设置默认路由
const routes = engine.getRoutes()
for (const r of routes) {
registerRoute(r.path, r.loader)
}
if (engine.getDefaultRoute) {
setDefaultRoute(engine.getDefaultRoute())
}
// 持久化到 clawpanel.json
if (persist) {
try {
const cfg = await api.readPanelConfig()
if (cfg.engineMode !== id) {
cfg.engineMode = id
await api.writePanelConfig(cfg)
}
} catch (e) {
console.warn('[engine-manager] 保存 engineMode 失败:', e)
}
}
// 通知监听者
_listeners.forEach(fn => { try { fn(engine) } catch {} })
}
/**
* 切换引擎(带 UI 跳转)
*/
export async function switchEngine(id) {
if (_activeEngine?.id === id) return
await activateEngine(id, true)
}

View File

@@ -1,5 +1,6 @@
import { api } from './tauri-api.js'
import { showContentModal } from '../components/modal.js'
import { showContentModal, showConfirm } from '../components/modal.js'
import { toast } from '../components/toast.js'
import { t } from './i18n.js'
function escapeHtml(str) {
@@ -207,11 +208,17 @@ export async function showGatewayConflictGuidance({ error = null, service = null
content,
width: 760,
buttons: [
{ id: 'gateway-conflict-open-settings', label: settingsButtonLabel, className: 'btn btn-primary btn-sm' },
{ id: 'gateway-conflict-open-cleanup', label: t('services.cleanupTitle'), className: 'btn btn-primary btn-sm' },
{ id: 'gateway-conflict-open-settings', label: settingsButtonLabel, className: 'btn btn-secondary btn-sm' },
{ id: 'gateway-conflict-refresh', label: t('services.refreshStatus'), className: 'btn btn-secondary btn-sm' },
],
})
overlay.querySelector('#gateway-conflict-open-cleanup')?.addEventListener('click', async () => {
overlay.close()
await showInstallationCleanup({ onRefresh })
})
overlay.querySelector('#gateway-conflict-open-settings')?.addEventListener('click', () => {
overlay.close()
window.location.hash = '#/settings'
@@ -226,3 +233,195 @@ export async function showGatewayConflictGuidance({ error = null, service = null
return overlay
}
/** 根据安装来源返回卸载命令 */
function uninstallCommandForSource(source, path) {
if (source === 'standalone') {
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
const p = escapeHtml(path || '')
return isWin ? `rmdir /s /q "${p}"` : `rm -rf "${p}"`
}
if (source === 'npm-official' || source === 'official') return 'npm uninstall -g openclaw'
// npm-zh, npm-global, and others
return 'npm uninstall -g @qingchencloud/openclaw-zh'
}
/**
* 显示安装清理弹窗
* 列出所有检测到的 OpenClaw 安装,提供逐个卸载命令 + 一键绑定 + 全量卸载
*/
export async function showInstallationCleanup({ onRefresh = null } = {}) {
const [versionInfo, panelConfig] = await Promise.all([
api.getVersionInfo().catch(() => null),
api.readPanelConfig().catch(() => null),
])
const installations = dedupeOpenclawInstallations(Array.isArray(versionInfo?.all_installations) ? versionInfo.all_installations : [])
const boundPath = readBoundCliPath(panelConfig)
const currentPath = versionInfo?.cli_path || ''
const sourceLabel = (src) => cliSourceLabel(src)
// 每个安装的卡片 HTML
const installCards = installations.map((inst, idx) => {
const isActive = !!inst.active
const isBound = boundPath && openclawInstallationIdentity({ path: inst.path }) === openclawInstallationIdentity({ path: boundPath })
const borderColor = isActive ? 'rgba(34,197,94,0.4)' : 'var(--border-light)'
const bgColor = isActive ? 'rgba(34,197,94,0.04)' : 'var(--bg-secondary)'
const badges = []
if (isActive) badges.push(`<span style="display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;background:rgba(34,197,94,0.14);color:#16a34a">● ${t('services.cleanupActive')}</span>`)
if (isBound) badges.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;background:rgba(99,102,241,0.14);color:#6366f1">✓ ${t('services.cleanupBound')}</span>`)
if (inst.version) badges.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:999px;font-size:11px;background:var(--bg-tertiary);color:var(--text-secondary)">${escapeHtml(inst.version)}</span>`)
if (inst.source) badges.push(`<span style="display:inline-flex;padding:2px 8px;border-radius:999px;font-size:11px;background:var(--bg-tertiary);color:var(--text-tertiary)">${escapeHtml(sourceLabel(inst.source))}</span>`)
const uninstallCmd = uninstallCommandForSource(inst.source, inst.path)
// 操作区:非活跃的安装显示卸载命令 + 复制按钮;活跃的显示绑定按钮
let actions = ''
if (isActive && !isBound) {
actions = `<button class="btn btn-primary btn-xs cleanup-bind-btn" data-path="${escapeHtml(inst.path)}" style="margin-top:8px">${t('services.cleanupBindThis')}</button>`
} else if (!isActive) {
actions = `
<div style="margin-top:8px;display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<code style="flex:1;min-width:0;font-size:11px;padding:4px 8px;background:var(--bg-tertiary);border-radius:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;user-select:all" title="${escapeHtml(uninstallCmd)}">${escapeHtml(uninstallCmd)}</code>
<button class="btn btn-secondary btn-xs cleanup-copy-cmd" data-cmd="${escapeHtml(uninstallCmd)}" style="flex-shrink:0">${t('services.cleanupCopyCmd')}</button>
</div>`
}
return `
<div style="padding:12px 14px;border:1px solid ${borderColor};border-radius:10px;background:${bgColor};transition:border-color .15s">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span style="font-size:14px">${isActive ? '✅' : '📦'}</span>
<code style="font-size:12px;word-break:break-all;flex:1;min-width:0">${escapeHtml(inst.path)}</code>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">${badges.join('')}</div>
${actions}
</div>`
}).join('')
const noInstalls = !installations.length
? `<div style="padding:14px;border:1px dashed var(--border-light);border-radius:10px;text-align:center;color:var(--text-tertiary)">${t('services.cleanupNoInstalls')}</div>`
: ''
// 概要提示
const summaryStyle = installations.length > 1
? 'background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.2);color:var(--warning)'
: 'background:rgba(34,197,94,0.08);border:1px solid rgba(34,197,94,0.2);color:var(--success)'
const summaryIcon = installations.length > 1 ? '⚠️' : '✅'
const summaryText = installations.length > 1
? t('services.cleanupMultiSummary', { count: installations.length })
: t('services.cleanupSingleSummary')
const content = `
<div style="display:flex;flex-direction:column;gap:12px;font-size:var(--font-size-sm);color:var(--text-secondary);line-height:1.6">
<div style="display:flex;gap:10px;padding:10px 14px;border-radius:10px;${summaryStyle}">
<span style="font-size:16px;flex-shrink:0">${summaryIcon}</span>
<div style="font-size:13px;line-height:1.5">${escapeHtml(summaryText)}</div>
</div>
${installations.length > 1 ? `
<div style="padding:10px 14px;border-radius:10px;background:var(--bg-secondary);border:1px solid var(--border-light);font-size:12px;line-height:1.6;color:var(--text-tertiary)">
<strong style="color:var(--text-secondary)">${t('services.cleanupHowTo')}</strong><br>
${t('services.cleanupHowToDesc')}
</div>` : ''}
<div style="display:flex;flex-direction:column;gap:8px">
<div style="font-size:13px;font-weight:600;color:var(--text-primary)">${t('services.cleanupInstallationsTitle', { count: installations.length })}</div>
${installCards}${noInstalls}
</div>
<details style="border-radius:10px;background:var(--bg-secondary);border:1px solid var(--border-light);overflow:hidden">
<summary style="padding:10px 14px;cursor:pointer;font-size:13px;font-weight:600;color:var(--error);user-select:none">${t('services.cleanupDangerZone')}</summary>
<div style="padding:0 14px 12px;display:flex;flex-direction:column;gap:8px">
<div style="font-size:12px;color:var(--text-tertiary);line-height:1.6">${t('services.cleanupDangerDesc')}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-secondary btn-xs" id="cleanup-uninstall-all">${t('services.cleanupUninstallAll')}</button>
<button class="btn btn-secondary btn-xs" id="cleanup-uninstall-all-config" style="color:var(--error)">${t('services.cleanupUninstallAllWithConfig')}</button>
</div>
</div>
</details>
</div>
`
const overlay = showContentModal({
title: t('services.cleanupTitle'),
content,
width: 640,
buttons: [
{ id: 'cleanup-goto-settings', label: t('sidebar.settings'), className: 'btn btn-secondary btn-sm' },
{ id: 'cleanup-refresh', label: t('services.refreshStatus'), className: 'btn btn-secondary btn-sm' },
],
})
// 复制命令按钮
overlay.addEventListener('click', async (e) => {
const copyBtn = e.target.closest('.cleanup-copy-cmd')
if (copyBtn) {
const cmd = copyBtn.dataset.cmd
try {
await navigator.clipboard.writeText(cmd)
const orig = copyBtn.textContent
copyBtn.textContent = '✓'
copyBtn.style.color = 'var(--success)'
setTimeout(() => { copyBtn.textContent = orig; copyBtn.style.color = '' }, 1500)
} catch {
toast(t('services.cleanupCopyFailed'), 'warning')
}
return
}
// 绑定 CLI 按钮
const bindBtn = e.target.closest('.cleanup-bind-btn')
if (bindBtn) {
const path = bindBtn.dataset.path
if (!path) return
try {
const cfg = await api.readPanelConfig()
cfg.openclawCliPath = path
await api.writePanelConfig(cfg)
toast(t('services.cleanupBindSuccess'), 'success')
overlay.close()
if (typeof onRefresh === 'function') await onRefresh()
} catch (err) {
toast(t('services.cleanupBindFailed') + ': ' + (err?.message || err), 'error')
}
return
}
})
// 全量卸载按钮
overlay.querySelector('#cleanup-uninstall-all')?.addEventListener('click', async () => {
const ok = await showConfirm(t('services.cleanupConfirmUninstall'))
if (!ok) return
overlay.close()
try {
toast(t('services.cleanupUninstalling'), 'info')
await api.uninstallOpenclaw(false)
} catch (err) {
toast(t('services.cleanupUninstallFailed') + ': ' + (err?.message || err), 'error')
}
})
overlay.querySelector('#cleanup-uninstall-all-config')?.addEventListener('click', async () => {
const ok = await showConfirm(t('services.cleanupConfirmUninstallConfig'))
if (!ok) return
overlay.close()
try {
toast(t('services.cleanupUninstalling'), 'info')
await api.uninstallOpenclaw(true)
} catch (err) {
toast(t('services.cleanupUninstallFailed') + ': ' + (err?.message || err), 'error')
}
})
// 导航按钮
overlay.querySelector('#cleanup-goto-settings')?.addEventListener('click', () => {
overlay.close()
window.location.hash = '#/settings'
})
overlay.querySelector('#cleanup-refresh')?.addEventListener('click', async () => {
overlay.close()
if (typeof onRefresh === 'function') await onRefresh()
})
return overlay
}

View File

@@ -373,4 +373,21 @@ export const api = {
saveImage: (id, data) => invoke('assistant_save_image', { id, data }),
loadImage: (id) => invoke('assistant_load_image', { id }),
deleteImage: (id) => invoke('assistant_delete_image', { id }),
// Hermes Agent 管理
checkPython: () => cachedInvoke('check_python', {}, 60000),
checkHermes: () => cachedInvoke('check_hermes', {}, 30000),
installHermes: (method = 'uv-tool', extras = []) => invoke('install_hermes', { method, extras }),
configureHermes: (provider, apiKey, model, baseUrl) => invoke('configure_hermes', { provider, apiKey, model: model || null, baseUrl: baseUrl || null }),
hermesGatewayAction: (action) => invoke('hermes_gateway_action', { action }),
hermesHealthCheck: () => invoke('hermes_health_check'),
hermesApiProxy: (method, path, body, headers) => invoke('hermes_api_proxy', { method, path, body: body || null, headers: headers || null }),
hermesAgentRun: (input, sessionId, conversationHistory, instructions) => invoke('hermes_agent_run', { input, sessionId: sessionId || null, conversationHistory: conversationHistory || null, instructions: instructions || null }),
hermesReadConfig: () => invoke('hermes_read_config'),
hermesFetchModels: (baseUrl, apiKey, apiType) => invoke('hermes_fetch_models', { baseUrl, apiKey, apiType: apiType || null }),
hermesUpdateModel: (model) => invoke('hermes_update_model', { model }),
hermesDetectEnvironments: () => invoke('hermes_detect_environments'),
hermesSetGatewayUrl: (url) => invoke('hermes_set_gateway_url', { url: url || null }),
updateHermes: () => invoke('update_hermes'),
uninstallHermes: (cleanConfig = false) => invoke('uninstall_hermes', { cleanConfig }),
}

View File

@@ -90,6 +90,11 @@
"remoteHint": "The remote server must be running ClawPanel (serve.js).",
"example": "Example"
},
"engine": {
"switchedTo": "Switched to {name} mode",
"hermesSetupDesc": "Install and configure Hermes Agent",
"hermesSetupIntro": "Hermes Agent is an AI assistant with tool-calling capabilities.<br>This engine is coming soon. Stay tuned!"
},
"dashboard": {
"title": "Dashboard",
"desc": "OpenClaw runtime status overview",

View File

@@ -34,12 +34,14 @@ import engagement from './modules/engagement.js'
import diagnose from './modules/diagnose.js'
import routeMap from './modules/routeMap.js'
import extensions from './modules/extensions.js'
import engine from './modules/engine.js'
const MODULES = {
common, sidebar, instance, dashboard, services, settings,
models, agents, agentDetail, gateway, security, communication, channels,
memory, dreaming, cron, usage, skills, chat, chatDebug, setup, about,
ext, logs, assistant, toast, modal, engagement, diagnose, routeMap, extensions,
engine,
}
/** 构建所有语言字典 { 'zh-CN': { common: {...}, sidebar: {...}, ... }, ... } */

View File

@@ -18,6 +18,8 @@ export default {
policyAhead: _('检测到你本地安装的是高于推荐稳定版的 {current},可能存在接口、事件或配置兼容性问题。建议回退到 {recommended};如果你要继续使用高版本,请自行验证兼容性并关注 issue / release。', 'Your local installation {current} is ahead of the recommended stable version. There may be API, event, or config compatibility issues. Consider rolling back to {recommended}; if you want to keep the newer version, verify compatibility yourself and watch issues/releases.', '檢測到你本地安裝的是高於推薦穩定版的 {current},可能存在介面、事件或設定相容性問題。建議回退到 {recommended};如果你要繼續使用高版本,請自行驗證相容性並關注 issue / release。'),
policyDefault: _('当前面板默认只保证推荐稳定版的兼容性;如果你要尝试其他版本或预览版,请自行验证兼容性。若希望面板尽快支持最新版特性,欢迎提交 issue 告诉我们。', 'This panel only guarantees compatibility with the recommended stable version. If you want to try other versions or previews, verify compatibility yourself. Submit an issue if you want us to support the latest version sooner.', '目前面板預設只保證推薦穩定版的相容性;如果你要尝試其他版本或預覽版,請自行驗證相容性。若希望面板尽快支援最新版特性,欢迎提交 issue 告诉我們。'),
notInstalled: _('未安装', 'Not installed', '未安裝', '未インストール', '미설치', 'Chưa cài đặt', 'No instalado', 'Não instalado', 'Не установлен', 'Non installé', 'Nicht installiert'),
installed: _('已安装', 'Installed', '已安裝'),
hermesSetup: _('前往配置', 'Go to Setup', '前往配置'),
aheadOfRecommended: _('当前版本高于推荐稳定版: {ver}', 'Current version is ahead of recommended stable: {ver}', '目前版本高於推薦穩定版: {ver}'),
rollbackToRecommended: _('回退到推荐版', 'Rollback to recommended', '回退到推薦版'),
recommendedStable: _('推荐稳定版: {ver}', 'Recommended stable: {ver}', '推薦穩定版: {ver}', '推奨安定版: {ver}', '권장 안정 버전: {ver}'),

View File

@@ -36,6 +36,21 @@ export default {
skillPrAssistantDesc: _('帮你走一遍 PR 流程', 'Walk through the PR process', '幫你走一遍 PR 流程'),
skillSkillsManager: _('Skills 管理', 'Skills Manager'),
skillSkillsManagerDesc: _('管理 OpenClaw 的 Skills', 'Manage OpenClaw Skills'),
// Hermes 专属 Skills
skillHermesChat: _('终端对话', 'Terminal Chat', '終端對話'),
skillHermesChatDesc: _('在终端中启动 Hermes 交互式对话', 'Start Hermes interactive chat in terminal', '在終端中啟動 Hermes 交互式對話'),
skillHermesDiagnose: _('诊断 Hermes', 'Diagnose Hermes', '診斷 Hermes'),
skillHermesDiagnoseDesc: _('检查 Hermes Agent 运行状态', 'Check Hermes Agent status', '檢查 Hermes Agent 運行狀態'),
skillHermesConfig: _('检查配置', 'Check Config', '檢查配置'),
skillHermesConfigDesc: _('检查 Hermes 配置文件是否正确', 'Check if Hermes config is correct', '檢查 Hermes 配置檔案是否正確'),
skillHermesBrowseDir: _('浏览目录', 'Browse Directory', '瀏覽目錄'),
skillHermesBrowseDirDesc: _('浏览 Hermes 配置目录结构', 'Browse Hermes config directory structure', '瀏覽 Hermes 配置目錄結構'),
skillHermesUpgrade: _('升级 Hermes', 'Upgrade Hermes', '升級 Hermes'),
skillHermesUpgradeDesc: _('升级到最新版本', 'Upgrade to the latest version', '升級到最新版本'),
skillHermesLogs: _('分析日志', 'Analyze Logs', '分析日誌'),
skillHermesLogsDesc: _('分析 Hermes 最近的日志', 'Analyze recent Hermes logs', '分析 Hermes 最近的日誌'),
skillHermesUninstall: _('卸载 Hermes', 'Uninstall Hermes', '解除安裝 Hermes'),
skillHermesUninstallDesc: _('完全卸载 Hermes Agent', 'Completely uninstall Hermes Agent', '完全解除安裝 Hermes Agent'),
soulFileSoul: _('核心人格', 'Core persona'),
soulFileIdentity: _('身份信息', 'Identity info', '身份資訊'),
soulFileUser: _('用户偏好', 'User preferences', '使用者偏好'),

View File

@@ -0,0 +1,157 @@
import { _ } from '../helper.js'
export default {
switchedTo: _('已切换到 {name} 模式', 'Switched to {name} mode', '已切換到 {name} 模式', '{name} モードに切り替えました', '{name} 모드로 전환됨'),
hermesSetupDesc: _('安装并配置 Hermes Agent', 'Install and configure Hermes Agent', '安裝並配置 Hermes Agent'),
hermesSetupIntro: _(
'Hermes Agent 是一个具有工具调用能力的 AI 助手框架。点击下方按钮一键安装,无需终端操作。',
'Hermes Agent is an AI assistant with tool-calling capabilities. Click the button below to install — no terminal needed.',
'Hermes Agent 是一個具有工具調用能力的 AI 助手框架。點擊下方按鈕一鍵安裝,無需終端操作。',
),
// 检测阶段
detecting: _('正在检测环境...', 'Detecting environment...', '正在偵測環境...'),
detectPython: _('检测 Python 环境', 'Detecting Python', '偵測 Python 環境'),
detectHermes: _('检测 Hermes Agent', 'Detecting Hermes Agent', '偵測 Hermes Agent'),
pythonFound: _('Python {version}', 'Python {version}', 'Python {version}'),
pythonNotFound: _('未检测到 Python将自动安装', 'Python not found (will be auto-installed)', '未偵測到 Python將自動安裝'),
pythonTooOld: _('Python {version} 版本过低,需要 3.11+(将自动安装)', 'Python {version} too old, need 3.11+ (will be auto-installed)', 'Python {version} 版本過低,需要 3.11+(將自動安裝)'),
uvFound: _('uv 包管理器已就绪', 'uv package manager ready', 'uv 套件管理器已就緒'),
uvNotFound: _('uv 未安装(将自动下载)', 'uv not installed (will be auto-downloaded)', 'uv 未安裝(將自動下載)'),
gitFound: _('Git 已就绪', 'Git ready', 'Git 已就緒'),
gitNotFound: _('Git 未安装(从 GitHub 安装需要 Git', 'Git not found (required for GitHub install)', 'Git 未安裝(從 GitHub 安裝需要 Git'),
hermesFound: _('Hermes Agent {version} 已安装', 'Hermes Agent {version} installed', 'Hermes Agent {version} 已安裝'),
hermesNotFound: _('Hermes Agent 未安装', 'Hermes Agent not installed', 'Hermes Agent 未安裝'),
hermesReady: _('Hermes Agent 已就绪Gateway 运行中', 'Hermes Agent ready, Gateway running', 'Hermes Agent 已就緒Gateway 運行中'),
// 安装阶段
installTitle: _('安装 Hermes Agent', 'Install Hermes Agent', '安裝 Hermes Agent'),
installDesc: _('通过 uv 自动安装(含 Python 环境),无需手动操作', 'Auto-install via uv (includes Python), no manual steps', '透過 uv 自動安裝(含 Python 環境),無需手動操作'),
installBtn: _('一键安装', 'Install Now', '一鍵安裝'),
installingBtn: _('正在安装...', 'Installing...', '正在安裝...'),
installSuccess: _('安装成功!', 'Installation successful!', '安裝成功!'),
installFailed: _('安装失败', 'Installation failed', '安裝失敗'),
retryBtn: _('重试', 'Retry', '重試'),
// Extras 选择
extrasTitle: _('可选组件', 'Optional Components', '可選組件'),
extrasDesc: _('选择需要安装的额外功能(可稍后更改)', 'Select optional features to install (can change later)', '選擇需要安裝的額外功能(可稍後更改)'),
extraCron: _('定时任务', 'Cron Jobs', '定時任務'),
extraCli: _('CLI 增强', 'CLI Enhanced', 'CLI 增強'),
extraPty: _('终端后端', 'Terminal Backend', '終端後端'),
extraMcp: _('MCP 协议', 'MCP Protocol', 'MCP 協議'),
extraMessaging: _('消息渠道Telegram/Discord 等)', 'Messaging (Telegram/Discord etc.)', '訊息頻道Telegram/Discord 等)'),
extraFeishu: _('飞书', 'Feishu/Lark', '飛書'),
extraDingtalk: _('钉钉', 'DingTalk', '釘釘'),
extraSlack: _('Slack', 'Slack', 'Slack'),
extraVoice: _('语音TTS/STT', 'Voice (TTS/STT)', '語音TTS/STT'),
extraAll: _('全部安装', 'Install All', '全部安裝'),
// 配置阶段
configTitle: _('配置 Hermes Agent', 'Configure Hermes Agent', '配置 Hermes Agent'),
configDesc: _('设置 LLM Provider 以启用 AI 功能', 'Set up LLM Provider to enable AI features', '設置 LLM Provider 以啟用 AI 功能'),
configProvider: _('LLM 提供商', 'LLM Provider', 'LLM 提供商'),
configApiKey: _('API Key', 'API Key', 'API Key'),
configModel: _('模型', 'Model', '模型'),
configBaseUrl: _('自定义 API 地址(可选)', 'Custom API URL (optional)', '自定義 API 地址(可選)'),
configFetchModels: _('获取模型列表', 'Fetch Models', '取得模型列表'),
configFetching: _('获取中...', 'Fetching...', '取得中...'),
configFetchSuccess: _('获取到 {count} 个模型', 'Found {count} models', '取得 {count} 個模型'),
configFetchFailed: _('获取失败: {error}', 'Fetch failed: {error}', '取得失敗: {error}'),
configFetchNotSupported: _('此渠道不支持获取模型列表,请前往平台查看可用模型后手动输入', 'This provider does not support model listing. Please check available models on their platform and enter manually.', '此渠道不支持取得模型列表,請前往平台查看可用模型後手動輸入'),
configFetchNeedKey: _('请先填写 API Key', 'Please enter API Key first', '請先填寫 API Key'),
configFetchNeedUrl: _('请先选择服务商或填写 Base URL', 'Please select a provider or enter Base URL first', '請先選擇服務商或填寫 Base URL'),
configSaveBtn: _('保存配置', 'Save Config', '儲存配置'),
configSkipBtn: _('跳过,稍后配置', 'Skip, configure later', '跳過,稍後配置'),
configSaved: _('配置已保存', 'Configuration saved', '配置已儲存'),
// Gateway 阶段
gatewayTitle: _('启动 Gateway', 'Start Gateway', '啟動 Gateway'),
gatewayDesc: _('启动 HTTP API 服务以连接 ClawPanel', 'Start HTTP API server to connect with ClawPanel', '啟動 HTTP API 服務以連接 ClawPanel'),
gatewayStartBtn: _('启动 Gateway', 'Start Gateway', '啟動 Gateway'),
gatewayStarting: _('正在启动...', 'Starting...', '正在啟動...'),
gatewayStartFailed: _('Gateway 启动失败', 'Gateway failed to start', 'Gateway 啟動失敗'),
gatewayRunning: _('Gateway 运行中 (端口 {port})', 'Gateway running (port {port})', 'Gateway 運行中 (端口 {port})'),
gatewayStopped: _('Gateway 未运行', 'Gateway not running', 'Gateway 未運行'),
// 完成
setupComplete: _('设置完成!', 'Setup Complete!', '設定完成!'),
setupCompleteDesc: _('Hermes Agent 已准备就绪,可以开始使用了。', 'Hermes Agent is ready to use.', 'Hermes Agent 已準備就緒,可以開始使用了。'),
goToDashboard: _('进入仪表盘', 'Go to Dashboard', '進入儀表盤'),
// 日志
viewLogs: _('查看安装日志', 'View install logs', '查看安裝日誌'),
hideLogs: _('隐藏日志', 'Hide logs', '隱藏日誌'),
// 仪表盘
hermesSetupDocLink: _('查看完整文档', 'View full documentation', '查看完整文檔'),
hermesDashboardTitle: _('Hermes 仪表盘', 'Hermes Dashboard', 'Hermes 儀表盤'),
dashGatewayStatus: _('Gateway 状态', 'Gateway Status', 'Gateway 狀態'),
dashRunning: _('运行中', 'Running', '運行中'),
dashStopped: _('已停止', 'Stopped', '已停止'),
dashModel: _('当前模型', 'Current Model', '目前模型'),
dashVersion: _('版本', 'Version', '版本'),
dashPort: _('监听端口', 'Listen Port', '監聽端口'),
dashStartGw: _('启动 Gateway', 'Start Gateway', '啟動 Gateway'),
dashStopGw: _('停止 Gateway', 'Stop Gateway', '停止 Gateway'),
dashRestartGw: _('重启 Gateway', 'Restart Gateway', '重啟 Gateway'),
dashStopping: _('正在停止...', 'Stopping...', '正在停止...'),
dashRestarting: _('正在重启...', 'Restarting...', '正在重啟...'),
dashQuickActions: _('快捷操作', 'Quick Actions', '快捷操作'),
dashOpenChat: _('打开对话', 'Open Chat', '開啟對話'),
dashOpenCron: _('定时任务', 'Cron Jobs', '定時任務'),
dashOpenSetup: _('重新配置', 'Reconfigure', '重新配置'),
dashNoModel: _('未配置', 'Not configured', '未配置'),
dashApiEndpoint: _('API 地址', 'API Endpoint', 'API 地址'),
dashModelConfig: _('模型配置', 'Model Config', '模型配置'),
dashConnectTarget: _('连接目标', 'Connection Target', '連接目標'),
dashDetectEnv: _('探测环境', 'Detect Environments', '探測環境'),
dashDetecting: _('探测中...', 'Detecting...', '探測中...'),
dashConnLocal: _('本地', 'Local', '本地'),
dashConnCustom: _('自定义', 'Custom', '自訂'),
dashConnApply: _('应用', 'Apply', '套用'),
dashQuickSwitch: _('快速切换', 'Quick Switch', '快速切換'),
// 终端命令
dashCliTitle: _('终端命令', 'Terminal Commands', '終端命令'),
dashCliDesc: _('在终端中使用以下命令管理 Hermes Agent点击复制', 'Use these commands in your terminal to manage Hermes Agent. Click to copy.', '在終端中使用以下命令管理 Hermes Agent點擊複製'),
cliChat: _('终端对话', 'Terminal Chat', '終端對話'),
cliChatDesc: _('在终端中直接与 Agent 对话', 'Chat with Agent directly in terminal', '在終端中直接與 Agent 對話'),
cliDoctor: _('诊断检查', 'Diagnostics', '診斷檢查'),
cliDoctorDesc: _('检测配置和环境问题', 'Check config and environment issues', '檢測配置和環境問題'),
cliVersion: _('查看版本', 'Check Version', '查看版本'),
cliVersionDesc: _('显示当前安装版本', 'Show installed version', '顯示目前安裝版本'),
cliGwStart: _('启动服务', 'Start Gateway', '啟動服務'),
cliGwStartDesc: _('在终端前台启动 Gateway', 'Start Gateway in foreground', '在終端前台啟動 Gateway'),
cliGwStop: _('停止服务', 'Stop Gateway', '停止服務'),
cliGwStopDesc: _('停止后台 Gateway 进程', 'Stop background Gateway process', '停止背景 Gateway 進程'),
cliUpgrade: _('升级', 'Upgrade', '升級'),
cliUpgradeDesc: _('从 GitHub 重新安装最新版', 'Reinstall latest from GitHub', '從 GitHub 重新安裝最新版'),
cliUninstall: _('卸载', 'Uninstall', '解除安裝'),
cliUninstallDesc: _('移除 Hermes Agent', 'Remove Hermes Agent', '移除 Hermes Agent'),
cliConfig: _('打开配置目录', 'Open Config Dir', '開啟配置目錄'),
cliConfigDesc: _('在文件管理器中查看配置文件', 'View config files in file manager', '在檔案管理器中查看配置檔案'),
// 对话页面
hermesChatTitle: _('Hermes 对话', 'Hermes Chat', 'Hermes 對話'),
chatPlaceholder: _('输入消息...', 'Type a message...', '輸入訊息...'),
chatSend: _('发送', 'Send', '發送'),
chatNewSession: _('新对话', 'New Chat', '新對話'),
chatThinking: _('正在思考...', 'Thinking...', '正在思考...'),
chatError: _('发送失败: {error}', 'Send failed: {error}', '發送失敗: {error}'),
chatGatewayOffline: _('Gateway 未运行,请先启动', 'Gateway is offline, please start it first', 'Gateway 未運行,請先啟動'),
chatWelcome: _('你好!我是 Hermes Agent有什么可以帮你的', 'Hello! I\'m Hermes Agent, how can I help?', '你好!我是 Hermes Agent有什麼可以幫你的'),
chatEmptyHint: _('开始一段对话吧', 'Start a conversation', '開始一段對話吧'),
fileAccess: _('文件访问', 'File Access', '檔案存取'),
fileAccessOn: _('已开启文件系统访问Agent 可读取本机文件)', 'File system access enabled (Agent can read local files)', '已開啟檔案系統存取Agent 可讀取本機檔案)'),
fileAccessOff: _('文件系统访问已关闭', 'File system access disabled', '檔案系統存取已關閉'),
// 定时任务
hermesCronTitle: _('定时任务', 'Cron Jobs', '定時任務'),
cronNoJobs: _('暂无定时任务', 'No cron jobs yet', '暫無定時任務'),
cronCreate: _('创建任务', 'Create Job', '建立任務'),
cronName: _('任务名称', 'Job Name', '任務名稱'),
cronSchedule: _('执行周期', 'Schedule', '執行週期'),
cronPrompt: _('AI 指令', 'AI Prompt', 'AI 指令'),
cronActive: _('启用', 'Active', '啟用'),
cronPaused: _('已暂停', 'Paused', '已暫停'),
cronRunNow: _('立即执行', 'Run Now', '立即執行'),
cronDelete: _('删除', 'Delete', '刪除'),
cronSave: _('保存', 'Save', '儲存'),
cronCancel: _('取消', 'Cancel', '取消'),
// 其它页面
hermesServicesTitle: _('Hermes 服务', 'Hermes Services', 'Hermes 服務'),
hermesConfigTitle: _('Hermes 配置', 'Hermes Config', 'Hermes 配置'),
hermesChannelsTitle: _('Hermes 渠道', 'Hermes Channels', 'Hermes 頻道'),
hermesSkillsTitle: _('Hermes Skills', 'Hermes Skills', 'Hermes Skills'),
comingSoonPhase2: _('即将在 Phase 2 中推出', 'Coming in Phase 2', '即將在 Phase 2 中推出'),
}

View File

@@ -165,4 +165,27 @@ export default {
claimGateway: _('认领 Gateway', 'Claim Gateway', '認領 Gateway', 'Gateway を引き取る', 'Gateway 인수', 'Nhận Gateway', 'Reclamar Gateway', 'Reivindicar Gateway', 'Принять Gateway', 'Revendiquer Gateway', 'Gateway übernehmen'),
claimSuccess: _('Gateway 已认领,当前面板已接管管理权', 'Gateway claimed, this panel now manages it', 'Gateway 已認領,目前面板已接管管理權', 'Gateway を引き取りました。このパネルが管理します', 'Gateway를 인수했습니다. 이 패널이 관리합니다'),
claimFailed: _('认领失败', 'Claim failed', '認領失敗', '引き取り失敗', '인수 실패'),
// 安装清理弹窗
cleanupTitle: _('安装管理与清理', 'Installation Management & Cleanup', '安裝管理與清理'),
cleanupActive: _('当前使用', 'Active', '目前使用'),
cleanupBound: _('已绑定', 'Bound', '已綁定'),
cleanupBindThis: _('绑定此安装', 'Bind this installation', '綁定此安裝'),
cleanupCopyCmd: _('复制命令', 'Copy', '複製命令'),
cleanupCopyFailed: _('复制失败,请手动复制', 'Copy failed, please copy manually', '複製失敗,請手動複製'),
cleanupBindSuccess: _('已绑定 CLI冲突已解决', 'CLI bound, conflict resolved', '已綁定 CLI衝突已解決'),
cleanupBindFailed: _('绑定失败', 'Bind failed', '綁定失敗'),
cleanupNoInstalls: _('未检测到 OpenClaw 安装', 'No OpenClaw installations detected', '未檢測到 OpenClaw 安裝'),
cleanupMultiSummary: _('检测到 {count} 个 OpenClaw 安装,重复安装是 99% 问题的根源。建议只保留一个,卸载其余副本。', '{count} OpenClaw installations detected. Duplicate installs cause 99% of issues. Keep one and uninstall the rest.', '檢測到 {count} 個 OpenClaw 安裝,重複安裝是 99% 問題的根源。建議只保留一個,卸載其餘副本。'),
cleanupSingleSummary: _('只检测到 1 个 OpenClaw 安装,状态正常。', 'Only 1 OpenClaw installation detected. Status is normal.', '只檢測到 1 個 OpenClaw 安裝,狀態正常。'),
cleanupHowTo: _('如何清理?', 'How to clean up?', '如何清理?'),
cleanupHowToDesc: _('1. 先点击「绑定此安装」锁定你要保留的版本2. 复制其余安装的卸载命令在终端中执行3. 刷新状态确认清理完成。', '1. Click "Bind this installation" to lock the version you want to keep. 2. Copy the uninstall commands for the others and run them in your terminal. 3. Refresh status to confirm cleanup.', '1. 先點擊「綁定此安裝」鎖定你要保留的版本2. 複製其餘安裝的卸載命令在終端中執行3. 重新整理狀態確認清理完成。'),
cleanupInstallationsTitle: _('已检测到的安装 ({count})', 'Detected installations ({count})', '已檢測到的安裝 ({count})'),
cleanupDangerZone: _('危险操作:全量卸载', 'Danger zone: Full uninstall', '危險操作:全量卸載'),
cleanupDangerDesc: _('以下操作将卸载所有 OpenClaw 安装(包括当前使用的)。通常不需要这样做,除非你想彻底清理后重新安装。', 'The following will uninstall ALL OpenClaw installations (including the active one). This is usually unnecessary unless you want to start completely fresh.', '以下操作將卸載所有 OpenClaw 安裝(包括目前使用的)。通常不需要這樣做,除非你想徹底清理後重新安裝。'),
cleanupUninstallAll: _('卸载全部(保留配置)', 'Uninstall all (keep config)', '卸載全部(保留設定)'),
cleanupUninstallAllWithConfig: _('卸载全部 + 清除配置', 'Uninstall all + delete config', '卸載全部 + 清除設定'),
cleanupConfirmUninstall: _('确定要卸载所有 OpenClaw 安装吗?\n配置文件会被保留可以重新安装后恢复。', 'Uninstall all OpenClaw installations?\nConfig files will be preserved and can be restored after reinstalling.', '確定要卸載所有 OpenClaw 安裝嗎?\n設定檔案會被保留可以重新安裝後恢復。'),
cleanupConfirmUninstallConfig: _('确定要卸载所有 OpenClaw 并删除配置目录吗?\n⚠ 这将删除所有配置、Agent 数据和会话记录,无法恢复!', 'Uninstall all OpenClaw AND delete config directory?\n⚠ This will delete all config, agent data, and session history permanently!', '確定要卸載所有 OpenClaw 並刪除設定目錄嗎?\n⚠ 這將刪除所有設定、Agent 資料和會話記錄,無法恢復!'),
cleanupUninstalling: _('正在卸载,请稍候...', 'Uninstalling, please wait...', '正在卸載,請稍候...'),
cleanupUninstallFailed: _('卸载失败', 'Uninstall failed', '卸載失敗'),
}

View File

@@ -90,6 +90,91 @@
"remoteHint": "远程服务器需要运行 ClawPanel (serve.js)。",
"example": "示例"
},
"engine": {
"switchedTo": "已切换到 {name} 模式",
"hermesSetupDesc": "安装并配置 Hermes Agent",
"hermesSetupIntro": "Hermes Agent 是一个具有工具调用能力的 AI 助手框架。<br>此引擎即将上线,敬请期待!",
"hermesDashboardTitle": "Hermes 仪表盘",
"hermesChatTitle": "Hermes 对话",
"dashGatewayStatus": "Gateway 状态",
"dashRunning": "运行中",
"dashStopped": "已停止",
"dashModel": "当前模型",
"dashNoModel": "未配置",
"dashVersion": "版本",
"dashApiEndpoint": "API 地址",
"dashStartGw": "启动 Gateway",
"dashStopGw": "停止",
"dashRestartGw": "重启",
"dashStopping": "停止中…",
"dashRestarting": "重启中…",
"dashQuickActions": "快捷操作",
"dashOpenChat": "打开对话",
"dashOpenCron": "定时任务",
"dashOpenSetup": "安装配置",
"dashModelConfig": "模型配置",
"dashQuickSwitch": "快速切换",
"configTitle": "模型配置",
"configDesc": "选择服务商并配置 API Key 和模型",
"configProvider": "服务商",
"configApiKey": "API Key",
"configModel": "模型",
"configFetchModels": "获取模型",
"configFetching": "获取中…",
"configFetchNeedUrl": "请先填写 API Base URL",
"configFetchNeedKey": "请先填写 API Key",
"configFetchNotSupported": "该接口不支持模型列表获取",
"configFetchSuccess": "获取到 {count} 个模型",
"configSaveBtn": "保存配置",
"configSkipBtn": "跳过",
"gatewayTitle": "启动 Gateway",
"gatewayDesc": "启动 Hermes Gateway 以使用对话和工具调用功能",
"gatewayRunning": "Gateway 运行中(端口 {port}",
"gatewayStopped": "Gateway 未运行",
"gatewayStartBtn": "启动 Gateway",
"gatewayStarting": "启动中…",
"gatewayStartFailed": "Gateway 启动失败",
"setupComplete": "设置完成",
"setupCompleteDesc": "Hermes Agent 已配置完成,可以开始使用了!",
"goToDashboard": "前往仪表盘",
"detecting": "检测中…",
"pythonFound": "Python {version} 已安装",
"pythonTooOld": "Python {version} 版本过低(需 3.11+",
"pythonNotFound": "未检测到 Python",
"uvFound": "uv 已安装",
"uvNotFound": "未检测到 uv",
"gitFound": "Git 已安装",
"gitNotFound": "未检测到 Git",
"hermesFound": "Hermes {version} 已安装",
"hermesNotFound": "未检测到 Hermes Agent",
"hermesReady": "Gateway 运行中,可直接使用",
"installTitle": "安装 Hermes Agent",
"installDesc": "选择需要的扩展功能,然后点击安装",
"installBtn": "开始安装",
"installingBtn": "安装中…",
"installSuccess": "安装成功",
"installFailed": "安装失败",
"extrasTitle": "扩展功能",
"extrasDesc": "选择需要安装的扩展",
"extraAll": "全选",
"extraCron": "定时任务",
"extraCli": "命令行",
"extraPty": "终端",
"extraMcp": "MCP 工具",
"extraMessaging": "消息平台",
"extraFeishu": "飞书",
"extraDingtalk": "钉钉",
"extraSlack": "Slack",
"extraVoice": "语音",
"viewLogs": "查看日志",
"hideLogs": "隐藏日志",
"chatEmptyHint": "输入消息开始对话",
"chatGatewayOffline": "Gateway 未运行,请先启动",
"chatPlaceholder": "输入消息…",
"chatSend": "发送",
"chatThinking": "思考中…",
"chatNewSession": "新对话"
},
"dashboard": {
"title": "仪表盘",
"desc": "OpenClaw 运行状态概览",

View File

@@ -18,6 +18,9 @@ import { tryShowEngagement } from './components/engagement.js'
import { toast } from './components/toast.js'
import { initI18n, t } from './lib/i18n.js'
import { initFeatureGates } from './lib/feature-gates.js'
import { registerEngine, initEngineManager, getActiveEngine, getActiveEngineId, onEngineChange } from './lib/engine-manager.js'
import openclawEngine from './engines/openclaw/index.js'
import hermesEngine from './engines/hermes/index.js'
// 样式
import './style/variables.css'
@@ -310,31 +313,12 @@ const sidebar = document.getElementById('sidebar')
const content = document.getElementById('content')
async function boot() {
// 注册所有路由,立即渲染 UI不等后端检测
registerRoute('/dashboard', () => import('./pages/dashboard.js'))
registerRoute('/chat', () => import('./pages/chat.js'))
registerRoute('/chat-debug', () => import('./pages/chat-debug.js'))
registerRoute('/services', () => import('./pages/services.js'))
registerRoute('/logs', () => import('./pages/logs.js'))
registerRoute('/models', () => import('./pages/models.js'))
registerRoute('/agents', () => import('./pages/agents.js'))
registerRoute('/agent-detail', () => import('./pages/agent-detail.js'))
registerRoute('/gateway', () => import('./pages/gateway.js'))
registerRoute('/memory', () => import('./pages/memory.js'))
registerRoute('/dreaming', () => import('./pages/dreaming.js'))
registerRoute('/skills', () => import('./pages/skills.js'))
registerRoute('/security', () => import('./pages/security.js'))
registerRoute('/about', () => import('./pages/about.js'))
registerRoute('/assistant', () => import('./pages/assistant.js'))
registerRoute('/setup', () => import('./pages/setup.js'))
registerRoute('/channels', () => import('./pages/channels.js'))
registerRoute('/cron', () => import('./pages/cron.js'))
registerRoute('/usage', () => import('./pages/usage.js'))
registerRoute('/communication', () => import('./pages/communication.js'))
registerRoute('/settings', () => import('./pages/settings.js'))
registerRoute('/route-map', () => import('./pages/route-map.js'))
registerRoute('/plugin-hub', () => import('./pages/plugin-hub.js'))
registerRoute('/diagnose', () => import('./pages/chat-debug.js'))
// 注册引擎
registerEngine(openclawEngine)
registerEngine(hermesEngine)
// 初始化引擎管理器:读取 clawpanel.json 的 engineMode注册对应路由
await initEngineManager()
renderSidebar(sidebar)
initRouter(content)
@@ -386,63 +370,85 @@ async function boot() {
}).catch(() => {})
: Promise.resolve()
ensureWebSession.then(() => loadActiveInstance()).then(() => detectOpenclawStatus()).then(() => initFeatureGates().catch(() => {})).then(() => {
// 重新渲染侧边栏(检测完成后 isOpenclawReady + 功能门控状态已更新)
ensureWebSession.then(() => getActiveEngineId() === 'openclaw' ? loadActiveInstance() : Promise.resolve()).then(async () => {
const engine = getActiveEngine()
if (!engine) return
// 引擎启动(检测安装状态 + 初始化轮询等)
await engine.boot()
// 重新渲染侧边栏(引擎检测完成后状态已更新)
renderSidebar(sidebar)
if (!isOpenclawReady()) {
setDefaultRoute('/setup')
navigate('/setup')
} else {
if (window.location.hash === '#/setup') navigate('/dashboard')
setupGatewayBanner()
startGatewayPoll()
// 自动连接 WebSocket如果 Gateway 正在运行)
if (isGatewayRunning()) {
autoConnectWebSocket()
}
// 监听 Gateway 状态变化,自动连接/断开 WebSocket
onGatewayChange((running) => {
if (running) {
autoConnectWebSocket()
// 正向时机Gateway 启动成功,延迟弹社区引导
setTimeout(tryShowEngagement, 5000)
} else {
wsClient.disconnect()
}
})
// 守护放弃时,弹出恢复选项
if (isTauriRuntime()) {
import('@tauri-apps/api/event').then(async ({ listen }) => {
await listen('guardian-event', (e) => {
if (e.payload?.kind === 'give_up') showGuardianRecovery()
else if (e.payload?.kind === 'auto_fix_start') toast(t('dashboard.fixing'), 'info')
else if (e.payload?.kind === 'auto_fix_retry') toast(t('dashboard.fixDoneRestarting'), 'info')
else if (e.payload?.kind === 'auto_fix_success') toast(t('dashboard.fixDoneRestarted'), 'success')
else if (e.payload?.kind === 'auto_fix_failure') toast(String(e.payload?.message || t('dashboard.fixDoneRestartFail')).slice(0, 240), 'error')
})
}).catch(() => {})
api.guardianStatus().then(status => {
if (status?.giveUp) showGuardianRecovery()
}).catch(() => {})
} else {
onGuardianGiveUp(() => {
showGuardianRecovery()
})
}
// 实例切换时,重连 WebSocket + 重新检测状态
onInstanceChange(async () => {
wsClient.disconnect()
await detectOpenclawStatus()
if (isGatewayRunning()) autoConnectWebSocket()
})
// 监听引擎状态变化(如 setup 完成后 ready 变为 true自动刷新侧边栏
if (engine.onStateChange) {
engine.onStateChange(() => renderSidebar(sidebar))
}
if (engine.onReadyChange) {
engine.onReadyChange(() => renderSidebar(sidebar))
}
// 全局监听后台任务完成/失败事件,自动刷新安装状态和侧边栏
if (isTauriRuntime()) {
if (!engine.isReady()) {
setDefaultRoute(engine.getSetupRoute())
navigate(engine.getSetupRoute())
} else {
const setupRoute = engine.getSetupRoute()
const currentHash = window.location.hash.slice(1) || ''
if (currentHash === setupRoute || !currentHash) {
navigate(engine.getDefaultRoute())
}
// === OpenClaw 专属逻辑WebSocket、Guardian 守护等) ===
if (getActiveEngineId() === 'openclaw') {
setupGatewayBanner()
// 自动连接 WebSocket如果 Gateway 正在运行)
if (isGatewayRunning()) {
autoConnectWebSocket()
}
// 监听 Gateway 状态变化,自动连接/断开 WebSocket
onGatewayChange((running) => {
if (running) {
autoConnectWebSocket()
// 正向时机Gateway 启动成功,延迟弹社区引导
setTimeout(tryShowEngagement, 5000)
} else {
wsClient.disconnect()
}
})
// 守护放弃时,弹出恢复选项
if (isTauriRuntime()) {
import('@tauri-apps/api/event').then(async ({ listen }) => {
await listen('guardian-event', (e) => {
if (e.payload?.kind === 'give_up') showGuardianRecovery()
else if (e.payload?.kind === 'auto_fix_start') toast(t('dashboard.fixing'), 'info')
else if (e.payload?.kind === 'auto_fix_retry') toast(t('dashboard.fixDoneRestarting'), 'info')
else if (e.payload?.kind === 'auto_fix_success') toast(t('dashboard.fixDoneRestarted'), 'success')
else if (e.payload?.kind === 'auto_fix_failure') toast(String(e.payload?.message || t('dashboard.fixDoneRestartFail')).slice(0, 240), 'error')
})
}).catch(() => {})
api.guardianStatus().then(status => {
if (status?.giveUp) showGuardianRecovery()
}).catch(() => {})
} else {
onGuardianGiveUp(() => {
showGuardianRecovery()
})
}
// 实例切换时,重连 WebSocket + 重新检测状态
onInstanceChange(async () => {
wsClient.disconnect()
await detectOpenclawStatus()
if (isGatewayRunning()) autoConnectWebSocket()
})
}
}
// 全局监听后台任务完成/失败事件,自动刷新安装状态和侧边栏(仅 OpenClaw
if (isTauriRuntime() && getActiveEngineId() === 'openclaw') {
import('@tauri-apps/api/event').then(async ({ listen }) => {
const refreshAfterTask = async () => {
// 清除 API 缓存,确保拿到最新状态

View File

@@ -8,6 +8,7 @@ import { showUpgradeModal, showConfirm } from '../components/modal.js'
import { setUpgrading } from '../lib/app-state.js'
import { icon, statusIcon } from '../lib/icons.js'
import { t, getLang } from '../lib/i18n.js'
import { getActiveEngineId } from '../lib/engine-manager.js'
export async function render() {
const page = document.createElement('div')
@@ -52,7 +53,11 @@ export async function render() {
</div>
`
loadData(page)
if (getActiveEngineId() === 'hermes') {
loadHermesData(page)
} else {
loadData(page)
}
renderCommunity(page)
renderProjects(page)
renderContribute(page)
@@ -61,6 +66,61 @@ export async function render() {
return page
}
async function loadHermesData(page) {
const cards = page.querySelector('#version-cards')
try {
const [hermesInfo, pythonInfo] = await Promise.all([
api.checkHermes().catch(() => null),
api.checkPython().catch(() => null),
])
let panelVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.1.0'
try {
const { getVersion } = await import('@tauri-apps/api/app')
panelVersion = await getVersion()
} catch {}
let panelUpdateHtml = `<span style="color:var(--text-tertiary)">${t('about.checkingUpdate')}</span>`
checkHotUpdate(cards, panelVersion)
const installed = !!hermesInfo?.installed
const gwRunning = !!hermesInfo?.gatewayRunning
const version = hermesInfo?.hermesVersion || hermesInfo?.version || ''
const model = hermesInfo?.model || ''
const port = hermesInfo?.gatewayPort || 8642
const pyVer = pythonInfo?.version || ''
const pyPath = pythonInfo?.path || ''
const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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" 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">Hermes Agent</span></div>
<div class="stat-card-value">${installed ? (version || t('about.installed')) : t('about.notInstalled')}</div>
<div class="stat-card-meta" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
${gwRunning
? `<span style="color:var(--success)">● Gateway ${t('engine.dashRunning')} · :${port}</span>`
: `<span style="color:var(--text-tertiary)">○ Gateway ${t('engine.dashStopped')}</span>`}
${model ? `<span style="color:var(--text-secondary)">${t('engine.dashModel')}: ${esc(model)}</span>` : ''}
${!installed ? `<a class="btn btn-primary btn-sm" href="#/h/setup" style="padding:2px 8px;font-size:var(--font-size-xs)">${t('about.hermesSetup')}</a>` : ''}
</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">Python</span></div>
<div class="stat-card-value" style="font-size:var(--font-size-sm)">${pyVer || t('about.notInstalled')}</div>
<div class="stat-card-meta" style="word-break:break-all">${esc(pyPath)}</div>
</div>
`
} catch {
cards.innerHTML = `<div class="stat-card"><div class="stat-card-label">${t('common.loadFailed')}</div></div>`
}
}
async function loadData(page) {
const cards = page.querySelector('#version-cards')
try {

View File

@@ -11,6 +11,7 @@ import { OPENCLAW_KB } from '../lib/openclaw-kb.js'
import { icon, statusIcon } from '../lib/icons.js'
import { QTCOOL, PROVIDER_PRESETS, API_TYPES as SHARED_API_TYPES, fetchQtcoolModels } from '../lib/model-presets.js'
import { t } from '../lib/i18n.js'
import { getActiveEngineId } from '../lib/engine-manager.js'
// ── 常量 ──
const STORAGE_KEY = 'clawpanel-assistant'
@@ -714,6 +715,155 @@ const BUILTIN_SKILLS = [
},
]
// ── Hermes 引擎专属 Skills ──
const HERMES_SKILLS = [
{
id: 'hermes-chat-terminal',
icon: icon('terminal', 16),
name: t('assistant.skillHermesChat'),
desc: t('assistant.skillHermesChatDesc'),
tools: ['terminal'],
prompt: `请帮我在终端中启动 Hermes Agent 的交互式对话。
具体操作:
1. 调用 get_system_info 获取系统信息
2. 用 run_command 执行 \`hermes version\` 检查 Hermes 是否已安装
3. 如果已安装,告诉用户可以在终端中运行 \`hermes chat\` 开始对话
4. 如果未安装,给出安装命令:\`uv tool install "hermes-agent @ git+https://github.com/NousResearch/hermes-agent.git" --python 3.11\``,
},
{
id: 'hermes-diagnose',
icon: icon('shield', 16),
name: t('assistant.skillHermesDiagnose'),
desc: t('assistant.skillHermesDiagnoseDesc'),
tools: ['terminal', 'fileOps'],
prompt: `请帮我诊断 Hermes Agent 的运行状态。
具体操作:
1. 调用 get_system_info 获取 OS 类型和主目录
2. 用 run_command 执行 \`hermes version\` 获取版本
3. 用 run_command 执行 \`hermes doctor\` 进行自诊断
4. 用 list_processes 检查 hermes/gateway 进程是否在运行
5. 用 check_port 检查端口 8642 是否在监听
6. 用 read_file 读取 ~/.hermes/config.yaml 检查配置
7. 给出诊断结论和修复建议`,
},
{
id: 'hermes-config',
icon: icon('wrench', 16),
name: t('assistant.skillHermesConfig'),
desc: t('assistant.skillHermesConfigDesc'),
tools: ['fileOps'],
prompt: `请帮我检查 Hermes Agent 的配置文件。
具体操作:
1. 调用 get_system_info 获取系统信息,确定主目录
2. 用 list_directory 查看 ~/.hermes/ 目录结构
3. 用 read_file 读取 ~/.hermes/config.yaml
4. 用 read_file 读取 ~/.hermes/.env注意隐藏 API Key
5. 分析配置内容,检查:
- 模型配置是否正确
- API Key 和 Base URL 是否设置
- Gateway 端口配置
6. 给出配置健康度评估和改进建议`,
},
{
id: 'hermes-browse-dir',
icon: icon('folder', 16),
name: t('assistant.skillHermesBrowseDir'),
desc: t('assistant.skillHermesBrowseDirDesc'),
tools: ['fileOps'],
prompt: `请帮我浏览 Hermes Agent 的工作目录。
具体操作:
1. 调用 get_system_info 获取主目录路径
2. 用 list_directory 列出 ~/.hermes/ 根目录
3. 简要说明每个目录/文件的作用:
- config.yaml: 全局配置
- .env: 环境变量API Key、Base URL 等)
- sessions/: 对话会话记录
- skills/: Skills 目录
- logs/: 日志文件
- cron/: 定时任务配置
4. 标注关键配置文件和常用路径`,
},
{
id: 'hermes-upgrade',
icon: icon('zap', 16),
name: t('assistant.skillHermesUpgrade'),
desc: t('assistant.skillHermesUpgradeDesc'),
tools: ['terminal'],
prompt: `请帮我升级 Hermes Agent 到最新版本。
具体操作:
1. 调用 get_system_info 获取系统信息
2. 用 run_command 执行 \`hermes version\` 获取当前版本
3. 告诉用户升级命令:
\`uv tool install --reinstall "hermes-agent @ git+https://github.com/NousResearch/hermes-agent.git" --python 3.11\`
4. 提醒用户升级前先停止 Gateway\`hermes gateway stop\`
5. 升级完成后建议重新启动 Gateway`,
},
{
id: 'hermes-logs',
icon: icon('clipboard', 16),
name: t('assistant.skillHermesLogs'),
desc: t('assistant.skillHermesLogsDesc'),
tools: ['terminal', 'fileOps'],
prompt: `请帮我分析 Hermes Agent 最近的日志。
具体操作:
1. 调用 get_system_info 获取主目录路径
2. 用 list_directory 查看 ~/.hermes/ 有哪些日志文件
3. 用 read_file 读取 ~/.hermes/gateway-run.log 和 ~/.hermes/gateway-err.log
4. 搜索 ERROR、WARN、fail、exception 等关键词
5. 分析错误原因,给出具体修复建议`,
},
{
id: 'hermes-uninstall',
icon: icon('trash', 16),
name: t('assistant.skillHermesUninstall'),
desc: t('assistant.skillHermesUninstallDesc'),
tools: [],
prompt: `请告诉我如何完全卸载 Hermes Agent。
卸载步骤:
1. 停止 Gateway\`hermes gateway stop\`
2. 卸载 Hermes Agent\`uv tool uninstall hermes-agent\`
3. 可选:删除配置目录 ~/.hermes/Windows: %USERPROFILE%\\.hermes
4. 可选:卸载 uv 包管理器
请详细说明每一步,并提醒用户备份重要数据。`,
},
{
id: 'report-bug',
icon: icon('bug', 16),
name: t('assistant.skillReportBug'),
desc: t('assistant.skillReportBugDesc'),
tools: ['terminal', 'fileOps'],
prompt: `我想反馈一个 Bug请帮我整理成标准的 GitHub Issue。
具体操作:
1. 用 ask_user 工具询问我遇到了什么问题(如果我还没说的话)
2. 调用 get_system_info 获取系统环境信息
3. 用 run_command 收集hermes version、node -v 等版本信息
4. 用 read_file 读取最近的错误日志(如有)
5. 按标准 Issue 模板整理:
- **问题描述**(一句话)
- **复现步骤**1, 2, 3...
- **期望行为** / **实际行为**
- **环境信息**(自动填充)
- **相关日志**(如有)
6. 给出对应仓库的 Issue 链接:
- ClawPanel: https://github.com/qingchencloud/clawpanel/issues/new
`,
},
]
/** 根据当前引擎返回对应的技能列表 */
function getBuiltinSkills() {
return getActiveEngineId() === 'hermes' ? HERMES_SKILLS : BUILTIN_SKILLS
}
function currentMode() {
return MODES[_config?.mode] ? _config.mode : DEFAULT_MODE
}
@@ -937,13 +1087,15 @@ function buildSystemPrompt() {
// 注入内置技能列表
prompt += '\n\n## 内置技能卡片'
prompt += '\n用户可以在欢迎页点击技能卡片快速触发操作。当用户遇到问题时你也可以主动推荐合适的技能'
for (const s of BUILTIN_SKILLS) {
for (const s of getBuiltinSkills()) {
prompt += `\n- **${s.name}**${s.desc}`
}
prompt += '\n\n当用户的需求匹配某个技能时可以建议用户点击对应的技能卡片或者你直接按技能的步骤操作。'
// 注入内置 OpenClaw 知识库
prompt += '\n\n' + OPENCLAW_KB
// 注入内置知识库(仅 OpenClaw 模式)
if (getActiveEngineId() !== 'hermes') {
prompt += '\n\n' + OPENCLAW_KB
}
// 注入用户自定义知识库内容
const kbEnabled = (_config.knowledgeFiles || []).filter(f => f.enabled !== false && f.content)
@@ -2493,7 +2645,7 @@ function renderMessages() {
const session = getCurrentSession()
if (!_messagesEl) return
if (!session || session.messages.length === 0) {
const skillCards = BUILTIN_SKILLS.map(s => `
const skillCards = getBuiltinSkills().map(s => `
<button class="ast-skill-card" data-skill="${s.id}">
<span class="ast-skill-icon">${s.icon}</span>
<div class="ast-skill-info">
@@ -2623,6 +2775,7 @@ function buildTestResult({ success, elapsed, usedApi, reqUrl, reqBody, respStatu
function showSettings() {
const c = _config
const isHermes = getActiveEngineId() === 'hermes'
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
overlay.innerHTML = `
@@ -2664,7 +2817,7 @@ function showSettings() {
<div style="display:flex;gap:6px;padding-bottom:1px">
<button class="btn btn-sm btn-secondary" id="ast-btn-test" title="${t('assistant.testConnTitle')}">${t('assistant.testBtn')}</button>
<button class="btn btn-sm btn-secondary" id="ast-btn-models" title="${t('assistant.fetchModelsTitle')}">${t('assistant.fetchBtn')}</button>
<button class="btn btn-sm btn-secondary" id="ast-btn-import" title="${t('assistant.importTitle')}">${icon('download', 14)} ${t('assistant.importBtn')}</button>
${!isHermes ? `<button class="btn btn-sm btn-secondary" id="ast-btn-import" title="${t('assistant.importTitle')}">${icon('download', 14)} ${t('assistant.importBtn')}</button>` : ''}
</div>
</div>
<div id="ast-test-result" style="margin:6px 0 2px;font-size:12px;min-height:16px"></div>
@@ -2714,10 +2867,10 @@ function showSettings() {
<div id="ast-qtcool-status" style="margin-top:8px;font-size:11px;min-height:16px;line-height:1.5"></div>
</div>
<div style="border-top:1px solid var(--border-primary);padding:6px 16px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px;background:var(--bg-tertiary)">
<div style="display:flex;gap:8px;align-items:center">
${!isHermes ? `<div style="display:flex;gap:8px;align-items:center">
<button class="btn btn-xs btn-secondary" id="ast-qtcool-sync-to" title="${t('assistant.qtcoolSyncToTitle')}">${icon('upload', 11)} ${t('assistant.qtcoolSyncTo')}</button>
<button class="btn btn-xs btn-secondary" id="ast-qtcool-sync-from" title="${t('assistant.qtcoolSyncFromTitle')}">${icon('download', 11)} ${t('assistant.qtcoolSyncFrom')}</button>
</div>
</div>` : '<div></div>'}
<a href="${QTCOOL.site}" target="_blank" style="color:var(--primary);text-decoration:none;font-size:11px">${icon('external-link', 11)} ${t('assistant.qtcoolLearnMore')}</a>
</div>
</div>
@@ -2755,7 +2908,7 @@ function showSettings() {
<div class="form-hint" style="margin-top:10px">${t('assistant.toolsAlwaysAvailable')}</div>
</div>
<div class="ast-tab-panel" data-panel="persona">
<div class="form-group">
${!isHermes ? `<div class="form-group">
<label class="form-label">${t('assistant.personaSource')}</label>
<div style="display:flex;flex-direction:column;gap:6px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
@@ -2767,8 +2920,8 @@ function showSettings() {
<span>${t('assistant.personaOpenClaw')} <span style="font-size:11px;color:var(--text-tertiary)">${t('assistant.personaOpenClawHint')}</span></span>
</label>
</div>
</div>
<div id="ast-soul-default" style="${c.soulSource?.startsWith('openclaw:') ? 'display:none' : ''}">
</div>` : ''}
<div id="ast-soul-default" style="${!isHermes && c.soulSource?.startsWith('openclaw:') ? 'display:none' : ''}">
<div class="form-group">
<label class="form-label">${t('assistant.personaName')}</label>
<input class="form-input" id="ast-name" value="${escHtml(c.assistantName || DEFAULT_NAME)}" placeholder="${DEFAULT_NAME}">
@@ -2779,7 +2932,7 @@ function showSettings() {
<div class="form-hint">${t('assistant.personaPersonalityHint')}</div>
</div>
</div>
<div id="ast-soul-openclaw" style="${c.soulSource?.startsWith('openclaw:') ? '' : 'display:none'}">
<div id="ast-soul-openclaw" style="${!isHermes && c.soulSource?.startsWith('openclaw:') ? '' : 'display:none'}">
<div class="form-group" style="margin-top:4px">
<label class="form-label">${t('assistant.personaSelectAgent')}</label>
<div style="display:flex;gap:6px;align-items:center">
@@ -3433,8 +3586,9 @@ function showSettings() {
}
}
// 从 OpenClaw 导入模型配置
overlay.querySelector('#ast-btn-import').onclick = async (e) => {
// 从 OpenClaw 导入模型配置Hermes 模式下该按钮不存在)
const importBtn = overlay.querySelector('#ast-btn-import')
if (importBtn) importBtn.onclick = async (e) => {
const btn = e.target
btn.disabled = true
btn.textContent = t('assistant.personaScanning')
@@ -4421,7 +4575,7 @@ export async function render() {
_messagesEl.addEventListener('click', (e) => {
const skillCard = e.target.closest('.ast-skill-card')
if (skillCard) {
const skill = BUILTIN_SKILLS.find(s => s.id === skillCard.dataset.skill)
const skill = getBuiltinSkills().find(s => s.id === skillCard.dataset.skill)
if (!skill) return
// 技能需要工具 → 自动切换到执行模式(如果当前是聊天模式)

View File

@@ -2841,6 +2841,7 @@ function appendSystemMessage(text) {
}
function clearMessages() {
if (!_messagesEl) return
_messagesEl.querySelectorAll('.msg').forEach(m => m.remove())
_autoScrollEnabled = true
_lastScrollTop = 0

View File

@@ -4,7 +4,7 @@
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { getActiveInstance, onGatewayChange } from '../lib/app-state.js'
import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGatewayBindingPrompt, showGatewayConflictGuidance } from '../lib/gateway-ownership.js'
import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGatewayBindingPrompt, showGatewayConflictGuidance, showInstallationCleanup } from '../lib/gateway-ownership.js'
import { navigate } from '../router.js'
import { t } from '../lib/i18n.js'
import { wsClient } from '../lib/ws-client.js'
@@ -274,11 +274,13 @@ function renderStatCards(page, services, version, agents, config, panelConfig) {
${multiInstall && !cliBound
? `<div class="stat-card-meta" style="margin-top:8px;color:var(--warning);line-height:1.6">${t('dashboard.multiInstallCardHint')}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
<button class="btn btn-primary btn-xs" data-action="open-cleanup">${t('services.cleanupTitle')}</button>
<button class="btn btn-secondary btn-xs" data-action="resolve-multi-install">${t('dashboard.viewGuidance')}</button>
<button class="btn btn-primary btn-xs" data-action="open-settings">${t('dashboard.goSettings')}</button>
<button class="btn btn-secondary btn-xs" data-action="open-settings">${t('dashboard.goSettings')}</button>
</div>`
: multiInstall && cliBound
? `<div class="stat-card-meta" style="margin-top:4px;color:var(--text-tertiary);font-size:11px">✓ ${t('dashboard.multiInstallBoundOk', { count: installCount })}</div>`
? `<div class="stat-card-meta" style="margin-top:4px;color:var(--text-tertiary);font-size:11px">✓ ${t('dashboard.multiInstallBoundOk', { count: installCount })}</div>
<div style="margin-top:6px"><button class="btn btn-secondary btn-xs" data-action="open-cleanup">${t('services.cleanupTitle')}</button></div>`
: ''}
</div>
<div class="stat-card">
@@ -604,6 +606,11 @@ function bindActions(page) {
return
}
if (action === 'open-cleanup') {
await showInstallationCleanup({ onRefresh: () => loadDashboardData(page, true) })
return
}
if (action === 'resolve-foreign-gateway') {
await openGatewayConflict(page, null, 'foreign-gateway')
return

View File

@@ -6,6 +6,7 @@ import { api, invalidate } from '../lib/tauri-api.js'
import { showConfirm, showUpgradeModal } from '../components/modal.js'
import { toast } from '../components/toast.js'
import { setUpgrading, isMacPlatform } from '../lib/app-state.js'
import { getActiveEngine } from '../lib/engine-manager.js'
import { diagnoseInstallError } from '../lib/error-diagnosis.js'
import { icon, statusIcon } from '../lib/icons.js'
import { t } from '../lib/i18n.js'
@@ -582,19 +583,16 @@ function bindEvents(page, nodeOk, detectState) {
window.location.hash = '/assistant'
})
// 进入面板
page.querySelector('#btn-enter')?.addEventListener('click', () => {
window.location.hash = '/dashboard'
})
page.querySelector('#btn-goto-models')?.addEventListener('click', () => {
window.location.hash = '/models'
})
page.querySelector('#btn-goto-gateway')?.addEventListener('click', () => {
window.location.hash = '/gateway'
})
page.querySelector('#btn-goto-channels')?.addEventListener('click', () => {
window.location.hash = '/channels'
})
// 进入面板(刷新引擎 ready 状态,触发侧边栏更新)
async function refreshAndNavigate(route) {
const engine = getActiveEngine()
if (engine?.detect) await engine.detect()
window.location.hash = route
}
page.querySelector('#btn-enter')?.addEventListener('click', () => refreshAndNavigate('/dashboard'))
page.querySelector('#btn-goto-models')?.addEventListener('click', () => refreshAndNavigate('/models'))
page.querySelector('#btn-goto-gateway')?.addEventListener('click', () => refreshAndNavigate('/gateway'))
page.querySelector('#btn-goto-channels')?.addEventListener('click', () => refreshAndNavigate('/channels'))
// 一键安装 Git
page.querySelector('#btn-auto-install-git')?.addEventListener('click', async () => {

View File

@@ -19,7 +19,8 @@
#sidebar.sidebar-collapsed .nav-section-title,
#sidebar.sidebar-collapsed .nav-item span,
#sidebar.sidebar-collapsed .sidebar-meta,
#sidebar.sidebar-collapsed .instance-switcher {
#sidebar.sidebar-collapsed .instance-switcher,
#sidebar.sidebar-collapsed .engine-switcher {
display: none;
}
#sidebar.sidebar-collapsed .sidebar-header {
@@ -82,134 +83,102 @@
white-space: nowrap;
}
/* === Instance Switcher === */
.instance-switcher {
/* === Engine Switcher === */
.engine-switcher {
padding: var(--space-xs) var(--space-sm);
border-bottom: 1px solid var(--border-secondary);
position: relative;
}
.instance-current {
.engine-current {
display: flex;
align-items: center;
gap: var(--space-xs);
gap: 6px;
width: 100%;
padding: 6px 10px;
border: 1px solid var(--border-primary);
padding: 5px 10px;
border: 1px solid var(--border-secondary);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: var(--font-size-sm);
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: var(--font-size-xs);
cursor: pointer;
transition: border-color 0.15s;
transition: border-color 0.15s, background 0.15s;
}
.instance-current:hover {
.engine-current:hover {
border-color: var(--accent);
background: var(--bg-secondary);
}
.instance-dot {
width: 8px;
height: 8px;
border-radius: 50%;
.engine-icon {
flex-shrink: 0;
display: flex;
align-items: center;
}
.instance-dot.local { background: var(--accent); }
.instance-dot.remote { background: #f59e0b; }
.instance-dot.online { background: var(--success, #22c55e); }
.instance-dot.offline { background: var(--error, #ef4444); }
.instance-label {
.engine-icon svg {
width: 14px;
height: 14px;
}
.engine-label {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.instance-chevron {
.engine-chevron {
flex-shrink: 0;
opacity: 0.5;
opacity: 0.4;
}
.instance-dropdown {
.engine-dropdown {
display: none;
position: absolute;
left: var(--space-sm);
right: var(--space-sm);
top: 100%;
z-index: 100;
z-index: 110;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
box-shadow: 0 4px 16px rgba(0,0,0,0.18);
padding: var(--space-xs) 0;
max-height: 260px;
max-height: 200px;
overflow-y: auto;
}
.instance-dropdown.open { display: block; }
.instance-option {
.engine-dropdown.open { display: block; }
.engine-option {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 6px 12px;
gap: 8px;
padding: 8px 12px;
font-size: var(--font-size-sm);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.1s;
}
.instance-option:hover { background: var(--bg-tertiary); }
.instance-option.active {
.engine-option:hover { background: var(--bg-tertiary); }
.engine-option.active {
color: var(--accent);
font-weight: 600;
}
.instance-option.instance-add {
color: var(--text-tertiary);
font-size: var(--font-size-xs);
.engine-opt-icon {
flex-shrink: 0;
display: flex;
align-items: center;
}
.instance-opt-name {
.engine-opt-icon svg {
width: 16px;
height: 16px;
}
.engine-opt-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.instance-badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
background: var(--bg-tertiary);
color: var(--text-tertiary);
.engine-active-check {
flex-shrink: 0;
}
.instance-badge.docker {
background: rgba(231,76,60,.1);
color: #e74c3c;
}
.instance-badge.remote {
background: rgba(6,182,212,.1);
color: #06b6d4;
}
.instance-hint {
font-size: 11px;
color: var(--text-tertiary);
padding: 6px 10px 4px;
line-height: 1.4;
border-bottom: 1px solid var(--border-secondary);
margin-bottom: 2px;
}
.instance-port {
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-tertiary);
flex-shrink: 0;
}
.instance-active-tag {
font-size: 10px;
padding: 0 5px;
border-radius: 3px;
background: rgba(99,102,241,.12);
color: var(--accent);
font-weight: 500;
flex-shrink: 0;
}
.instance-divider {
height: 1px;
background: var(--border-secondary);
margin: var(--space-xs) 0;
display: flex;
align-items: center;
}
.sidebar-nav {

View File

@@ -1673,4 +1673,807 @@
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* === Hermes Setup — Phase Indicator === */
.hermes-phases {
display: flex;
align-items: center;
gap: 0;
margin-bottom: 20px;
padding: 16px 0;
}
.hermes-phase {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.hermes-phase-dot {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bg-tertiary);
color: var(--text-tertiary);
font-size: 12px;
font-weight: 700;
border: 2px solid var(--border-primary);
transition: all 0.2s;
}
.hermes-phase.active .hermes-phase-dot {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.hermes-phase.done .hermes-phase-dot {
background: var(--bg-secondary);
border-color: var(--accent);
}
.hermes-phase-label {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
}
.hermes-phase.active .hermes-phase-label {
color: var(--accent);
font-weight: 600;
}
.hermes-phase.done .hermes-phase-label {
color: var(--text-secondary);
}
.hermes-phase-line {
flex: 1;
height: 2px;
background: var(--border-primary);
margin: 0 6px;
margin-bottom: 20px;
}
/* === Hermes Setup — Detection === */
.hermes-detect-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.hermes-detect-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: var(--radius-sm, 6px);
background: var(--bg-tertiary);
font-size: 13px;
color: var(--text-secondary);
}
.hermes-detect-row.ok {
color: var(--text-primary);
}
.hermes-detect-row.warn {
color: var(--warning, #f59e0b);
}
.hermes-detect-row svg {
flex-shrink: 0;
}
/* === Hermes Setup — Extras Grid === */
.hermes-extras-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 6px;
}
.hermes-extra-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: var(--radius-sm, 6px);
background: var(--bg-tertiary);
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
transition: background 0.15s;
}
.hermes-extra-item:hover {
background: var(--bg-secondary);
}
.hermes-extra-item input[type="checkbox"] {
accent-color: var(--accent);
}
/* === Hermes Setup — Progress Bar === */
.hermes-progress {
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
margin-bottom: 16px;
}
.hermes-progress-bar {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.3s ease;
}
/* === Hermes Setup — Log Panel === */
.hermes-log-panel {
margin-top: 12px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md, 8px);
overflow: hidden;
}
.hermes-log-content {
max-height: 240px;
overflow-y: auto;
padding: 12px 16px;
background: var(--bg-tertiary);
font-family: var(--font-mono, monospace);
font-size: 12px;
line-height: 1.7;
color: var(--text-secondary);
}
.hermes-log-content div {
white-space: pre-wrap;
word-break: break-all;
}
/* === Hermes Setup — Form === */
.hermes-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.hermes-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.hermes-field > span {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.hermes-field .input {
padding: 8px 12px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm, 6px);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.hermes-field .input:focus {
border-color: var(--accent);
}
.hermes-field select.input {
cursor: pointer;
}
/* === Hermes Model Dropdown === */
.hermes-model-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 240px;
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm, 6px);
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
z-index: 50;
margin-top: 2px;
}
.hermes-model-option:hover {
background: var(--bg-tertiary);
}
.hermes-model-option:last-child {
border-bottom: none !important;
}
/* === Hermes Chat Page === */
.hermes-chat-page {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 !important;
}
/* Chat layout: sidebar + main */
.hm-chat-layout {
display: flex;
height: 100%;
min-height: 0;
}
.hm-chat-sidebar {
width: 240px;
min-width: 240px;
border-right: 1px solid var(--border-primary);
display: flex;
flex-direction: column;
background: var(--bg-secondary);
}
.hm-chat-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 14px 12px;
border-bottom: 1px solid var(--border-primary);
}
.hm-chat-sidebar-header span {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.hm-new-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: background 0.15s, opacity 0.15s;
}
.hm-new-btn:hover {
background: var(--bg-tertiary);
opacity: 1 !important;
}
.hm-chat-session-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.hm-session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
margin-bottom: 2px;
}
.hm-session-item:hover {
background: var(--bg-tertiary);
}
.hm-session-item.active {
background: var(--accent-subtle, rgba(99,102,241,0.1));
color: var(--text-primary);
font-weight: 500;
}
.hm-session-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hm-session-del {
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary);
font-size: 16px;
padding: 0 2px;
opacity: 0;
transition: opacity 0.15s;
}
.hm-session-item:hover .hm-session-del {
opacity: 0.6;
}
.hm-session-del:hover {
opacity: 1 !important;
color: var(--error);
}
/* Chat main area */
.hm-chat-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg-primary);
}
/* Model bar */
.hm-chat-model-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
border-bottom: 1px solid var(--border-primary);
font-size: 12px;
flex-shrink: 0;
background: var(--bg-secondary);
}
.hm-chat-model-bar .hm-model-label {
color: var(--text-tertiary);
font-size: 12px;
white-space: nowrap;
}
.hm-chat-model-bar .hm-model-input {
font-size: 12px;
padding: 4px 10px;
height: 28px;
border-radius: 6px;
cursor: pointer;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
color: var(--text-primary);
font-weight: 500;
transition: border-color 0.15s;
}
.hm-chat-model-bar .hm-model-input:hover {
border-color: var(--accent);
}
.hm-file-access-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-primary);
color: var(--text-tertiary);
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
.hm-file-access-toggle:hover {
border-color: var(--accent);
color: var(--text-secondary);
}
.hm-file-access-toggle.active {
background: rgba(99,102,241,0.12);
border-color: var(--accent);
color: var(--accent);
}
.hm-file-access-toggle svg {
flex-shrink: 0;
}
.hm-chat-model-bar .hm-model-link {
font-size: 11px;
color: var(--accent);
text-decoration: none;
white-space: nowrap;
opacity: 0.8;
transition: opacity 0.15s;
}
.hm-chat-model-bar .hm-model-link:hover {
opacity: 1;
text-decoration: underline;
}
.hm-model-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 8px;
z-index: 100;
box-shadow: 0 8px 24px rgba(0,0,0,.12);
margin-top: 4px;
padding: 4px;
}
.hm-chat-model-opt {
padding: 6px 12px;
cursor: pointer;
font-size: 12px;
border-radius: 6px;
transition: background 0.12s;
color: var(--text-secondary);
}
.hm-chat-model-opt:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.hm-chat-model-opt.active {
font-weight: 600;
color: var(--accent);
}
/* Slash command menu */
.hm-slash-menu {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
margin-bottom: 6px;
max-height: 200px;
overflow-y: auto;
z-index: 50;
padding: 4px;
}
.hm-slash-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: background 0.12s;
}
.hm-slash-item:hover {
background: var(--bg-tertiary);
}
.hm-slash-cmd {
font-weight: 600;
color: var(--accent);
font-family: var(--font-mono, monospace);
min-width: 70px;
}
.hm-slash-desc {
color: var(--text-tertiary);
}
/* Stream area */
.hm-stream-area {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 4px;
}
/* Tool summary (stored in messages) */
.hm-tool-summary {
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px 0;
}
/* Tool card (shared by streaming + summary) */
.hm-tool-card {
border-radius: 10px;
background: var(--bg-tertiary);
border-left: 3px solid var(--accent);
overflow: hidden;
transition: border-color 0.2s;
}
.hm-tool-card.active {
animation: hm-tool-pulse 1.5s ease-in-out infinite;
}
.hm-tool-card.done {
border-left-color: var(--success, #22c55e);
animation: none;
}
.hm-tool-card.err {
border-left-color: var(--error, #ef4444);
animation: none;
}
.hm-tool-card-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
transition: background 0.12s;
}
.hm-tool-card-header:hover {
background: rgba(0,0,0,0.04);
}
.hm-tool-name {
font-weight: 600;
font-family: var(--font-mono, monospace);
font-size: 12px;
}
.hm-tool-status {
margin-left: auto;
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
}
.hm-tool-card.err .hm-tool-status {
color: var(--error, #ef4444);
}
.hm-tool-card.done .hm-tool-status {
color: var(--success, #22c55e);
}
.hm-tool-toggle {
font-size: 10px;
color: var(--text-tertiary);
margin-left: 4px;
transition: transform 0.15s;
}
@keyframes hm-tool-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Tool card details (expandable) */
.hm-tool-details {
border-top: 1px solid var(--border-secondary, rgba(0,0,0,0.06));
padding: 8px 14px 10px;
}
.hm-tool-section {
margin-bottom: 8px;
}
.hm-tool-section:last-child {
margin-bottom: 0;
}
.hm-tool-section-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-tertiary);
margin-bottom: 4px;
}
.hm-tool-section-err .hm-tool-section-label {
color: var(--error, #ef4444);
}
.hm-tool-pre {
margin: 0;
padding: 8px 10px;
border-radius: 6px;
background: var(--bg-primary);
border: 1px solid var(--border-secondary, rgba(0,0,0,0.06));
font-family: var(--font-mono, monospace);
font-size: 11px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
.hm-tool-section-err .hm-tool-pre {
background: rgba(239, 68, 68, 0.06);
border-color: rgba(239, 68, 68, 0.15);
color: var(--error, #ef4444);
}
/* CLI commands grid */
.hm-cli-grid {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--border-primary);
border-radius: 8px;
overflow: hidden;
}
.hm-cli-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--border-primary);
transition: background 0.12s;
}
.hm-cli-row:last-child {
border-bottom: none;
}
.hm-cli-row:hover {
background: var(--bg-tertiary);
}
.hm-cli-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 120px;
flex-shrink: 0;
}
.hm-cli-label {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.hm-cli-desc {
font-size: 11px;
color: var(--text-tertiary);
}
.hm-cli-cmd-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.hm-cli-cmd {
flex: 1;
font-family: var(--font-mono, monospace);
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 6px;
padding: 6px 10px;
white-space: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
.hm-cli-copy {
flex-shrink: 0;
background: none;
border: 1px solid var(--border-primary);
border-radius: 6px;
cursor: pointer;
padding: 5px 6px;
color: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.hm-cli-copy:hover {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-subtle, rgba(99,102,241,0.06));
}
/* Legacy container (unused but keep for safety) */
.hermes-chat-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.hermes-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid var(--border-primary);
flex-shrink: 0;
}
.hermes-chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
display: flex;
flex-direction: column;
gap: 16px;
}
.hermes-chat-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: 14px;
gap: 8px;
}
.hermes-chat-empty::before {
content: '';
display: block;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--accent-subtle, rgba(99,102,241,0.1));
margin-bottom: 4px;
}
.hermes-chat-msg {
display: flex;
animation: hm-msg-in 0.2s ease-out;
}
@keyframes hm-msg-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.hermes-chat-msg.user {
justify-content: flex-end;
}
.hermes-chat-msg.assistant {
justify-content: flex-start;
}
.hermes-chat-bubble {
max-width: 75%;
padding: 10px 16px;
border-radius: 18px;
font-size: 14px;
line-height: 1.65;
word-break: break-word;
}
.hermes-chat-bubble.user {
background: var(--accent);
color: #fff;
border-bottom-right-radius: 6px;
box-shadow: 0 1px 3px rgba(99,102,241,0.2);
}
.hermes-chat-bubble.assistant {
background: var(--bg-tertiary);
color: var(--text-primary);
border-bottom-left-radius: 6px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.hermes-chat-bubble pre {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 12px 14px;
overflow-x: auto;
margin: 8px 0;
font-size: 13px;
}
.hermes-chat-bubble code {
font-family: var(--font-mono, 'Consolas', monospace);
font-size: 0.9em;
}
.hermes-chat-bubble pre code {
background: none;
padding: 0;
}
.hermes-chat-bubble code:not(pre code) {
background: rgba(0,0,0,0.06);
padding: 1px 5px;
border-radius: 4px;
}
.hermes-chat-typing {
color: var(--text-tertiary);
font-style: italic;
}
.hermes-chat-input-area {
padding: 12px 32px 20px;
border-top: 1px solid var(--border-primary);
flex-shrink: 0;
background: var(--bg-primary);
}
.hm-chat-input-wrap {
display: flex;
gap: 10px;
align-items: flex-end;
}
.hm-chat-input-wrap textarea {
flex: 1;
resize: none;
min-height: 40px;
max-height: 120px;
padding: 10px 14px;
font-size: 14px;
line-height: 1.5;
border-radius: 12px;
border: 1px solid var(--border-primary);
background: var(--bg-secondary);
color: var(--text-primary);
transition: border-color 0.15s, box-shadow 0.15s;
}
.hm-chat-input-wrap textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle, rgba(99,102,241,0.12));
}
.hm-chat-send {
height: 40px;
padding: 0 20px;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
flex-shrink: 0;
}
.hm-gw-offline {
text-align: center;
padding: 8px 12px;
color: var(--warning);
font-size: 13px;
background: rgba(245,158,11,0.06);
border-radius: 8px;
margin-bottom: 8px;
}
/* === Hermes Spinner === */
@keyframes hermes-spin {
to { transform: rotate(360deg); }
}
.hermes-spin {
animation: hermes-spin 0.8s linear infinite;
}