auto-save 2026-05-11 14:50 (~4)
This commit is contained in:
@@ -1,18 +1,5 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-10 06:40 (~1)",
|
||||
"ts": "2026-05-09T22:45:54Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "46ef663",
|
||||
"message": "auto-save 2026-05-10 06:46 (~1)",
|
||||
"ts": "2026-05-10T06:46:49+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-10 06:46 (~1)",
|
||||
@@ -3256,6 +3243,19 @@
|
||||
"message": "auto-save 2026-05-11 14:39 (~6)",
|
||||
"hash": "5247dfd",
|
||||
"files_changed": 6
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-11T14:45:05+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-11 14:45 (~1)",
|
||||
"hash": "854152b",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-11T06:46:27Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-11 14:45 (~1)",
|
||||
"files_changed": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import logging
|
||||
import os
|
||||
import hashlib
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
@@ -81,6 +83,9 @@ class Config:
|
||||
hermes_api_base = _env("HERMES_API_BASE", "http://127.0.0.1:8642/v1").rstrip("/")
|
||||
hermes_api_key = _env("HERMES_API_KEY")
|
||||
hermes_model = _env("HERMES_MODEL", "gemini-3-pro-preview")
|
||||
hermes_agent_lxc = _env("HERMES_AGENT_LXC", "hermes-personal")
|
||||
hermes_agent_dir = _env("HERMES_AGENT_DIR", "/opt/hermes-agent")
|
||||
incus_bin = _env("INCUS_BIN", "/usr/bin/incus")
|
||||
hermes_system_prompt = _env(
|
||||
"HERMES_SYSTEM_PROMPT",
|
||||
"你是爱马仕 Hermes。你通过飞书与用户对话,回答要直接、简洁、可执行。",
|
||||
@@ -507,6 +512,251 @@ def handle_apps() -> tuple[int, dict[str, Any]]:
|
||||
}
|
||||
|
||||
|
||||
def run_lxc_command(args: list[str], input_text: str = "", timeout: float = 20) -> str:
|
||||
cmd = [Config.incus_bin, "exec", Config.hermes_agent_lxc, "--", *args]
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
input=input_text,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout or "").strip()
|
||||
raise RuntimeError(detail or f"command failed with exit code {proc.returncode}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def read_hermes_runtime_config() -> dict[str, Any]:
|
||||
script = r'''
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
text = path.read_text(encoding="utf-8")
|
||||
lines = text.splitlines()
|
||||
|
||||
def clean_value(value):
|
||||
value = value.strip()
|
||||
if " #" in value:
|
||||
value = value.split(" #", 1)[0].rstrip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
def read_mapping_block(key):
|
||||
result = {}
|
||||
in_block = False
|
||||
for line in lines:
|
||||
if re.match(rf"^{re.escape(key)}\s*:\s*$", line):
|
||||
in_block = True
|
||||
continue
|
||||
if in_block:
|
||||
if line and not line[0].isspace():
|
||||
break
|
||||
match = re.match(r"\s+([A-Za-z0-9_-]+)\s*:\s*(.*?)\s*$", line)
|
||||
if match:
|
||||
result[match.group(1)] = clean_value(match.group(2))
|
||||
return result
|
||||
|
||||
def read_raw_block(key):
|
||||
start = None
|
||||
for idx, line in enumerate(lines):
|
||||
if re.match(rf"^{re.escape(key)}\s*:\s*(?:#.*)?$", line):
|
||||
start = idx
|
||||
break
|
||||
if start is None:
|
||||
return ""
|
||||
end = len(lines)
|
||||
for idx in range(start + 1, len(lines)):
|
||||
line = lines[idx]
|
||||
if line and not line[0].isspace() and not line.lstrip().startswith("#"):
|
||||
end = idx
|
||||
break
|
||||
return "\n".join(lines[start:end]).strip()
|
||||
|
||||
payload = {
|
||||
"model": read_mapping_block("model"),
|
||||
"mcp_servers_yaml": read_raw_block("mcp_servers"),
|
||||
"config_path": str(path),
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
'''
|
||||
path = f"{Config.hermes_agent_dir}/config.yaml"
|
||||
raw = run_lxc_command(["python3", "-c", script, path], timeout=20)
|
||||
data = json.loads(raw)
|
||||
model = data.get("model") if isinstance(data.get("model"), dict) else {}
|
||||
return {
|
||||
"model": {
|
||||
"default": str(model.get("default") or model.get("model") or ""),
|
||||
"provider": str(model.get("provider") or ""),
|
||||
"base_url": str(model.get("base_url") or ""),
|
||||
},
|
||||
"mcp_servers_yaml": str(data.get("mcp_servers_yaml") or ""),
|
||||
"config_path": data.get("config_path") or path,
|
||||
"lxc": Config.hermes_agent_lxc,
|
||||
}
|
||||
|
||||
|
||||
def normalize_mcp_yaml(value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return ""
|
||||
first = next((line.strip() for line in value.splitlines() if line.strip()), "")
|
||||
if re.match(r"^mcp_servers\s*:", first):
|
||||
return value
|
||||
indented = "\n".join((" " + line if line.strip() else line) for line in value.splitlines())
|
||||
return "mcp_servers:\n" + indented
|
||||
|
||||
|
||||
def validate_hermes_config_payload(body: dict[str, Any]) -> dict[str, str]:
|
||||
model = body.get("model") if isinstance(body.get("model"), dict) else {}
|
||||
default_model = str(model.get("default") or body.get("model_default") or "").strip()
|
||||
provider = str(model.get("provider") or body.get("provider") or "openrouter").strip()
|
||||
base_url = str(model.get("base_url") or body.get("base_url") or "").strip()
|
||||
mcp_servers_yaml = normalize_mcp_yaml(str(body.get("mcp_servers_yaml") or ""))
|
||||
|
||||
for label, value, limit in (
|
||||
("model.default", default_model, 180),
|
||||
("model.provider", provider, 80),
|
||||
("model.base_url", base_url, 220),
|
||||
):
|
||||
if "\n" in value or "\r" in value:
|
||||
raise ValueError(f"{label} must be a single line")
|
||||
if len(value) > limit:
|
||||
raise ValueError(f"{label} is too long")
|
||||
if not default_model:
|
||||
raise ValueError("model.default is required")
|
||||
if base_url and not re.match(r"^https?://", base_url):
|
||||
raise ValueError("model.base_url must start with http:// or https://")
|
||||
if len(mcp_servers_yaml) > 20000:
|
||||
raise ValueError("mcp_servers_yaml is too large")
|
||||
if mcp_servers_yaml and not re.match(r"^mcp_servers\s*:", mcp_servers_yaml.splitlines()[0].strip()):
|
||||
raise ValueError("mcp_servers_yaml must start with mcp_servers:")
|
||||
|
||||
return {
|
||||
"default_model": default_model,
|
||||
"provider": provider,
|
||||
"base_url": base_url,
|
||||
"mcp_servers_yaml": mcp_servers_yaml,
|
||||
}
|
||||
|
||||
|
||||
def write_hermes_runtime_config(body: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = validate_hermes_config_payload(body)
|
||||
script = r'''
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
payload = json.loads(sys.stdin.read())
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
def quote(value):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
def find_block(lines, key):
|
||||
start = None
|
||||
for idx, line in enumerate(lines):
|
||||
if re.match(rf"^{re.escape(key)}\s*:\s*(?:#.*)?$", line):
|
||||
start = idx
|
||||
break
|
||||
if start is None:
|
||||
return None, None
|
||||
end = len(lines)
|
||||
for idx in range(start + 1, len(lines)):
|
||||
line = lines[idx]
|
||||
if line and not line[0].isspace() and not line.lstrip().startswith("#"):
|
||||
end = idx
|
||||
break
|
||||
return start, end
|
||||
|
||||
def replace_block(text, key, block):
|
||||
lines = text.splitlines()
|
||||
start, end = find_block(lines, key)
|
||||
block_lines = block.rstrip().splitlines() if block.strip() else []
|
||||
if start is None:
|
||||
if not block_lines:
|
||||
return text.rstrip() + "\n"
|
||||
return text.rstrip() + "\n\n" + "\n".join(block_lines) + "\n"
|
||||
if block_lines:
|
||||
new_lines = lines[:start] + block_lines + lines[end:]
|
||||
else:
|
||||
new_lines = lines[:start] + lines[end:]
|
||||
return "\n".join(new_lines).rstrip() + "\n"
|
||||
|
||||
model_block = "\n".join([
|
||||
"model:",
|
||||
f" default: {quote(payload['default_model'])}",
|
||||
f" provider: {quote(payload['provider'])}",
|
||||
f" base_url: {quote(payload['base_url'])}",
|
||||
])
|
||||
|
||||
text = replace_block(text, "model", model_block)
|
||||
text = replace_block(text, "mcp_servers", payload.get("mcp_servers_yaml", ""))
|
||||
backup = path.with_name(path.name + ".bak-" + time.strftime("%Y%m%d%H%M%S"))
|
||||
shutil.copy2(path, backup)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
print(json.dumps({"backup": str(backup)}, ensure_ascii=False))
|
||||
'''
|
||||
path = f"{Config.hermes_agent_dir}/config.yaml"
|
||||
raw = run_lxc_command(
|
||||
["python3", "-c", script, path],
|
||||
input_text=json.dumps(payload, ensure_ascii=False),
|
||||
timeout=20,
|
||||
)
|
||||
result = json.loads(raw)
|
||||
if body.get("restart", True):
|
||||
run_lxc_command(
|
||||
["bash", "-lc", f"cd {shlex.quote(Config.hermes_agent_dir)} && docker compose restart hermes-agent"],
|
||||
timeout=90,
|
||||
)
|
||||
return {
|
||||
"model": {
|
||||
"default": payload["default_model"],
|
||||
"provider": payload["provider"],
|
||||
"base_url": payload["base_url"],
|
||||
},
|
||||
"mcp_servers_yaml": payload["mcp_servers_yaml"],
|
||||
"backup": result.get("backup"),
|
||||
"restarted": bool(body.get("restart", True)),
|
||||
}
|
||||
|
||||
|
||||
def handle_hermes_config_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:
|
||||
config = read_hermes_runtime_config()
|
||||
except Exception as exc:
|
||||
logging.error("failed to read Hermes config:\n%s", traceback.format_exc())
|
||||
return 500, {"code": 500, "msg": str(exc)}
|
||||
return 200, {"code": 0, "msg": "ok", "config": config}
|
||||
|
||||
|
||||
def handle_hermes_config_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:
|
||||
config = write_hermes_runtime_config(body)
|
||||
except ValueError as exc:
|
||||
return 400, {"code": 400, "msg": str(exc)}
|
||||
except Exception as exc:
|
||||
logging.error("failed to write Hermes config:\n%s", traceback.format_exc())
|
||||
return 500, {"code": 500, "msg": str(exc)}
|
||||
logging.info("updated Hermes runtime config model=%s", config["model"]["default"])
|
||||
return 200, {"code": 0, "msg": "ok", "config": config}
|
||||
|
||||
|
||||
def is_admin_request(headers: dict[str, str]) -> tuple[bool, int, str]:
|
||||
cookie = headers.get("cookie", "")
|
||||
if "hermes_auth=ok" not in cookie:
|
||||
@@ -681,6 +931,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "HermesFeishuBridge/1.0"
|
||||
|
||||
def do_GET(self) -> None:
|
||||
headers = {key.lower(): value for key, value in self.headers.items()}
|
||||
if self.path == "/health":
|
||||
self.send_json(200, {"ok": True, "service": "feishu-bridge"})
|
||||
return
|
||||
@@ -688,6 +939,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
status, payload = handle_apps()
|
||||
self.send_json(status, payload)
|
||||
return
|
||||
if self.path == "/feishu/hermes-config":
|
||||
status, payload = handle_hermes_config_get(headers)
|
||||
self.send_json(status, payload)
|
||||
return
|
||||
self.send_json(404, {"code": 404, "msg": "not found"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
@@ -698,6 +953,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
status, payload = handle_feishu_event(self.path, body)
|
||||
elif self.path == "/feishu/apps":
|
||||
status, payload = handle_apps_admin(headers, body)
|
||||
elif self.path == "/feishu/hermes-config":
|
||||
status, payload = handle_hermes_config_post(headers, body)
|
||||
elif self.path == "/feishu/notify" or self.path.startswith("/feishu/notify/"):
|
||||
status, payload = handle_notify(self.path, headers, body)
|
||||
else:
|
||||
|
||||
24
src/app.js
24
src/app.js
@@ -108,20 +108,38 @@ function loadSettings() {
|
||||
if (sm) sm.checked = state.stream;
|
||||
}
|
||||
} catch (e) {}
|
||||
syncModelPick(state.model);
|
||||
}
|
||||
function saveSettings(options = {}) {
|
||||
state.apiBase = document.getElementById("apiBase").value.trim();
|
||||
state.apiKey = document.getElementById("apiKey").value.trim();
|
||||
state.stream = document.getElementById("streamMode").checked;
|
||||
state.model = document.getElementById("modelPick")?.value || state.model;
|
||||
localStorage.setItem(LS_SETTINGS, JSON.stringify({
|
||||
apiBase: state.apiBase,
|
||||
apiKey: state.apiKey,
|
||||
stream: state.stream,
|
||||
model: state.model,
|
||||
}));
|
||||
if (!options.silent) toast("设置已保存");
|
||||
pingBackend();
|
||||
}
|
||||
|
||||
function syncModelPick(modelValue) {
|
||||
const model = (modelValue || state.model || "").trim();
|
||||
const pick = document.getElementById("modelPick");
|
||||
if (!pick || !model) return;
|
||||
let option = Array.from(pick.options).find(item => item.value === model);
|
||||
if (!option) {
|
||||
option = new Option(model, model);
|
||||
pick.appendChild(option);
|
||||
}
|
||||
pick.value = model;
|
||||
state.model = model;
|
||||
const stat = document.getElementById("statModel");
|
||||
if (stat) stat.textContent = option.textContent || model;
|
||||
}
|
||||
|
||||
// ---------- 会话持久化 ----------
|
||||
function loadConversations() {
|
||||
try {
|
||||
@@ -359,7 +377,10 @@ function switchTab(name, options = {}) {
|
||||
if (name === "memory") refreshMemory();
|
||||
if (name === "tools") refreshTools();
|
||||
if (name === "integrations") refreshFeishuApps();
|
||||
if (name === "settings") renderWeeklyReports();
|
||||
if (name === "settings") {
|
||||
renderWeeklyReports();
|
||||
refreshHermesConfig();
|
||||
}
|
||||
if (name === "runs") setTimeout(() => document.getElementById("runPrompt")?.focus(), 50);
|
||||
if (name === "dashboard" && _dashboardDirty) {
|
||||
// 推迟到下一帧,避免阻塞切换动画
|
||||
@@ -616,6 +637,7 @@ function bindChat() {
|
||||
document.getElementById("modelPick").addEventListener("change", (e) => {
|
||||
state.model = e.target.value;
|
||||
document.getElementById("statModel").textContent = e.target.options[e.target.selectedIndex].text;
|
||||
saveSettings({ silent: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1189,6 +1189,51 @@ git push # Gitea kangwan/hermes-glass-ui-personal
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型与 MCP -->
|
||||
<div class="settings-group wide">
|
||||
<div class="settings-group-head">
|
||||
<div class="settings-group-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20"/><path d="M2 12h20"/><path d="M4.93 4.93 19.07 19.07"/><path d="M19.07 4.93 4.93 19.07"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="settings-group-title">模型与 MCP</div>
|
||||
<div class="settings-group-desc">线上 Hermes agent 的默认模型和 MCP server 配置</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group-body">
|
||||
<div class="settings-grid-3">
|
||||
<div class="settings-field">
|
||||
<label for="hermesModelDefault">默认模型</label>
|
||||
<input type="text" id="hermesModelDefault" placeholder="google/gemini-3.1-pro-preview" autocomplete="off">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="hermesModelProvider">Provider</label>
|
||||
<input type="text" id="hermesModelProvider" placeholder="openrouter" autocomplete="off">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="hermesModelBaseUrl">Base URL</label>
|
||||
<input type="text" id="hermesModelBaseUrl" placeholder="https://openrouter.ai/api/v1" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="mcpServersYaml">MCP Servers YAML</label>
|
||||
<textarea id="mcpServersYaml" rows="8" spellcheck="false" placeholder='mcp_servers:
|
||||
time:
|
||||
command: uvx
|
||||
args: ["mcp-server-time"]'></textarea>
|
||||
<div class="settings-help">留空会移除 <code>mcp_servers</code>;保存会备份配置并重启 <code>hermes-agent</code>。</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button class="glass-btn-sm" onclick="refreshHermesConfig(true)">读取线上配置</button>
|
||||
<button class="glass-btn-sm primary" id="hermesConfigSaveBtn" onclick="saveHermesConfig()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
|
||||
保存并重启
|
||||
</button>
|
||||
<div class="settings-help" id="hermesConfigStatus">打开设置页后自动读取。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话偏好 -->
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-head">
|
||||
|
||||
Reference in New Issue
Block a user