diff --git a/.memory/worklog.json b/.memory/worklog.json index 135fec6..4541b37 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,12 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "a4b2ec6", - "message": "auto-save 2026-05-10 10:13 (~1)", - "ts": "2026-05-10T10:13:19+08:00", - "type": "commit" - }, { "files_changed": 1, "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-10 10:13 (~1)", @@ -3271,6 +3264,13 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-11 19:04 (~1)", "files_changed": 1 + }, + { + "ts": "2026-05-11T19:10:01+08:00", + "type": "commit", + "message": "auto-save 2026-05-11 19:09 (~1)", + "hash": "77d800d", + "files_changed": 1 } ] } diff --git a/RULES.md b/RULES.md index 1640e10..1d7cd0e 100644 --- a/RULES.md +++ b/RULES.md @@ -21,6 +21,7 @@ - 爱马仕前端「设置 → 连接」可自助维护 API 地址 / API Key 并测试连接;「对话 → 存周报」和「设置 → 周报记录」会在本地保存任务描述、上下文片段和最终周报 - 爱马仕前端「模型」可维护 AI 模型 Profiles,用于给不同 Agent 绑定不同模型、Provider、Base URL 和服务器端 Key 引用 - 爱马仕前端「提供商」可维护 LXC 内 `/opt/hermes-agent/config.yaml` 的 `model` 块,保存后重启 Docker `hermes-agent` +- 爱马仕前端「提供商 → Provider API Keys」可写入 / 清除服务器环境变量里的 AI Provider Key;真实 Key 不回显、不入库 - 爱马仕前端「工具 → MCP 工具接入」可维护 LXC 内 `/opt/hermes-agent/config.yaml` 的 `mcp_servers` 块,保存后重启 Docker `hermes-agent` - 飞书、模型、MCP、共享 Agent/Profile 等 `/feishu/*` 管理接口复用网页登录 cookie,并在前端 401 时走 `/_auth/verify` 静默续期 - 当前前端不再启用 Service Worker 静态壳缓存;`sw.js` 仅用于清理旧 `hermes-ui-*` 缓存并注销旧注册 @@ -68,5 +69,6 @@ - 飞书后台配置所需回调 URL、verification token、notify token 备份:`/root/hermes-feishu-bridge.tokens`,mode 600 - 飞书自助配置接口 `POST /feishu/apps`、`DELETE /feishu/apps/{app_id}` 要求已登录爱马仕 cookie 且同源请求;未登录公网请求返回 401;后台 Origin 白名单默认包含当前请求 Host,可用 `HERMES_ADMIN_ORIGINS` 扩展 - Hermes 运行配置接口 `/feishu/hermes-config` 复用飞书桥接反代,要求已登录爱马仕 cookie 且同源请求;会通过 Incus 写入 LXC 配置并重启 `hermes-agent` +- Provider Key 管理接口 `/feishu/provider-keys` 复用飞书桥接反代,要求已登录爱马仕 cookie 且同源请求;只允许管理形如 `*_API_KEY` 的 Provider 环境变量,禁止管理飞书和部署控制变量 - 当前飞书桥接版本按明文事件回调处理;如果飞书后台开启事件加密,需要先补充解密支持 - 主站有 cookie 门禁;nginx 已对 `/feishu/` 单独放行并反代到飞书桥接服务 diff --git a/server/feishu_bridge.py b/server/feishu_bridge.py index 84f044e..4d32702 100644 --- a/server/feishu_bridge.py +++ b/server/feishu_bridge.py @@ -228,6 +228,9 @@ def reload_config_from_env_file() -> None: Config.feishu_app_secret = _env("FEISHU_APP_SECRET") Config.feishu_apps = _load_feishu_apps() Config.default_feishu_app_id = _env("FEISHU_DEFAULT_APP_ID", Config.feishu_app_id) + Config.hermes_api_base = _env("HERMES_API_BASE", Config.hermes_api_base).rstrip("/") + Config.hermes_api_key = _env("HERMES_API_KEY") + Config.hermes_model = _env("HERMES_MODEL", Config.hermes_model) def sync_tokens_file() -> None: @@ -1140,6 +1143,125 @@ def is_admin_request(headers: dict[str, str]) -> tuple[bool, int, str]: return True, 200, "ok" +COMMON_PROVIDER_KEYS = [ + "HERMES_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GOOGLE_API_KEY", + "GEMINI_API_KEY", + "DEEPSEEK_API_KEY", + "XAI_API_KEY", + "GROQ_API_KEY", + "DASHSCOPE_API_KEY", +] + + +def provider_env_key(raw: Any) -> str: + key = str(raw or "").strip() + if key.startswith("env:"): + key = key[4:].strip() + if not re.match(r"^[A-Z][A-Z0-9_]{1,120}$", key): + return "" + return key + + +def validate_provider_env_key(raw: Any) -> str: + key = provider_env_key(raw) + if not key: + raise ValueError("env key must look like OPENROUTER_API_KEY") + blocked_prefixes = ("FEISHU_", "HERMES_ADMIN_", "FEISHU_BRIDGE_") + blocked_keys = {"PORT", "PATH", "HOME", "SHELL", "PWD"} + if key in blocked_keys or key.startswith(blocked_prefixes): + raise ValueError("this env key is managed by deployment, not by provider settings") + if not key.endswith("_API_KEY"): + raise ValueError("provider env key must end with _API_KEY") + return key + + +def provider_key_profiles() -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + try: + config = read_ui_config_file() + except Exception: + logging.warning("provider key profile map unavailable:\n%s", traceback.format_exc()) + return result + profiles = config.get("modelProfiles") if isinstance(config.get("modelProfiles"), list) else [] + for profile in profiles: + if not isinstance(profile, dict): + continue + key = provider_env_key(profile.get("apiKeyRef")) + if not key: + continue + name = str(profile.get("name") or profile.get("model") or "Profile")[:80] + result.setdefault(key, []).append(name) + return result + + +def handle_provider_keys_get(headers: dict[str, str]) -> tuple[int, dict[str, Any]]: + ok, status, message = is_admin_request(headers) + if not ok: + return status, {"code": status, "msg": message} + try: + env = parse_env_file(Config.env_file) + profile_map = provider_key_profiles() + keys = set(COMMON_PROVIDER_KEYS) | set(profile_map.keys()) + payload = [] + for key in sorted(keys): + present = bool(os.environ.get(key) or env.get(key)) + payload.append({ + "key": key, + "present": present, + "profiles": profile_map.get(key, []), + }) + return 200, {"code": 0, "msg": "ok", "keys": payload} + except Exception as exc: + logging.error("failed to read provider keys:\n%s", traceback.format_exc()) + return 500, {"code": 500, "msg": str(exc)} + + +def handle_provider_keys_post(headers: dict[str, str], body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + ok, status, message = is_admin_request(headers) + if not ok: + return status, {"code": status, "msg": message} + try: + key = validate_provider_env_key(body.get("key")) + value = str(body.get("value") or body.get("api_key") or "").strip() + if not value: + raise ValueError("api key value is required") + if "\n" in value or "\r" in value: + raise ValueError("api key value must be a single line") + if len(value) > 10000: + raise ValueError("api key value is too large") + write_env_updates(Config.env_file, {key: value}) + reload_config_from_env_file() + logging.info("updated provider env key %s", key) + return 200, {"code": 0, "msg": "ok", "key": {"key": key, "present": True}} + except ValueError as exc: + return 400, {"code": 400, "msg": str(exc)} + except Exception as exc: + logging.error("failed to write provider key:\n%s", traceback.format_exc()) + return 500, {"code": 500, "msg": str(exc)} + + +def handle_provider_keys_delete(path: str, headers: dict[str, str]) -> tuple[int, dict[str, Any]]: + ok, status, message = is_admin_request(headers) + if not ok: + return status, {"code": status, "msg": message} + try: + raw_key = urllib.parse.unquote(path[len("/feishu/provider-keys/") :].strip("/")) + key = validate_provider_env_key(raw_key) + write_env_removals(Config.env_file, {key}) + reload_config_from_env_file() + logging.info("removed provider env key %s", key) + return 200, {"code": 0, "msg": "ok", "key": {"key": key, "present": False}} + except ValueError as exc: + return 400, {"code": 400, "msg": str(exc)} + except Exception as exc: + logging.error("failed to remove provider key:\n%s", traceback.format_exc()) + return 500, {"code": 500, "msg": str(exc)} + + def validate_feishu_credentials(app_id: str, app_secret: str) -> None: data = http_json( "POST", @@ -1348,6 +1470,10 @@ class Handler(BaseHTTPRequestHandler): status, payload = handle_ui_config_get(headers) self.send_json(status, payload) return + if self.path == "/feishu/provider-keys": + status, payload = handle_provider_keys_get(headers) + self.send_json(status, payload) + return self.send_json(404, {"code": 404, "msg": "not found"}) def do_POST(self) -> None: @@ -1362,6 +1488,8 @@ class Handler(BaseHTTPRequestHandler): status, payload = handle_hermes_config_post(headers, body) elif self.path == "/feishu/ui-config": status, payload = handle_ui_config_post(headers, body) + elif self.path == "/feishu/provider-keys": + status, payload = handle_provider_keys_post(headers, body) elif self.path == "/feishu/chat/completions": ok, status, message = is_admin_request(headers) if not ok: @@ -1383,6 +1511,8 @@ class Handler(BaseHTTPRequestHandler): headers = {key.lower(): value for key, value in self.headers.items()} if self.path.startswith("/feishu/apps/"): status, payload = handle_apps_delete(self.path, headers) + elif self.path.startswith("/feishu/provider-keys/"): + status, payload = handle_provider_keys_delete(self.path, headers) else: status, payload = 404, {"code": 404, "msg": "not found"} except Exception as exc: diff --git a/src/app.js b/src/app.js index 6dd5a71..b987d77 100644 --- a/src/app.js +++ b/src/app.js @@ -13,6 +13,7 @@ const LS_TAB = "hermes-ui-active-tab-v1"; const LS_WEEKLY_REPORTS = "hermes-ui-weekly-reports-v1"; const LS_MODEL_PROFILES = "hermes-ui-model-profiles-v1"; const UI_CONFIG_ENDPOINT = "/feishu/ui-config"; +const PROVIDER_KEYS_ENDPOINT = "/feishu/provider-keys"; const DEFAULT_MODEL_ID = "google/gemini-3.1-pro-preview"; const LEGACY_DEFAULT_MODEL_ID = "gemini-3-pro-preview"; @@ -666,6 +667,7 @@ function switchTab(name, options = {}) { } if (name === "providers") { refreshHermesConfig(); + refreshProviderKeys(); } if (name === "tools") { refreshTools(); @@ -894,6 +896,8 @@ async function testApiConnection() { let _hermesConfigLoaded = false; let _hermesConfigLoading = false; let _hermesConfigSnapshot = null; +let _providerKeysLoaded = false; +let _providerKeysLoading = false; function setSettingsStatus(id, text, isError = false) { const el = document.getElementById(id); if (!el) return; @@ -910,6 +914,141 @@ function setHermesConfigStatuses(text, isError = false) { setHermesModelStatus(text, isError); setHermesMcpStatus(text, isError); } +function setProviderKeyStatus(text, isError = false) { + setSettingsStatus("providerKeyStatus", text, isError); +} + +function providerKeyBadge(present) { + return present + ? '已配置' + : '未配置'; +} + +function renderProviderKeys(keys) { + const box = document.getElementById("providerKeysList"); + if (!box) return; + if (!Array.isArray(keys) || !keys.length) { + box.innerHTML = '
还没有发现可管理的 Provider Key。
'; + return; + } + box.innerHTML = keys.map(item => { + const key = escapeHTML(item.key || ""); + const profiles = Array.isArray(item.profiles) ? item.profiles.filter(Boolean) : []; + const profileText = profiles.length ? profiles.map(escapeHTML).join(" · ") : "尚未被模型 Profile 引用"; + const cls = item.present ? "model-profile-card active" : "model-profile-card"; + const removeBtn = item.present + ? `` + : ""; + return ` +
+
+
+ ${key} + ${providerKeyBadge(!!item.present)} +
+
${profileText}
+
+
+ + ${removeBtn} +
+
+ `; + }).join(""); +} + +async function refreshProviderKeys(force = false) { + if (_providerKeysLoading || (_providerKeysLoaded && !force)) return; + const box = document.getElementById("providerKeysList"); + if (!box) return; + _providerKeysLoading = true; + setProviderKeyStatus("正在读取 Provider Key 状态..."); + try { + const res = await apiFetch(PROVIDER_KEYS_ENDPOINT, { + credentials: "same-origin", + cache: "no-store", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.code !== 0) throw new Error(data.msg || ("HTTP " + res.status)); + renderProviderKeys(data.keys || []); + _providerKeysLoaded = true; + setProviderKeyStatus("已读取 Key 状态;真实值不会回显。"); + } catch (e) { + setProviderKeyStatus("读取失败: " + (e.message || e), true); + } finally { + _providerKeysLoading = false; + } +} + +function fillProviderKeyName(key) { + const el = document.getElementById("providerKeyName"); + if (el) { + el.value = key || ""; + el.focus(); + } +} + +async function saveProviderKey() { + const key = document.getElementById("providerKeyName")?.value.trim() || ""; + const valueEl = document.getElementById("providerKeyValue"); + const value = valueEl?.value.trim() || ""; + if (!/^[A-Z][A-Z0-9_]{1,120}$/.test(key) || !key.endsWith("_API_KEY")) { + toast("环境变量名需类似 OPENROUTER_API_KEY"); + return; + } + if (!value) { + toast("请填写 API Key"); + return; + } + const btn = document.getElementById("providerKeySaveBtn"); + const oldHTML = btn?.innerHTML; + if (btn) { + btn.disabled = true; + btn.textContent = "保存 Key 中..."; + } + setProviderKeyStatus("正在写入服务器环境变量..."); + try { + const res = await apiFetch(PROVIDER_KEYS_ENDPOINT, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key, value }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.code !== 0) throw new Error(data.msg || ("HTTP " + res.status)); + if (valueEl) valueEl.value = ""; + _providerKeysLoaded = false; + await refreshProviderKeys(true); + toast("Provider Key 已写入服务器"); + } catch (e) { + setProviderKeyStatus("保存失败: " + (e.message || e), true); + toast("保存失败: " + (e.message || e)); + } finally { + if (btn) { + btn.disabled = false; + btn.innerHTML = oldHTML; + } + } +} + +async function deleteProviderKey(key) { + if (!key || !confirm("清除服务器环境变量 " + key + "? 已绑定该 Key 的模型 Profile 会停止可用。")) return; + setProviderKeyStatus("正在清除 " + key + "..."); + try { + const res = await apiFetch(PROVIDER_KEYS_ENDPOINT + "/" + encodeURIComponent(key), { + method: "DELETE", + credentials: "same-origin", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.code !== 0) throw new Error(data.msg || ("HTTP " + res.status)); + _providerKeysLoaded = false; + await refreshProviderKeys(true); + toast("Provider Key 已清除"); + } catch (e) { + setProviderKeyStatus("清除失败: " + (e.message || e), true); + toast("清除失败: " + (e.message || e)); + } +} async function refreshHermesConfig(force = false) { if (_hermesConfigLoading || (_hermesConfigLoaded && !force)) return; @@ -1061,6 +1200,7 @@ async function saveSharedConfig(options = {}) { saveModelProfilesToLS(); state.sharedConfigLoaded = true; state.sharedConfigAvailable = true; + _providerKeysLoaded = false; syncModelOptionsFromProfiles(); renderModelProfiles(); renderAgentModelProfileOptions(document.getElementById("agentModelProfile")?.value || ""); diff --git a/src/index.html b/src/index.html index ce4a28e..f30ad56 100644 --- a/src/index.html +++ b/src/index.html @@ -11,7 +11,7 @@ 爱马仕 · AI - + @@ -736,6 +736,48 @@ + +
+
+
+ +
+
+
Provider API Keys
+
只写入服务器环境变量;前端只保存变量名,不回显真实 Key
+
+
+
+
+
+
+
服务器 Key 状态
+
模型 Profile 的 API Key 引用会匹配这些环境变量。
+
+ +
+
+
打开提供商页后自动读取。
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
真实 Key 不会保存到浏览器或仓库。
+
+
+
+
@@ -954,7 +996,7 @@ 档案前端 CRUD + system prompt 组装localStorage (hermes-ui-agents-v1) 工作区GET /v1/models + localStorage 聚合实时 + 本地 模型GET/POST /feishu/ui-config服务器共享模型 Profiles - 提供商GET/PUT /feishu/hermes-configmodel 块Hermes 运行模型配置 + 提供商GET/POST /feishu/hermes-config + /feishu/provider-keys运行模型配置 + Provider Key 状态 技能GET /hermes-skills/ autoindex JSONdocker cp 快照 (78 个真实 SKILL.md) 人格GET /memory/SOUL.mdsystemd timer sync 记忆GET /memory/sessions.json 入口聚合systemd timer sync @@ -1545,6 +1587,6 @@ git push # Gitea kangwan/hermes-glass-ui-personal - +