feat: 添加 Hermite 同步服务与导航优化

This commit is contained in:
caoxiaozhu
2026-05-09 09:14:04 +00:00
parent 6d91528b7c
commit 694ee42781
9 changed files with 455 additions and 95 deletions

View File

@@ -2,13 +2,15 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.core.config import get_settings
from app.schemas.settings import (
ModelConnectivityTestRead,
ModelConnectivityTestRequest,
RuntimeModelConfigRead,
SettingsRead,
SettingsWrite,
)
@@ -19,6 +21,25 @@ router = APIRouter(prefix="/settings")
DbSession = Annotated[Session, Depends(get_db)]
def require_hermes_agent_token(
authorization: Annotated[str | None, Header()] = None,
) -> None:
configured_token = str(get_settings().hermes_agent_shared_token or "").strip()
if not configured_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Hermes 集成令牌未配置。",
)
normalized = str(authorization or "").strip()
expected = f"Bearer {configured_token}"
if normalized != expected:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Hermes 集成鉴权失败。",
)
@router.get("", response_model=SettingsRead)
def get_settings(db: DbSession) -> SettingsRead:
return SettingsService(db).get_settings_snapshot()
@@ -42,3 +63,20 @@ def test_model_connectivity(payload: ModelConnectivityTestRequest, db: DbSession
resolved_payload = payload.model_copy(update={"api_key": stored_api_key})
return probe_model_connectivity(resolved_payload)
@router.get(
"/runtime-models/{slot}",
response_model=RuntimeModelConfigRead,
dependencies=[Depends(require_hermes_agent_token)],
)
def get_runtime_model_config(
slot: str,
db: DbSession,
) -> RuntimeModelConfigRead:
try:
payload = SettingsService(db).get_runtime_model_config(slot)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return RuntimeModelConfigRead(**payload)

View File

@@ -61,6 +61,7 @@ class Settings(BaseSettings):
onlyoffice_public_url: str = Field(default="", alias="ONLYOFFICE_PUBLIC_URL")
onlyoffice_backend_url: str = Field(default="", alias="ONLYOFFICE_BACKEND_URL")
onlyoffice_jwt_secret: str = Field(default="", alias="ONLYOFFICE_JWT_SECRET")
hermes_agent_shared_token: str = Field(default="", alias="HERMES_AGENT_SHARED_TOKEN")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
log_dir: str = Field(default="logs", alias="LOG_DIR")

View File

@@ -199,3 +199,12 @@ class ModelConnectivityTestRead(BaseModel):
detail: str
status_code: int | None = None
checked_at: datetime
class RuntimeModelConfigRead(BaseModel):
slot: Literal["main", "backup", "vlm", "embedding"]
provider: str
model: str
endpoint: str
apiKey: str
capability: Literal["chat", "embedding"]

View File

@@ -0,0 +1,159 @@
from __future__ import annotations
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
@dataclass(frozen=True, slots=True)
class HermesModelRoute:
model: str
endpoint: str
api_key: str = ""
provider_label: str = ""
@dataclass(frozen=True, slots=True)
class HermesConfigSnapshot:
path: Path
existed: bool
content: bytes
def get_hermes_home() -> Path:
configured_home = str(os.getenv("HERMES_HOME", "")).strip()
if configured_home:
return Path(configured_home).expanduser()
return Path.home() / ".hermes"
def get_hermes_config_path() -> Path:
return get_hermes_home() / "config.yaml"
def capture_hermes_config_snapshot(config_path: Path | None = None) -> HermesConfigSnapshot:
target_path = config_path or get_hermes_config_path()
if not target_path.exists():
return HermesConfigSnapshot(path=target_path, existed=False, content=b"")
return HermesConfigSnapshot(path=target_path, existed=True, content=target_path.read_bytes())
def restore_hermes_config_snapshot(snapshot: HermesConfigSnapshot) -> None:
snapshot.path.parent.mkdir(parents=True, exist_ok=True)
if snapshot.existed:
snapshot.path.write_bytes(snapshot.content)
return
if snapshot.path.exists():
snapshot.path.unlink()
def sync_hermes_model_settings(
primary_route: HermesModelRoute,
fallback_route: HermesModelRoute | None = None,
config_path: Path | None = None,
) -> Path:
target_path = config_path or get_hermes_config_path()
target_path.parent.mkdir(parents=True, exist_ok=True)
config = _load_existing_config(target_path)
config["model"] = _build_primary_model_config(primary_route)
if fallback_route is None:
config.pop("fallback_model", None)
else:
config["fallback_model"] = _build_fallback_model_config(fallback_route)
_atomic_yaml_write(target_path, config)
return target_path
def _load_existing_config(config_path: Path) -> dict[str, Any]:
if not config_path.exists():
return {}
raw_content = config_path.read_text(encoding="utf-8")
if not raw_content.strip():
return {}
loaded = yaml.safe_load(raw_content)
if loaded is None:
return {}
if not isinstance(loaded, dict):
raise ValueError(f"Hermes 配置文件格式无效: {config_path}")
return dict(loaded)
def _build_primary_model_config(route: HermesModelRoute) -> dict[str, Any]:
normalized_model = route.model.strip()
normalized_endpoint = route.endpoint.strip().rstrip("/")
if not normalized_model or not normalized_endpoint:
raise ValueError("Hermes 主模型同步失败:模型名称或接口地址为空。")
api_mode = _infer_api_mode(route)
payload: dict[str, Any] = {
"provider": "custom",
"default": normalized_model,
"base_url": normalized_endpoint,
}
if route.api_key.strip():
payload["api_key"] = route.api_key.strip()
else:
payload.pop("api_key", None)
if api_mode != "chat_completions":
payload["api_mode"] = api_mode
else:
payload.pop("api_mode", None)
return payload
def _build_fallback_model_config(route: HermesModelRoute) -> dict[str, Any]:
normalized_model = route.model.strip()
normalized_endpoint = route.endpoint.strip().rstrip("/")
if not normalized_model or not normalized_endpoint:
raise ValueError("Hermes 备份模型同步失败:模型名称或接口地址为空。")
api_mode = _infer_api_mode(route)
payload: dict[str, Any] = {
"provider": "custom",
"model": normalized_model,
"base_url": normalized_endpoint,
}
if route.api_key.strip():
payload["api_key"] = route.api_key.strip()
else:
payload.pop("api_key", None)
if api_mode != "chat_completions":
payload["api_mode"] = api_mode
else:
payload.pop("api_mode", None)
return payload
def _infer_api_mode(route: HermesModelRoute) -> str:
provider_label = route.provider_label.strip().casefold()
endpoint = route.endpoint.strip().lower().rstrip("/")
if provider_label == "claude" or "anthropic.com" in endpoint or endpoint.endswith("/anthropic"):
return "anthropic_messages"
if "api.openai.com" in endpoint or "api.x.ai" in endpoint:
return "codex_responses"
return "chat_completions"
def _atomic_yaml_write(target_path: Path, payload: dict[str, Any]) -> None:
serialized = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(target_path.parent),
prefix=f".{target_path.name}.",
delete=False,
) as temp_file:
temp_file.write(serialized)
temp_path = Path(temp_file.name)
temp_path.replace(target_path)

