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 typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_db
from app.core.config import get_settings
from app.schemas.settings import ( from app.schemas.settings import (
ModelConnectivityTestRead, ModelConnectivityTestRead,
ModelConnectivityTestRequest, ModelConnectivityTestRequest,
RuntimeModelConfigRead,
SettingsRead, SettingsRead,
SettingsWrite, SettingsWrite,
) )
@@ -19,6 +21,25 @@ router = APIRouter(prefix="/settings")
DbSession = Annotated[Session, Depends(get_db)] 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) @router.get("", response_model=SettingsRead)
def get_settings(db: DbSession) -> SettingsRead: def get_settings(db: DbSession) -> SettingsRead:
return SettingsService(db).get_settings_snapshot() 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}) resolved_payload = payload.model_copy(update={"api_key": stored_api_key})
return probe_model_connectivity(resolved_payload) 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_public_url: str = Field(default="", alias="ONLYOFFICE_PUBLIC_URL")
onlyoffice_backend_url: str = Field(default="", alias="ONLYOFFICE_BACKEND_URL") onlyoffice_backend_url: str = Field(default="", alias="ONLYOFFICE_BACKEND_URL")
onlyoffice_jwt_secret: str = Field(default="", alias="ONLYOFFICE_JWT_SECRET") 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_level: str = Field(default="INFO", alias="LOG_LEVEL")
log_dir: str = Field(default="logs", alias="LOG_DIR") log_dir: str = Field(default="logs", alias="LOG_DIR")

View File

