diff --git a/server/src/app/api/v1/endpoints/settings.py b/server/src/app/api/v1/endpoints/settings.py index 67b6f94..3faeb8b 100644 --- a/server/src/app/api/v1/endpoints/settings.py +++ b/server/src/app/api/v1/endpoints/settings.py @@ -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) diff --git a/server/src/app/core/config.py b/server/src/app/core/config.py index a692c40..bbf4868 100644 --- a/server/src/app/core/config.py +++ b/server/src/app/core/config.py @@ -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") diff --git a/server/src/app/schemas/settings.py b/server/src/app/schemas/settings.py index 95c5d8e..a68283b 100644 --- a/server/src/app/schemas/settings.py +++ b/server/src/app/schemas/settings.py @@ -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"] diff --git a/server/src/app/services/hermes_sync.py b/server/src/app/services/hermes_sync.py new file mode 100644 index 0000000..4f6af10 --- /dev/null +++ b/server/src/app/services/hermes_sync.py @@ -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) diff --git a/server/src/app/services/settings.py b/server/src/app/services/settings.py index aa92baa..667922e 100644 --- a/server/src/app/services/settings.py +++ b/server/src/app/services/settings.py @@ -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) @@ -32,23 +38,23 @@ class ModelSlotConfig: priority: int -MODEL_SLOT_CONFIGS = { - "main": ModelSlotConfig( - provider_attr="main_provider", - model_attr="main_model", - endpoint_attr="main_endpoint", - legacy_secret_attr="main_api_key_encrypted", - default_provider="Codex", - default_model="codex-mini-latest", - default_endpoint="https://api.openai.com/v1", - capability="chat", - priority=10, - ), - "backup": ModelSlotConfig( - provider_attr="backup_provider", - model_attr="backup_model", - endpoint_attr="backup_endpoint", - legacy_secret_attr="backup_api_key_encrypted", +MODEL_SLOT_CONFIGS = { + "main": ModelSlotConfig( + provider_attr="main_provider", + model_attr="main_model", + endpoint_attr="main_endpoint", + legacy_secret_attr="main_api_key_encrypted", + default_provider="Codex", + default_model="codex-mini-latest", + default_endpoint="https://api.openai.com/v1", + capability="chat", + priority=10, + ), + "backup": ModelSlotConfig( + provider_attr="backup_provider", + model_attr="backup_model", + endpoint_attr="backup_endpoint", + legacy_secret_attr="backup_api_key_encrypted", default_provider="GLM", default_model="glm-5.1", 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) return self._serialize(settings_row, secrets_row, model_rows) - def save_settings_snapshot(self, payload: SettingsWrite) -> SettingsRead: - settings_row, secrets_row = self.ensure_settings_ready() - model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) + def save_settings_snapshot(self, payload: SettingsWrite) -> SettingsRead: + settings_row, secrets_row = self.ensure_settings_ready() + model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) if payload.adminForm.newPassword: if len(payload.adminForm.newPassword) < 5: @@ -200,18 +206,18 @@ class SettingsService: settings_row.strong_password = payload.adminForm.strongPassword settings_row.login_alert_enabled = payload.adminForm.loginAlertEnabled - self._apply_model_setting( - model_rows["main"], - payload.llmForm.mainProvider, - payload.llmForm.mainModel, - payload.llmForm.mainEndpoint, - payload.llmForm.mainApiKey, - ) - self._apply_model_setting( - model_rows["backup"], - payload.llmForm.backupProvider, - payload.llmForm.backupModel, - payload.llmForm.backupEndpoint, + self._apply_model_setting( + model_rows["main"], + payload.llmForm.mainProvider, + payload.llmForm.mainModel, + payload.llmForm.mainEndpoint, + payload.llmForm.mainApiKey, + ) + self._apply_model_setting( + model_rows["backup"], + payload.llmForm.backupProvider, + payload.llmForm.backupModel, + payload.llmForm.backupEndpoint, payload.llmForm.backupApiKey, ) self._apply_model_setting( @@ -279,30 +285,60 @@ class SettingsService: payload.renderForm.jwtSecret, ) 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) - self.db.add(secrets_row) - for model_row in model_rows.values(): - 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 "" + 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() model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) encrypted_value = model_rows[slot].api_key_encrypted if not encrypted_value: 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: settings_row, secrets_row = self.ensure_settings_ready() @@ -448,10 +484,22 @@ class SettingsService: model_row.model_name = model_name model_row.endpoint = endpoint - normalized_api_key = api_key.strip() + normalized_api_key = api_key.strip() 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) diff --git a/server/tests/test_settings_persistence.py b/server/tests/test_settings_persistence.py index 1936726..97bdff5 100644 --- a/server/tests/test_settings_persistence.py +++ b/server/tests/test_settings_persistence.py @@ -1,22 +1,24 @@ from __future__ import annotations -from pathlib import Path -import hashlib -import json -import secrets -import tempfile - -from sqlalchemy import create_engine -from sqlalchemy.orm import Session, sessionmaker +from pathlib import Path +import hashlib +import json +import secrets +import tempfile + +import yaml +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker from app.core import admin_secret from app.core import secret_box -from app.db.base import Base -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.settings import SettingsService +from app.db.base import Base +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 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: - 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) + 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) @@ -72,7 +75,7 @@ def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) -> assert saved_snapshot.mailForm.password == "" assert saved_snapshot.mailForm.passwordConfigured is True 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") 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 -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) +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) @@ -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["llmForm"]["mainApiKey"] = "" - service.save_settings_snapshot(SettingsWrite(**second_payload)) - - assert service.load_saved_model_api_key("main") == "persisted-key" + service.save_settings_snapshot(SettingsWrite(**second_payload)) + + 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) +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) @@ -142,5 +172,73 @@ def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> Non assert snapshot.adminForm.adminPasswordConfigured is True 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 + 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" diff --git a/web/src/components/layout/SidebarRail.vue b/web/src/components/layout/SidebarRail.vue index 4a58528..ba15139 100644 --- a/web/src/components/layout/SidebarRail.vue +++ b/web/src/components/layout/SidebarRail.vue @@ -77,7 +77,7 @@ const sidebarMeta = { approval: { label: '审批中心', badge: '12' }, chat: { label: 'AI 助手' }, policies: { label: '知识管理' }, - audit: { label: '审计追踪' }, + audit: { label: '技能中心' }, employees: { label: '员工管理' }, settings: { label: '系统设置' } } diff --git a/web/src/composables/useNavigation.js b/web/src/composables/useNavigation.js index 0421e87..b1fa861 100644 --- a/web/src/composables/useNavigation.js +++ b/web/src/composables/useNavigation.js @@ -56,11 +56,11 @@ export const navItems = [ }, { id: 'audit', - label: '审计追踪', - navHint: '查看日志与追踪记录', + label: '技能中心', + navHint: '查看和管理技能配置', icon: icons.skill, - title: '审计追踪', - desc: '记录关键操作、追踪审批链和系统行为。' + title: '技能中心', + desc: '集中管理技能配置、提示词结构、测试样例与发布状态。' }, { id: 'employees', diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index d33485a..8c0e026 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -199,12 +199,19 @@ -
-