View File

@@ -17,6 +17,12 @@ from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
from app.repositories.settings import SETTINGS_ROW_ID, SettingsRepository
from app.schemas.settings import SettingsRead, SettingsWrite
from app.services.hermes_sync import (
HermesModelRoute,
capture_hermes_config_snapshot,
restore_hermes_config_snapshot,
sync_hermes_model_settings,
)
@dataclass(frozen=True, slots=True)
@@ -280,11 +286,24 @@ class SettingsService:
)
self._replace_secret_if_present(secrets_row, "smtp_password_encrypted", payload.mailForm.password)
hermes_snapshot = capture_hermes_config_snapshot()
try:
sync_hermes_model_settings(
primary_route=self._build_hermes_model_route(model_rows["main"]),
fallback_route=self._build_hermes_model_route(model_rows["backup"]),
)
self.db.add(settings_row)
self.db.add(secrets_row)
for model_row in model_rows.values():
self.db.add(model_row)
self.db.commit()
except Exception:
self.db.rollback()
restore_hermes_config_snapshot(hermes_snapshot)
raise
self.db.refresh(settings_row)
self.db.refresh(secrets_row)
for model_row in model_rows.values():
@@ -304,6 +323,23 @@ class SettingsService:
return decrypt_secret(encrypted_value)
def get_runtime_model_config(self, slot: str) -> dict[str, str]:
if slot not in MODEL_SLOT_CONFIGS:
raise ValueError("未知模型槽位。")
settings_row, secrets_row = self.ensure_settings_ready()
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
model_row = model_rows[slot]
return {
"slot": slot,
"provider": model_row.provider,
"model": model_row.model_name,
"endpoint": model_row.endpoint,
"apiKey": self.load_saved_model_api_key(slot),
"capability": model_row.capability,
}
def get_admin_credentials(self) -> AdminCredentialRecord | None:
settings_row, secrets_row = self.ensure_settings_ready()
@@ -452,6 +488,18 @@ class SettingsService:
if normalized_api_key:
model_row.api_key_encrypted = encrypt_secret(normalized_api_key)
def _build_hermes_model_route(self, model_row: SystemModelSetting) -> HermesModelRoute:
api_key = ""
if model_row.api_key_encrypted:
api_key = decrypt_secret(model_row.api_key_encrypted)
return HermesModelRoute(
provider_label=str(model_row.provider or "").strip(),
model=str(model_row.model_name or "").strip(),
endpoint=str(model_row.endpoint or "").strip(),
api_key=api_key,
)
def _ensure_settings_schema(self) -> None:
bind = self.db.get_bind()
inspector = inspect(bind)

