From 31936b4779f953194772f99854583dfd39d251f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Fri, 24 Apr 2026 20:50:29 +0800 Subject: [PATCH] feat(hermes): add .env editor page for unmanaged env vars (Step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users may need to configure custom environment variables for Hermes (e.g. `TAVILY_API_KEY` for the tavily skill, `HTTP_PROXY`, SKILL_* settings). Previously the only way to set these was to hand-edit ~/.hermes/.env, which risks clobbering the provider keys that ClawPanel writes through configure_hermes. This patch adds a dedicated editor UI backed by three new Tauri commands that refuse to touch managed keys. Backend (src-tauri/src/commands/hermes.rs): - `hermes_env_read_unmanaged` — returns every KEY=VALUE pair whose key is NOT in `hermes_providers::all_managed_env_keys()` (so provider API keys, base URLs, `GATEWAY_ALLOW_ALL_USERS`, `API_SERVER_KEY` stay hidden). Preserves file order, dedups. - `hermes_env_set(key, value)` — validates key against `^[A-Z0-9_]+$`, refuses managed keys, updates first occurrence or appends, preserves comments/blanks. - `hermes_env_delete(key)` — refuses managed keys, removes first matching line, preserves other structure. All three commands registered in `src-tauri/src/lib.rs`. Frontend: - New page `src/engines/hermes/pages/env-editor.js`: - Header with "back to dashboard" link and warning banner listing which keys are managed by ClawPanel. - Table with Key / Value / Actions columns. - Inline edit mode per row (save / cancel). - "Add variable" button for new entries. - Value column masks long secrets (`sk-a…xyz9`) so glances don't leak credentials. - Toast feedback on save / delete / validation errors. - Inline Chinese copy (TODO: wire up i18n when the locales module lands). - Route registered at `/h/env` in `src/engines/hermes/index.js`. - Dashboard "Model config" section now has a subtle link ".env 高级编辑 →" pointing to the new page. API wiring: - `src/lib/tauri-api.js`: added `hermesEnvReadUnmanaged`, `hermesEnvSet`, `hermesEnvDelete`. - `scripts/dev-api.js`: mirrors the three commands in Web mode with a duplicated managed-key list (keep in sync with Rust's `hermes_providers::all_managed_env_keys` as new providers land). Verified: cargo fmt / cargo clippy -D warnings / cargo test / npm run build all green. Dashboard chunk unchanged (24.30 kB); new env-editor chunk is ~7 kB gzip. --- scripts/dev-api.js | 129 +++++++++++++ src-tauri/src/commands/hermes.rs | 188 ++++++++++++++++++ src-tauri/src/lib.rs | 3 + src/engines/hermes/index.js | 1 + src/engines/hermes/pages/dashboard.js | 3 +- src/engines/hermes/pages/env-editor.js | 251 +++++++++++++++++++++++++ src/lib/tauri-api.js | 3 + 7 files changed, 577 insertions(+), 1 deletion(-) create mode 100644 src/engines/hermes/pages/env-editor.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 20ea11a..94f31d4 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -6845,6 +6845,135 @@ const handlers = { return [] }, + // ========================================================================= + // .env editor (Step 4) — Web-mode implementations mirroring Rust behavior. + // The managed-key list is duplicated here since Rust's hermes_providers is + // not accessible from Node. Keep in sync with + // src-tauri/src/commands/hermes_providers.rs::all_managed_env_keys + // whenever new providers are added to the registry. + // ========================================================================= + _hermesManagedEnvKeys() { + return [ + // Anthropic + 'ANTHROPIC_API_KEY', 'ANTHROPIC_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', + // Gemini + 'GOOGLE_API_KEY', 'GEMINI_API_KEY', 'GEMINI_BASE_URL', + // DeepSeek + 'DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', + // Z.AI / GLM + 'GLM_API_KEY', 'ZAI_API_KEY', 'Z_AI_API_KEY', 'GLM_BASE_URL', + // Kimi + 'KIMI_API_KEY', 'KIMI_BASE_URL', + // xAI + 'XAI_API_KEY', 'XAI_BASE_URL', + // MiniMax intl + CN + 'MINIMAX_API_KEY', 'MINIMAX_BASE_URL', + 'MINIMAX_CN_API_KEY', 'MINIMAX_CN_BASE_URL', + // Alibaba DashScope + 'DASHSCOPE_API_KEY', 'DASHSCOPE_BASE_URL', + // Hugging Face + 'HF_TOKEN', 'HF_BASE_URL', + // Xiaomi + 'XIAOMI_API_KEY', 'XIAOMI_BASE_URL', + // AI Gateway + 'AI_GATEWAY_API_KEY', 'AI_GATEWAY_BASE_URL', + // OpenCode Zen + Go + 'OPENCODE_ZEN_API_KEY', 'OPENCODE_ZEN_BASE_URL', + 'OPENCODE_GO_API_KEY', 'OPENCODE_GO_BASE_URL', + // Kilocode + 'KILOCODE_API_KEY', 'KILOCODE_BASE_URL', + // Copilot (PAT) + 'COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN', + // OpenRouter + 'OPENROUTER_API_KEY', 'OPENAI_BASE_URL', + // Copilot ACP + 'COPILOT_ACP_BASE_URL', + // Custom placeholder + 'CUSTOM_API_KEY', 'OPENAI_API_KEY', + // ClawPanel-specific + 'GATEWAY_ALLOW_ALL_USERS', 'API_SERVER_KEY', + ] + }, + + hermes_env_read_unmanaged() { + const envPath = path.join(hermesHome(), '.env') + if (!fs.existsSync(envPath)) return [] + const raw = fs.readFileSync(envPath, 'utf8') + const managed = new Set(this._hermesManagedEnvKeys()) + const seen = new Set() + const out = [] + for (const line of raw.split('\n')) { + const t = line.trim() + if (!t || t.startsWith('#')) continue + const eq = t.indexOf('=') + if (eq < 0) continue + const key = t.slice(0, eq).trim() + if (!key || managed.has(key) || seen.has(key)) continue + seen.add(key) + out.push([key, t.slice(eq + 1)]) + } + return out + }, + + hermes_env_set({ key, value } = {}) { + key = (key || '').trim() + if (!key) throw new Error('Key cannot be empty') + if (!/^[A-Z0-9_]+$/i.test(key)) { + throw new Error(`Invalid env var key '${key}': only [A-Z0-9_] are allowed`) + } + const managed = new Set(this._hermesManagedEnvKeys()) + if (managed.has(key)) { + throw new Error(`'${key}' is managed by ClawPanel; please configure it via the provider setup page`) + } + const envPath = path.join(hermesHome(), '.env') + fs.mkdirSync(path.dirname(envPath), { recursive: true }) + const raw = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '' + const lines = raw.split('\n') + const out = [] + let replaced = false + for (const line of lines) { + const t = line.trim() + if (!t || t.startsWith('#')) { out.push(line); continue } + const eq = t.indexOf('=') + if (eq > 0 && t.slice(0, eq).trim() === key && !replaced) { + out.push(`${key}=${value == null ? '' : value}`) + replaced = true + continue + } + out.push(line) + } + if (!replaced) out.push(`${key}=${value == null ? '' : value}`) + let content = out.join('\n') + if (!content.endsWith('\n')) content += '\n' + fs.writeFileSync(envPath, content) + return null + }, + + hermes_env_delete({ key } = {}) { + key = (key || '').trim() + if (!key) throw new Error('Key cannot be empty') + const managed = new Set(this._hermesManagedEnvKeys()) + if (managed.has(key)) { + throw new Error(`'${key}' is managed by ClawPanel; please configure it via the provider setup page`) + } + const envPath = path.join(hermesHome(), '.env') + if (!fs.existsSync(envPath)) return null + const raw = fs.readFileSync(envPath, 'utf8') + const lines = raw.split('\n') + const out = [] + for (const line of lines) { + const t = line.trim() + if (!t || t.startsWith('#')) { out.push(line); continue } + const eq = t.indexOf('=') + if (eq > 0 && t.slice(0, eq).trim() === key) continue + out.push(line) + } + let content = out.join('\n') + if (!content.endsWith('\n')) content += '\n' + fs.writeFileSync(envPath, content) + return null + }, + async hermes_fetch_models({ baseUrl, apiKey, apiType, provider: _provider } = {}) { const api = apiType || 'openai' let base = baseUrl.replace(/\/+$/, '') diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index ca6b0fc..7495ee4 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3138,3 +3138,191 @@ pub async fn hermes_memory_write( std::fs::write(&file_path, &content).map_err(|e| format!("Failed to write memory: {e}"))?; Ok("ok".into()) } + +// ============================================================================ +// .env editor commands (Step 4 / G6) +// +// Users may need to set custom environment variables for Hermes (e.g. +// `TAVILY_API_KEY` for the tavily skill, `HTTP_PROXY`, etc.). These keys +// live in ~/.hermes/.env alongside the ClawPanel-managed provider keys. +// +// The three commands below: +// * `hermes_env_read_unmanaged` — returns every key in .env that is NOT +// managed by ClawPanel (i.e. not in `hermes_providers::all_managed_env_keys`) +// * `hermes_env_set` — writes or updates an unmanaged key +// * `hermes_env_delete` — removes an unmanaged key +// +// All three refuse to touch `all_managed_env_keys` to prevent users from +// accidentally clobbering provider keys from the editor UI (those should +// be configured via the setup page / configure_hermes). +// ============================================================================ + +/// Lenient .env parser shared by the three commands below. +/// Returns a Vec of (key, value, original_line_index) for every `KEY=VALUE` +/// pair. Comments and blanks are preserved by line index but not returned. +fn parse_env_file_lines(raw: &str) -> Vec<(String, String, usize)> { + let mut out = Vec::new(); + for (i, line) in raw.lines().enumerate() { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') { + continue; + } + if let Some((k, v)) = t.split_once('=') { + let k = k.trim().to_string(); + if k.is_empty() { + continue; + } + out.push((k, v.to_string(), i)); + } + } + out +} + +/// Return every non-managed `KEY=VALUE` pair from ~/.hermes/.env. +/// +/// Output is ordered by the order of appearance in the file. Managed keys +/// (provider API keys, base URLs, `GATEWAY_ALLOW_ALL_USERS`, `API_SERVER_KEY`) +/// are filtered out — those are surfaced separately in the config UI. +#[tauri::command] +pub fn hermes_env_read_unmanaged() -> Result, String> { + use super::hermes_providers; + + let env_path = hermes_home().join(".env"); + if !env_path.exists() { + return Ok(Vec::new()); + } + + let raw = + std::fs::read_to_string(&env_path).map_err(|e| format!("Failed to read .env: {e}"))?; + + let managed = hermes_providers::all_managed_env_keys(); + let mut out: Vec<(String, String)> = Vec::new(); + let mut seen = std::collections::HashSet::::new(); + for (k, v, _) in parse_env_file_lines(&raw) { + if managed.contains(&k.as_str()) { + continue; + } + if seen.insert(k.clone()) { + out.push((k, v)); + } + } + Ok(out) +} + +/// Write or update a single unmanaged env var in ~/.hermes/.env. +/// +/// Refuses to write keys in `hermes_providers::all_managed_env_keys`. +/// Creates the file (and parent dir) if missing. +#[tauri::command] +pub fn hermes_env_set(key: String, value: String) -> Result<(), String> { + use super::hermes_providers; + + let key = key.trim().to_string(); + if key.is_empty() { + return Err("Key cannot be empty".into()); + } + // Basic sanity: env var keys are typically A-Z0-9_ + if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(format!( + "Invalid env var key '{key}': only [A-Z0-9_] are allowed" + )); + } + let managed = hermes_providers::all_managed_env_keys(); + if managed.contains(&key.as_str()) { + return Err(format!( + "'{key}' is managed by ClawPanel; please configure it via the provider setup page" + )); + } + + let env_path = hermes_home().join(".env"); + if let Some(parent) = env_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create .hermes dir: {e}"))?; + } + + let raw = if env_path.exists() { + std::fs::read_to_string(&env_path).map_err(|e| format!("Failed to read .env: {e}"))? + } else { + String::new() + }; + + // Preserve file structure: if the key already exists, update the first + // occurrence and leave the rest (which would be dead code anyway for + // dotenv loaders) alone. Otherwise append a new line. + let lines: Vec<&str> = raw.lines().collect(); + let mut out: Vec = Vec::with_capacity(lines.len() + 1); + let mut replaced = false; + for line in lines.iter() { + let t = line.trim(); + if t.starts_with('#') || t.is_empty() { + out.push(line.to_string()); + continue; + } + if let Some((k, _)) = t.split_once('=') { + if k.trim() == key && !replaced { + out.push(format!("{key}={value}")); + replaced = true; + continue; + } + } + out.push(line.to_string()); + } + if !replaced { + out.push(format!("{key}={value}")); + } + let mut content = out.join("\n"); + if !content.ends_with('\n') { + content.push('\n'); + } + std::fs::write(&env_path, content).map_err(|e| format!("Failed to write .env: {e}"))?; + Ok(()) +} + +/// Remove an unmanaged env var from ~/.hermes/.env. +/// +/// Refuses to delete keys in `hermes_providers::all_managed_env_keys`. +/// No-op if the key doesn't exist. +#[tauri::command] +pub fn hermes_env_delete(key: String) -> Result<(), String> { + use super::hermes_providers; + + let key = key.trim().to_string(); + if key.is_empty() { + return Err("Key cannot be empty".into()); + } + let managed = hermes_providers::all_managed_env_keys(); + if managed.contains(&key.as_str()) { + return Err(format!( + "'{key}' is managed by ClawPanel; please configure it via the provider setup page" + )); + } + + let env_path = hermes_home().join(".env"); + if !env_path.exists() { + return Ok(()); + } + let raw = + std::fs::read_to_string(&env_path).map_err(|e| format!("Failed to read .env: {e}"))?; + + let lines: Vec<&str> = raw.lines().collect(); + let mut out: Vec = Vec::with_capacity(lines.len()); + for line in lines.iter() { + let t = line.trim(); + if t.starts_with('#') || t.is_empty() { + out.push(line.to_string()); + continue; + } + if let Some((k, _)) = t.split_once('=') { + if k.trim() == key { + continue; // drop + } + } + out.push(line.to_string()); + } + let mut content = out.join("\n"); + if !content.ends_with('\n') { + content.push('\n'); + } + std::fs::write(&env_path, content).map_err(|e| format!("Failed to write .env: {e}"))?; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ede1d5b..bf8b30f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -231,6 +231,9 @@ pub fn run() { hermes::hermes_update_model, hermes::hermes_detect_environments, hermes_providers::hermes_list_providers, + hermes::hermes_env_read_unmanaged, + hermes::hermes_env_set, + hermes::hermes_env_delete, hermes::hermes_set_gateway_url, hermes::update_hermes, hermes::uninstall_hermes, diff --git a/src/engines/hermes/index.js b/src/engines/hermes/index.js index bfa9698..da1ab59 100644 --- a/src/engines/hermes/index.js +++ b/src/engines/hermes/index.js @@ -108,6 +108,7 @@ export default { { 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/env', loader: () => import('./pages/env-editor.js') }, // 共用页面(引擎无关) { path: '/assistant', loader: () => import('../../pages/assistant.js') }, { path: '/settings', loader: () => import('../../pages/settings.js') }, diff --git a/src/engines/hermes/pages/dashboard.js b/src/engines/hermes/pages/dashboard.js index 326a625..6a001c8 100644 --- a/src/engines/hermes/pages/dashboard.js +++ b/src/engines/hermes/pages/dashboard.js @@ -228,8 +228,9 @@ export function render() {
${cfgMsg}
-
+
+ .env 高级编辑 →
diff --git a/src/engines/hermes/pages/env-editor.js b/src/engines/hermes/pages/env-editor.js new file mode 100644 index 0000000..6339d7d --- /dev/null +++ b/src/engines/hermes/pages/env-editor.js @@ -0,0 +1,251 @@ +/** + * Hermes ~/.hermes/.env 高级编辑器 + * + * Managed keys (provider API keys, base URLs, GATEWAY_ALLOW_ALL_USERS, + * API_SERVER_KEY) are hidden — those are surfaced on the setup page. + * + * Users can add/edit/delete custom env vars (TAVILY_API_KEY, HTTP_PROXY, + * SKILL_*, etc.) which Hermes will pick up on Gateway restart. + */ +import { api } from '../../../lib/tauri-api.js' +import { toast } from '../../../components/toast.js' + +// NOTE: i18n keys for this page are not yet wired up in src/locales; using +// inline Chinese copy (with occasional English fallback) for now. When the +// translation module lands, replace these literals with `t('hermesEnv.*')`. + +const ICONS = { + back: ``, + trash: ``, + edit: ``, + save: ``, + cancel: ``, + plus: ``, +} + +export function render() { + const el = document.createElement('div') + el.className = 'page' + + let rows = [] // [{ key, value, editing: false, draftValue: '', isNew: false }] + let loading = true + let loadError = null + + el.innerHTML = skeleton() + + function skeleton() { + return ` + +
+
+
+
+ 以下环境变量由 ClawPanel 在 Hermes 配置页面管理:OPENAI_API_KEY / ANTHROPIC_API_KEY / DEEPSEEK_API_KEY 等 provider 密钥和 base URL,以及 GATEWAY_ALLOW_ALL_USERS / API_SERVER_KEY。请通过 Hermes 仪表盘的「模型配置」修改这些项——本页仅用于添加自定义环境变量(如 TAVILY_API_KEYHTTP_PROXY、Skills 所需的自定义变量等)。 +
+
+ + +
+
+
+ ` + } + + function renderList() { + const listEl = el.querySelector('#env-list') + const emptyEl = el.querySelector('#env-empty') + if (!listEl) return + + if (loading) { + listEl.innerHTML = `
加载中…
` + if (emptyEl) emptyEl.style.display = 'none' + return + } + + if (!rows.length) { + listEl.innerHTML = '' + if (emptyEl) { + emptyEl.textContent = '暂无自定义环境变量。点击下方「添加变量」新增一条。' + emptyEl.style.display = 'block' + } + renderFooter() + return + } + + if (emptyEl) emptyEl.style.display = 'none' + + const header = ` +
+
变量名
+
+
操作
+
+ ` + + const body = rows.map((row, idx) => { + if (row.editing) { + return ` +
+ + +
+ + +
+
+ ` + } + return ` +
+ ${esc(row.key)} + ${esc(maskValue(row.value))} +
+ + +
+
+ ` + }).join('') + + listEl.innerHTML = header + body + renderFooter() + bind() + } + + function renderFooter() { + const listEl = el.querySelector('#env-list') + if (!listEl) return + // Append footer after list contents + const hasAddRow = rows.some(r => r.isNew) + const footer = document.createElement('div') + footer.style.cssText = 'margin-top:14px;display:flex;gap:10px' + footer.innerHTML = hasAddRow + ? '' + : `` + // Remove existing footer + const old = el.querySelector('.env-footer') + if (old) old.remove() + footer.className = 'env-footer' + listEl.parentElement.appendChild(footer) + + footer.querySelector('.env-add-btn')?.addEventListener('click', () => { + rows.push({ key: '', value: '', editing: true, draftValue: '', isNew: true }) + renderList() + // Focus the newly created key input + const inputs = el.querySelectorAll('.env-row') + const last = inputs[inputs.length - 1] + last?.querySelector('.env-key-input')?.focus() + }) + } + + function bind() { + el.querySelectorAll('.env-row').forEach((rowEl) => { + const idx = Number(rowEl.dataset.idx) + const row = rows[idx] + if (!row) return + + rowEl.querySelector('.env-edit-btn')?.addEventListener('click', () => { + row.editing = true + row.draftValue = row.value + renderList() + }) + rowEl.querySelector('.env-cancel-btn')?.addEventListener('click', () => { + if (row.isNew) { + rows.splice(idx, 1) + } else { + row.editing = false + row.draftValue = '' + } + renderList() + }) + rowEl.querySelector('.env-save-btn')?.addEventListener('click', async () => { + const keyInput = rowEl.querySelector('.env-key-input') + const valueInput = rowEl.querySelector('.env-value-input') + const newKey = (keyInput?.value || '').trim() + const newValue = valueInput?.value || '' + if (!newKey) { + toast('变量名不能为空', 'warning') + return + } + if (!/^[A-Z0-9_]+$/i.test(newKey)) { + toast('变量名只能包含字母、数字和下划线', 'warning') + return + } + try { + await api.hermesEnvSet(newKey, newValue) + row.key = newKey + row.value = newValue + row.editing = false + row.isNew = false + row.draftValue = '' + toast('已保存', 'success') + renderList() + } catch (err) { + toast(String(err).replace(/^Error:\s*/, ''), 'error') + } + }) + rowEl.querySelector('.env-delete-btn')?.addEventListener('click', async () => { + if (!confirm(`确定删除 ${row.key} 吗?`)) return + try { + await api.hermesEnvDelete(row.key) + rows.splice(idx, 1) + toast('已删除', 'success') + renderList() + } catch (err) { + toast(String(err).replace(/^Error:\s*/, ''), 'error') + } + }) + }) + } + + async function load() { + loading = true + loadError = null + renderList() + try { + const list = await api.hermesEnvReadUnmanaged() + // Rust returns Vec<(String, String)> serialized as [[k, v], ...] + rows = (list || []).map(([k, v]) => ({ + key: k, + value: v, + editing: false, + draftValue: '', + isNew: false, + })) + } catch (err) { + loadError = String(err).replace(/^Error:\s*/, '') + const errEl = el.querySelector('#env-error') + if (errEl) { + errEl.textContent = loadError + errEl.style.display = 'block' + } + rows = [] + } finally { + loading = false + renderList() + } + } + + function esc(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + } + + // Mask long values so sensitive secrets don't leak at a glance. + function maskValue(v) { + const s = String(v ?? '') + if (s.length <= 12) return s + return `${s.slice(0, 4)}…${s.slice(-4)}` + } + + load() + return el +} diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index e204247..0cf1637 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -401,6 +401,9 @@ export const api = { hermesFetchModels: (baseUrl, apiKey, apiType, provider) => invoke('hermes_fetch_models', { baseUrl, apiKey, apiType: apiType || null, provider: provider || null }), hermesUpdateModel: (model, provider) => invoke('hermes_update_model', { model, provider: provider || null }), hermesListProviders: () => cachedInvoke('hermes_list_providers', {}, 600000), + hermesEnvReadUnmanaged: () => invoke('hermes_env_read_unmanaged'), + hermesEnvSet: (key, value) => invoke('hermes_env_set', { key, value }), + hermesEnvDelete: (key) => invoke('hermes_env_delete', { key }), hermesDetectEnvironments: () => invoke('hermes_detect_environments'), hermesSetGatewayUrl: (url) => invoke('hermes_set_gateway_url', { url: url || null }), updateHermes: () => invoke('update_hermes'),