mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
feat(hermes): add .env editor page for unmanaged env vars (Step 4)
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.
This commit is contained in:
@@ -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(/\/+$/, '')
|
||||
|
||||
@@ -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<Vec<(String, String)>, 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::<String>::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<String> = 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<String> = 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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -228,8 +228,9 @@ export function render() {
|
||||
<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">
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button class="btn btn-primary btn-sm hm-save-model" ${modelBusy ? 'disabled' : ''}>${modelBusy ? '...' : t('engine.configSaveBtn')}</button>
|
||||
<a href="#/h/env" style="font-size:11px;color:var(--text-tertiary);text-decoration:none;margin-left:auto" title=".env 文件高级编辑(自定义环境变量)">.env 高级编辑 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
251
src/engines/hermes/pages/env-editor.js
Normal file
251
src/engines/hermes/pages/env-editor.js
Normal file
@@ -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: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="15 18 9 12 15 6"/></svg>`,
|
||||
trash: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>`,
|
||||
edit: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
|
||||
save: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>`,
|
||||
cancel: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
|
||||
plus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="page-header" style="display:flex;align-items:center;gap:12px">
|
||||
<a href="#/h/dashboard" class="btn-text" style="display:inline-flex;align-items:center;gap:4px;font-size:13px">
|
||||
${ICONS.back} 返回仪表盘
|
||||
</a>
|
||||
<h1 style="margin:0;font-size:20px">.env 高级编辑</h1>
|
||||
</div>
|
||||
<div style="max-width:860px">
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-body" style="padding:20px">
|
||||
<div style="padding:10px 14px;background:var(--bg-tertiary);border-radius:var(--radius-sm,6px);font-size:12px;line-height:1.6;color:var(--text-secondary);margin-bottom:16px">
|
||||
以下环境变量由 ClawPanel 在 Hermes 配置页面管理:<code>OPENAI_API_KEY</code> / <code>ANTHROPIC_API_KEY</code> / <code>DEEPSEEK_API_KEY</code> 等 provider 密钥和 base URL,以及 <code>GATEWAY_ALLOW_ALL_USERS</code> / <code>API_SERVER_KEY</code>。请通过 Hermes 仪表盘的「模型配置」修改这些项——本页仅用于添加自定义环境变量(如 <code>TAVILY_API_KEY</code>、<code>HTTP_PROXY</code>、Skills 所需的自定义变量等)。
|
||||
</div>
|
||||
<div id="env-list"></div>
|
||||
<div id="env-empty" style="display:none;padding:18px 14px;text-align:center;color:var(--text-tertiary);font-size:13px"></div>
|
||||
<div id="env-error" style="display:none;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;margin-top:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const listEl = el.querySelector('#env-list')
|
||||
const emptyEl = el.querySelector('#env-empty')
|
||||
if (!listEl) return
|
||||
|
||||
if (loading) {
|
||||
listEl.innerHTML = `<div style="padding:18px 14px;text-align:center;color:var(--text-tertiary);font-size:13px">加载中…</div>`
|
||||
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 = `
|
||||
<div style="display:grid;grid-template-columns:1fr 2fr 88px;gap:10px;padding:6px 4px;font-size:11px;color:var(--text-tertiary);font-weight:500">
|
||||
<div>变量名</div>
|
||||
<div>值</div>
|
||||
<div style="text-align:right">操作</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const body = rows.map((row, idx) => {
|
||||
if (row.editing) {
|
||||
return `
|
||||
<div class="env-row" data-idx="${idx}" style="display:grid;grid-template-columns:1fr 2fr 88px;gap:10px;align-items:center;padding:6px 4px;border-top:1px solid var(--border-primary)">
|
||||
<input type="text" class="input env-key-input" ${row.isNew ? '' : 'readonly'} value="${esc(row.key)}" placeholder="EXAMPLE_KEY" style="font-family:var(--font-mono, ui-monospace);font-size:12px;padding:4px 8px">
|
||||
<input type="text" class="input env-value-input" value="${esc(row.draftValue)}" placeholder="..." style="font-size:12px;padding:4px 8px">
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn btn-sm btn-primary env-save-btn" title="保存">${ICONS.save}</button>
|
||||
<button class="btn btn-sm btn-secondary env-cancel-btn" title="取消">${ICONS.cancel}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
return `
|
||||
<div class="env-row" data-idx="${idx}" style="display:grid;grid-template-columns:1fr 2fr 88px;gap:10px;align-items:center;padding:6px 4px;border-top:1px solid var(--border-primary)">
|
||||
<code style="font-size:12px;color:var(--text-primary);word-break:break-all">${esc(row.key)}</code>
|
||||
<code style="font-size:12px;color:var(--text-secondary);word-break:break-all;opacity:0.8">${esc(maskValue(row.value))}</code>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn btn-sm btn-secondary env-edit-btn" title="编辑">${ICONS.edit}</button>
|
||||
<button class="btn btn-sm btn-secondary env-delete-btn" title="删除" style="color:var(--error)">${ICONS.trash}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}).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
|
||||
? ''
|
||||
: `<button class="btn btn-primary env-add-btn" style="display:inline-flex;align-items:center;gap:6px">${ICONS.plus} 添加变量</button>`
|
||||
// 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, '>')
|
||||
.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
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user