View File

@@ -6,6 +6,7 @@ import json
import secrets
import tempfile
import yaml
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
@@ -16,6 +17,7 @@ from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
from app.schemas.settings import SettingsWrite
from app.services.hermes_sync import get_hermes_config_path
from app.services.settings import SettingsService
@@ -39,6 +41,7 @@ def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) ->
temp_dir = build_temp_secret_dir()
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
with build_session(temp_dir / "settings.db") as db:
service = SettingsService(db)
@@ -95,6 +98,7 @@ def test_blank_secret_input_does_not_clear_saved_secret(monkeypatch) -> None:
temp_dir = build_temp_secret_dir()
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
with build_session(temp_dir / "settings.db") as db:
service = SettingsService(db)
@@ -109,12 +113,38 @@ def test_blank_secret_input_does_not_clear_saved_secret(monkeypatch) -> None:
assert service.load_saved_model_api_key("main") == "persisted-key"
def test_runtime_model_config_returns_decrypted_main_model(monkeypatch) -> None:
temp_dir = build_temp_secret_dir()
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
with build_session(temp_dir / "settings.db") as db:
service = SettingsService(db)
payload = service.get_settings_snapshot().model_dump()
payload["llmForm"]["mainProvider"] = "MiniMax"
payload["llmForm"]["mainModel"] = "MiniMax-Text-01"
payload["llmForm"]["mainEndpoint"] = "https://api.minimaxi.com/v1"
payload["llmForm"]["mainApiKey"] = "shared-main-key"
service.save_settings_snapshot(SettingsWrite(**payload))
runtime_model = service.get_runtime_model_config("main")
assert runtime_model["slot"] == "main"
assert runtime_model["provider"] == "MiniMax"
assert runtime_model["model"] == "MiniMax-Text-01"
assert runtime_model["endpoint"] == "https://api.minimaxi.com/v1"
assert runtime_model["apiKey"] == "shared-main-key"
assert runtime_model["capability"] == "chat"
def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> None:
temp_dir = build_temp_secret_dir()
admin_file = temp_dir / "admin.json"
monkeypatch.setattr(admin_secret, "ADMIN_SECRET_FILE", admin_file)
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
password = "setup-secret"
salt = secrets.token_bytes(16)
@@ -144,3 +174,71 @@ def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> Non
assert secrets_row is not None
assert secrets_row.admin_password_hash.startswith("scrypt$")
assert service.verify_admin_login("setup-admin", password) is not None
def test_settings_service_syncs_models_to_hermes_config(monkeypatch) -> None:
temp_dir = build_temp_secret_dir()
hermes_home = temp_dir / ".hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
hermes_config_path = hermes_home / "config.yaml"
hermes_config_path.write_text(
yaml.safe_dump({"toolsets": ["hermes-cli"], "browser": {"record_sessions": False}}, sort_keys=False),
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
with build_session(temp_dir / "settings.db") as db:
service = SettingsService(db)
payload = service.get_settings_snapshot().model_dump()
payload["llmForm"]["mainProvider"] = "Claude"
payload["llmForm"]["mainModel"] = "claude-sonnet-4-6"
payload["llmForm"]["mainEndpoint"] = "https://api.anthropic.com/v1/"
payload["llmForm"]["mainApiKey"] = "anthropic-secret"
payload["llmForm"]["backupProvider"] = "GLM"
payload["llmForm"]["backupModel"] = "glm-5.1"
payload["llmForm"]["backupEndpoint"] = "https://open.bigmodel.cn/api/paas/v4/"
payload["llmForm"]["backupApiKey"] = "glm-secret"
service.save_settings_snapshot(SettingsWrite(**payload))
hermes_config = yaml.safe_load(get_hermes_config_path().read_text(encoding="utf-8"))
assert hermes_config["toolsets"] == ["hermes-cli"]
assert hermes_config["browser"] == {"record_sessions": False}
assert hermes_config["model"] == {
"provider": "custom",
"default": "claude-sonnet-4-6",
"base_url": "https://api.anthropic.com/v1",
"api_key": "anthropic-secret",
"api_mode": "anthropic_messages",
}
assert hermes_config["fallback_model"] == {
"provider": "custom",
"model": "glm-5.1",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"api_key": "glm-secret",
}
def test_blank_secret_input_keeps_synced_hermes_api_key(monkeypatch) -> None:
temp_dir = build_temp_secret_dir()
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
with build_session(temp_dir / "settings.db") as db:
service = SettingsService(db)
first_payload = service.get_settings_snapshot().model_dump()
first_payload["llmForm"]["mainApiKey"] = "persisted-main-key"
service.save_settings_snapshot(SettingsWrite(**first_payload))
second_payload = service.get_settings_snapshot().model_dump()
second_payload["llmForm"]["mainModel"] = "gpt-5.4-mini"
second_payload["llmForm"]["mainApiKey"] = ""
service.save_settings_snapshot(SettingsWrite(**second_payload))
hermes_config = yaml.safe_load(get_hermes_config_path().read_text(encoding="utf-8"))
assert hermes_config["model"]["default"] == "gpt-5.4-mini"
assert hermes_config["model"]["api_key"] == "persisted-main-key"

View File

@@ -77,7 +77,7 @@ const sidebarMeta = {
approval: { label: '审批中心', badge: '12' },
chat: { label: 'AI 助手' },
policies: { label: '知识管理' },
audit: { label: '审计追踪' },
audit: { label: '技能中心' },
employees: { label: '员工管理' },
settings: { label: '系统设置' }
}

View File

@@ -56,11 +56,11 @@ export const navItems = [
},
{
id: 'audit',
label: '审计追踪',
navHint: '查看日志与追踪记录',
label: '技能中心',
navHint: '查看和管理技能配置',
icon: icons.skill,
title: '审计追踪',
desc: '记录关键操作、追踪审批链和系统行为。'
title: '技能中心',
desc: '集中管理技能配置、提示词结构、测试样例与发布状态。'
},
{
id: 'employees',

View File

@@ -200,6 +200,13 @@
</div>
<div class="form-grid">
<div class="field field-full">
<small class="secret-bound-state">
<i class="mdi mdi-source-branch"></i>
<span>保存后会同步写入 Hermes 配置外部 Hermes agent 也可通过后端共享接口读取这里的主模型配置</span>
</small>
</div>
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="pageState.llmForm.mainProvider" @change="applyProviderPreset('main')">