@@ -199,3 +199,12 @@ class ModelConnectivityTestRead(BaseModel):
detail: str detail: str
status_code: int | None = None status_code: int | None = None
checked_at: datetime 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.models.system_setting_secret import SystemSettingSecret
from app.repositories.settings import SETTINGS_ROW_ID, SettingsRepository from app.repositories.settings import SETTINGS_ROW_ID, SettingsRepository
from app.schemas.settings import SettingsRead, SettingsWrite 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) @dataclass(frozen=True, slots=True)
@@ -32,23 +38,23 @@ class ModelSlotConfig:
priority: int priority: int
MODEL_SLOT_CONFIGS = { MODEL_SLOT_CONFIGS = {
"main": ModelSlotConfig( "main": ModelSlotConfig(
provider_attr="main_provider", provider_attr="main_provider",
model_attr="main_model", model_attr="main_model",
endpoint_attr="main_endpoint", endpoint_attr="main_endpoint",
legacy_secret_attr="main_api_key_encrypted", legacy_secret_attr="main_api_key_encrypted",
default_provider="Codex", default_provider="Codex",
default_model="codex-mini-latest", default_model="codex-mini-latest",
default_endpoint="https://api.openai.com/v1", default_endpoint="https://api.openai.com/v1",
capability="chat", capability="chat",
priority=10, priority=10,
), ),
"backup": ModelSlotConfig( "backup": ModelSlotConfig(
provider_attr="backup_provider", provider_attr="backup_provider",
model_attr="backup_model", model_attr="backup_model",
endpoint_attr="backup_endpoint", endpoint_attr="backup_endpoint",
legacy_secret_attr="backup_api_key_encrypted", legacy_secret_attr="backup_api_key_encrypted",
default_provider="GLM", default_provider="GLM",
default_model="glm-5.1", default_model="glm-5.1",
default_endpoint="https://open.bigmodel.cn/api/paas/v4/", default_endpoint="https://open.bigmodel.cn/api/paas/v4/",
@@ -175,9 +181,9 @@ class SettingsService:
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
return self._serialize(settings_row, secrets_row, model_rows) return self._serialize(settings_row, secrets_row, model_rows)
def save_settings_snapshot(self, payload: SettingsWrite) -> SettingsRead: def save_settings_snapshot(self, payload: SettingsWrite) -> SettingsRead:
settings_row, secrets_row = self.ensure_settings_ready() settings_row, secrets_row = self.ensure_settings_ready()
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
if payload.adminForm.newPassword: if payload.adminForm.newPassword:
if len(payload.adminForm.newPassword) < 5: if len(payload.adminForm.newPassword) < 5:
@@ -200,18 +206,18 @@ class SettingsService:
settings_row.strong_password = payload.adminForm.strongPassword settings_row.strong_password = payload.adminForm.strongPassword
settings_row.login_alert_enabled = payload.adminForm.loginAlertEnabled settings_row.login_alert_enabled = payload.adminForm.loginAlertEnabled
self._apply_model_setting( self._apply_model_setting(
model_rows["main"], model_rows["main"],
payload.llmForm.mainProvider, payload.llmForm.mainProvider,
payload.llmForm.mainModel, payload.llmForm.mainModel,
payload.llmForm.mainEndpoint, payload.llmForm.mainEndpoint,
payload.llmForm.mainApiKey, payload.llmForm.mainApiKey,
) )
self._apply_model_setting( self._apply_model_setting(
model_rows["backup"], model_rows["backup"],
payload.llmForm.backupProvider, payload.llmForm.backupProvider,
payload.llmForm.backupModel, payload.llmForm.backupModel,
payload.llmForm.backupEndpoint, payload.llmForm.backupEndpoint,
payload.llmForm.backupApiKey, payload.llmForm.backupApiKey,
) )
self._apply_model_setting( self._apply_model_setting(
@@ -279,30 +285,60 @@ class SettingsService:
payload.renderForm.jwtSecret, payload.renderForm.jwtSecret,
) )
self._replace_secret_if_present(secrets_row, "smtp_password_encrypted", payload.mailForm.password) 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():
self.db.refresh(model_row)
return self._serialize(settings_row, secrets_row, model_rows)
self.db.add(settings_row) def load_saved_model_api_key(self, slot: str | None) -> str:
self.db.add(secrets_row) if not slot or slot not in MODEL_SLOT_CONFIGS:
for model_row in model_rows.values(): return ""
self.db.add(model_row)
self.db.commit()
self.db.refresh(settings_row)
self.db.refresh(secrets_row)
for model_row in model_rows.values():
self.db.refresh(model_row)
return self._serialize(settings_row, secrets_row, model_rows)
def load_saved_model_api_key(self, slot: str | None) -> str:
if not slot or slot not in MODEL_SLOT_CONFIGS:
return ""
settings_row, secrets_row = self.ensure_settings_ready() settings_row, secrets_row = self.ensure_settings_ready()
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
encrypted_value = model_rows[slot].api_key_encrypted encrypted_value = model_rows[slot].api_key_encrypted
if not encrypted_value: if not encrypted_value:
return "" return ""
return decrypt_secret(encrypted_value) 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: def get_admin_credentials(self) -> AdminCredentialRecord | None:
settings_row, secrets_row = self.ensure_settings_ready() settings_row, secrets_row = self.ensure_settings_ready()
@@ -448,10 +484,22 @@ class SettingsService:
model_row.model_name = model_name model_row.model_name = model_name
model_row.endpoint = endpoint model_row.endpoint = endpoint
normalized_api_key = api_key.strip() normalized_api_key = api_key.strip()
if normalized_api_key: if normalized_api_key:
model_row.api_key_encrypted = encrypt_secret(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: def _ensure_settings_schema(self) -> None:
bind = self.db.get_bind() bind = self.db.get_bind()
inspector = inspect(bind) inspector = inspect(bind)

View File

@@ -1,22 +1,24 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import hashlib import hashlib
import json import json
import secrets import secrets
import tempfile import tempfile
from sqlalchemy import create_engine import yaml
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core import admin_secret from app.core import admin_secret
from app.core import secret_box from app.core import secret_box
from app.db.base import Base from app.db.base import Base
from app.models.system_model_setting import SystemModelSetting from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret from app.models.system_setting_secret import SystemSettingSecret
from app.schemas.settings import SettingsWrite from app.schemas.settings import SettingsWrite
from app.services.settings import SettingsService from app.services.hermes_sync import get_hermes_config_path
from app.services.settings import SettingsService
def build_session(db_file: Path) -> Session: def build_session(db_file: Path) -> Session:
@@ -36,9 +38,10 @@ def build_temp_secret_dir() -> Path:
def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) -> None: def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) -> None:
temp_dir = build_temp_secret_dir() temp_dir = build_temp_secret_dir()
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key") monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None) 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: with build_session(temp_dir / "settings.db") as db:
service = SettingsService(db) service = SettingsService(db)
@@ -72,7 +75,7 @@ def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) ->
assert saved_snapshot.mailForm.password == "" assert saved_snapshot.mailForm.password == ""
assert saved_snapshot.mailForm.passwordConfigured is True assert saved_snapshot.mailForm.passwordConfigured is True
assert saved_snapshot.adminForm.newPassword == "" assert saved_snapshot.adminForm.newPassword == ""
assert saved_snapshot.adminForm.adminPasswordConfigured is True assert saved_snapshot.adminForm.adminPasswordConfigured is True
model_row = db.get(SystemModelSetting, "main") model_row = db.get(SystemModelSetting, "main")
settings_row = db.get(SystemSetting, "default") settings_row = db.get(SystemSetting, "default")
@@ -91,10 +94,11 @@ def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) ->
assert service.verify_admin_login("admin@example.com", "54321") is not None assert service.verify_admin_login("admin@example.com", "54321") is not None
def test_blank_secret_input_does_not_clear_saved_secret(monkeypatch) -> None: def test_blank_secret_input_does_not_clear_saved_secret(monkeypatch) -> None:
temp_dir = build_temp_secret_dir() temp_dir = build_temp_secret_dir()
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key") monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None) 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: with build_session(temp_dir / "settings.db") as db:
service = SettingsService(db) service = SettingsService(db)
@@ -104,17 +108,43 @@ def test_blank_secret_input_does_not_clear_saved_secret(monkeypatch) -> None:
second_payload = service.get_settings_snapshot().model_dump() second_payload = service.get_settings_snapshot().model_dump()
second_payload["llmForm"]["mainApiKey"] = "" second_payload["llmForm"]["mainApiKey"] = ""
service.save_settings_snapshot(SettingsWrite(**second_payload)) service.save_settings_snapshot(SettingsWrite(**second_payload))
assert service.load_saved_model_api_key("main") == "persisted-key" 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: def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> None:
temp_dir = build_temp_secret_dir() temp_dir = build_temp_secret_dir()
admin_file = temp_dir / "admin.json" admin_file = temp_dir / "admin.json"
monkeypatch.setattr(admin_secret, "ADMIN_SECRET_FILE", admin_file) monkeypatch.setattr(admin_secret, "ADMIN_SECRET_FILE", admin_file)
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key") monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None) monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
password = "setup-secret" password = "setup-secret"
salt = secrets.token_bytes(16) salt = secrets.token_bytes(16)
@@ -142,5 +172,73 @@ def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> Non
assert snapshot.adminForm.adminPasswordConfigured is True assert snapshot.adminForm.adminPasswordConfigured is True
assert secrets_row is not None assert secrets_row is not None
assert secrets_row.admin_password_hash.startswith("scrypt$") assert secrets_row.admin_password_hash.startswith("scrypt$")
assert service.verify_admin_login("setup-admin", password) is not None 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' }, approval: { label: '审批中心', badge: '12' },
chat: { label: 'AI 助手' }, chat: { label: 'AI 助手' },
policies: { label: '知识管理' }, policies: { label: '知识管理' },
audit: { label: '审计追踪' }, audit: { label: '技能中心' },
employees: { label: '员工管理' }, employees: { label: '员工管理' },
settings: { label: '系统设置' } settings: { label: '系统设置' }
} }

View File

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

View File

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