diff --git a/server/src/app/core/admin_secret.py b/server/src/app/core/admin_secret.py index 865843a..35f0aa1 100644 --- a/server/src/app/core/admin_secret.py +++ b/server/src/app/core/admin_secret.py @@ -51,3 +51,13 @@ def verify_admin_secret(password: str, record: dict[str, object]) -> bool: dklen=key_length, ) return secrets.compare_digest(derived_key, stored_key) + + +def legacy_admin_secret_to_password_hash(record: dict[str, object]) -> str: + salt = str(record["salt"]) + derived_key = str(record["derived_key"]) + key_length = int(record.get("key_length", 64)) + n_value = int(record.get("N", 16384)) + r_value = int(record.get("r", 8)) + p_value = int(record.get("p", 1)) + return f"scrypt${n_value}${r_value}${p_value}${key_length}${salt}${derived_key}" diff --git a/server/src/app/core/security.py b/server/src/app/core/security.py index ee5bb30..a7cefcc 100644 --- a/server/src/app/core/security.py +++ b/server/src/app/core/security.py @@ -23,6 +23,9 @@ def hash_password(password: str) -> str: def verify_password(password: str, password_hash: str) -> bool: + if password_hash.startswith("scrypt$"): + return verify_scrypt_password(password, password_hash) + try: scheme, iterations, encoded_salt, encoded_digest = password_hash.split("$", 3) except ValueError: @@ -40,3 +43,29 @@ def verify_password(password: str, password_hash: str) -> bool: int(iterations), ) return secrets.compare_digest(computed_digest, expected_digest) + + +def verify_scrypt_password(password: str, password_hash: str) -> bool: + try: + scheme, n_value, r_value, p_value, key_length, salt_hex, derived_key_hex = password_hash.split("$", 6) + except ValueError: + return False + + if scheme != "scrypt": + return False + + try: + salt = bytes.fromhex(salt_hex) + expected_key = bytes.fromhex(derived_key_hex) + derived_key = hashlib.scrypt( + password.encode("utf-8"), + salt=salt, + n=int(n_value), + r=int(r_value), + p=int(p_value), + dklen=int(key_length), + ) + except ValueError: + return False + + return secrets.compare_digest(derived_key, expected_key) diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index 612b2bb..9082282 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -5,6 +5,7 @@ from app.models.employee import Employee from app.models.organization import OrganizationUnit from app.models.reimbursement import ReimbursementRequest from app.models.role import Role +from app.models.system_model_setting import SystemModelSetting from app.models.system_setting import SystemSetting from app.models.system_setting_secret import SystemSettingSecret @@ -16,6 +17,7 @@ __all__ = [ "OrganizationUnit", "ReimbursementRequest", "Role", + "SystemModelSetting", "SystemSetting", "SystemSettingSecret", ] diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index a25a818..b4a9671 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -4,6 +4,7 @@ from app.models.employee import Employee from app.models.organization import OrganizationUnit from app.models.reimbursement import ReimbursementRequest from app.models.role import Role +from app.models.system_model_setting import SystemModelSetting from app.models.system_setting import SystemSetting from app.models.system_setting_secret import SystemSettingSecret @@ -14,6 +15,7 @@ __all__ = [ "OrganizationUnit", "ReimbursementRequest", "Role", + "SystemModelSetting", "SystemSetting", "SystemSettingSecret", ] diff --git a/server/src/app/models/system_model_setting.py b/server/src/app/models/system_model_setting.py new file mode 100644 index 0000000..0a38dbd --- /dev/null +++ b/server/src/app/models/system_model_setting.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class SystemModelSetting(Base): + __tablename__ = "system_model_settings" + + slot: Mapped[str] = mapped_column(String(32), primary_key=True) + provider: Mapped[str] = mapped_column(String(64), default="") + model_name: Mapped[str] = mapped_column(String(255), default="") + endpoint: Mapped[str] = mapped_column(String(512), default="") + capability: Mapped[str] = mapped_column(String(32), default="chat") + priority: Mapped[int] = mapped_column(Integer, default=0) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + api_key_encrypted: Mapped[str] = mapped_column(Text, default="") + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/server/src/app/repositories/settings.py b/server/src/app/repositories/settings.py index e099f74..49908d7 100644 --- a/server/src/app/repositories/settings.py +++ b/server/src/app/repositories/settings.py @@ -3,6 +3,7 @@ from __future__ import annotations from sqlalchemy import select from sqlalchemy.orm import Session +from app.models.system_model_setting import SystemModelSetting from app.models.system_setting import SystemSetting from app.models.system_setting_secret import SystemSettingSecret @@ -21,6 +22,14 @@ class SettingsRepository: stmt = select(SystemSettingSecret).where(SystemSettingSecret.id == SETTINGS_ROW_ID) return self.db.execute(stmt).scalars().first() + def get_model_settings(self) -> list[SystemModelSetting]: + stmt = select(SystemModelSetting) + return list(self.db.execute(stmt).scalars().all()) + + def get_model_setting(self, slot: str) -> SystemModelSetting | None: + stmt = select(SystemModelSetting).where(SystemModelSetting.slot == slot) + return self.db.execute(stmt).scalars().first() + def save_settings(self, settings: SystemSetting) -> SystemSetting: self.db.add(settings) self.db.commit() diff --git a/server/src/app/services/settings.py b/server/src/app/services/settings.py index ed8a71b..4028016 100644 --- a/server/src/app/services/settings.py +++ b/server/src/app/services/settings.py @@ -5,21 +5,76 @@ from datetime import datetime from sqlalchemy.orm import Session -from app.core.admin_secret import read_admin_secret, verify_admin_secret +from app.core.admin_secret import legacy_admin_secret_to_password_hash, read_admin_secret, verify_admin_secret from app.core.config import get_settings from app.core.secret_box import decrypt_secret, encrypt_secret from app.core.security import hash_password, verify_password 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.repositories.settings import SETTINGS_ROW_ID, SettingsRepository from app.schemas.settings import SettingsRead, SettingsWrite -MODEL_SECRET_FIELDS = { - "main": "main_api_key_encrypted", - "backup": "backup_api_key_encrypted", - "vlm": "vlm_api_key_encrypted", - "embedding": "embedding_api_key_encrypted", + +@dataclass(frozen=True, slots=True) +class ModelSlotConfig: + provider_attr: str + model_attr: str + endpoint_attr: str + legacy_secret_attr: str + default_provider: str + default_model: str + default_endpoint: str + capability: str + 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", + default_provider="GLM", + default_model="glm-5.1", + default_endpoint="https://open.bigmodel.cn/api/paas/v4/", + capability="chat", + priority=20, + ), + "vlm": ModelSlotConfig( + provider_attr="vlm_provider", + model_attr="vlm_model", + endpoint_attr="vlm_endpoint", + legacy_secret_attr="vlm_api_key_encrypted", + default_provider="Gemini", + default_model="gemini-2.5-flash", + default_endpoint="https://generativelanguage.googleapis.com/v1beta/openai/", + capability="chat", + priority=30, + ), + "embedding": ModelSlotConfig( + provider_attr="embedding_provider", + model_attr="embedding_model", + endpoint_attr="embedding_endpoint", + legacy_secret_attr="embedding_api_key_encrypted", + default_provider="GLM", + default_model="Embedding-3", + default_endpoint="https://open.bigmodel.cn/api/paas/v4/", + capability="embedding", + priority=40, + ), } @@ -42,6 +97,7 @@ class SettingsService: settings_row = self.repository.get_settings() secrets_row = self.repository.get_secrets() should_commit = False + legacy_admin = read_admin_secret() if settings_row is None: settings_row = self._build_default_settings() @@ -53,6 +109,13 @@ class SettingsService: self.db.add(secrets_row) should_commit = True + if legacy_admin is not None and not secrets_row.admin_password_hash: + secrets_row.admin_password_hash = legacy_admin_secret_to_password_hash(legacy_admin) + admin_username = str(legacy_admin.get("username", "")).strip() + if admin_username and str(settings_row.admin_account or "").strip() in {"", "superadmin"}: + settings_row.admin_account = admin_username + should_commit = True + if should_commit: self.db.commit() self.db.refresh(settings_row) @@ -60,12 +123,47 @@ class SettingsService: return settings_row, secrets_row + def ensure_model_settings_ready( + self, + settings_row: SystemSetting, + secrets_row: SystemSettingSecret, + ) -> dict[str, SystemModelSetting]: + model_rows = {row.slot: row for row in self.repository.get_model_settings()} + should_commit = False + + for slot, config in MODEL_SLOT_CONFIGS.items(): + if slot in model_rows: + continue + + model_row = SystemModelSetting( + slot=slot, + provider=str(getattr(settings_row, config.provider_attr, "") or config.default_provider), + model_name=str(getattr(settings_row, config.model_attr, "") or config.default_model), + endpoint=str(getattr(settings_row, config.endpoint_attr, "") or config.default_endpoint), + capability=config.capability, + priority=config.priority, + enabled=True, + api_key_encrypted=str(getattr(secrets_row, config.legacy_secret_attr, "") or ""), + ) + self.db.add(model_row) + model_rows[slot] = model_row + should_commit = True + + if should_commit: + self.db.commit() + for model_row in model_rows.values(): + self.db.refresh(model_row) + + return model_rows + def get_settings_snapshot(self) -> SettingsRead: settings_row, secrets_row = self.ensure_settings_ready() - return self._serialize(settings_row, secrets_row) + 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) if payload.adminForm.newPassword: if len(payload.adminForm.newPassword) < 5: @@ -88,28 +186,48 @@ class SettingsService: settings_row.strong_password = payload.adminForm.strongPassword settings_row.login_alert_enabled = payload.adminForm.loginAlertEnabled - settings_row.main_provider = payload.llmForm.mainProvider - settings_row.main_model = payload.llmForm.mainModel - settings_row.main_endpoint = payload.llmForm.mainEndpoint - settings_row.backup_provider = payload.llmForm.backupProvider - settings_row.backup_model = payload.llmForm.backupModel - settings_row.backup_endpoint = payload.llmForm.backupEndpoint - settings_row.vlm_provider = payload.llmForm.vlmProvider - settings_row.vlm_model = payload.llmForm.vlmModel - settings_row.vlm_endpoint = payload.llmForm.vlmEndpoint - settings_row.embedding_provider = payload.llmForm.embeddingProvider - settings_row.embedding_model = payload.llmForm.embeddingModel - settings_row.embedding_endpoint = payload.llmForm.embeddingEndpoint - - self._replace_secret_if_present(secrets_row, "main_api_key_encrypted", payload.llmForm.mainApiKey) - self._replace_secret_if_present(secrets_row, "backup_api_key_encrypted", payload.llmForm.backupApiKey) - self._replace_secret_if_present(secrets_row, "vlm_api_key_encrypted", payload.llmForm.vlmApiKey) - self._replace_secret_if_present( - secrets_row, - "embedding_api_key_encrypted", + 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( + model_rows["vlm"], + payload.llmForm.vlmProvider, + payload.llmForm.vlmModel, + payload.llmForm.vlmEndpoint, + payload.llmForm.vlmApiKey, + ) + self._apply_model_setting( + model_rows["embedding"], + payload.llmForm.embeddingProvider, + payload.llmForm.embeddingModel, + payload.llmForm.embeddingEndpoint, payload.llmForm.embeddingApiKey, ) + settings_row.main_provider = model_rows["main"].provider + settings_row.main_model = model_rows["main"].model_name + settings_row.main_endpoint = model_rows["main"].endpoint + settings_row.backup_provider = model_rows["backup"].provider + settings_row.backup_model = model_rows["backup"].model_name + settings_row.backup_endpoint = model_rows["backup"].endpoint + settings_row.vlm_provider = model_rows["vlm"].provider + settings_row.vlm_model = model_rows["vlm"].model_name + settings_row.vlm_endpoint = model_rows["vlm"].endpoint + settings_row.embedding_provider = model_rows["embedding"].provider + settings_row.embedding_model = model_rows["embedding"].model_name + settings_row.embedding_endpoint = model_rows["embedding"].endpoint + settings_row.log_level = payload.logForm.level settings_row.retention_days = payload.logForm.retentionDays settings_row.archive_cycle = payload.logForm.archiveCycle @@ -134,18 +252,23 @@ class SettingsService: 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) + 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_SECRET_FIELDS: + if not slot or slot not in MODEL_SLOT_CONFIGS: return "" - _, secrets_row = self.ensure_settings_ready() - encrypted_value = getattr(secrets_row, MODEL_SECRET_FIELDS[slot], "") + 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 "" @@ -282,7 +405,32 @@ class SettingsService: setattr(secret_row, field_name, encrypt_secret(normalized)) @staticmethod - def _serialize(settings_row: SystemSetting, secrets_row: SystemSettingSecret) -> SettingsRead: + def _apply_model_setting( + model_row: SystemModelSetting, + provider: str, + model_name: str, + endpoint: str, + api_key: str, + ) -> None: + model_row.provider = provider + model_row.model_name = model_name + model_row.endpoint = endpoint + + normalized_api_key = api_key.strip() + if normalized_api_key: + model_row.api_key_encrypted = encrypt_secret(normalized_api_key) + + @staticmethod + def _serialize( + settings_row: SystemSetting, + secrets_row: SystemSettingSecret, + model_rows: dict[str, SystemModelSetting], + ) -> SettingsRead: + main_model = model_rows["main"] + backup_model = model_rows["backup"] + vlm_model = model_rows["vlm"] + embedding_model = model_rows["embedding"] + return SettingsRead( companyForm={ "companyName": settings_row.company_name, @@ -304,26 +452,26 @@ class SettingsService: "adminPasswordConfigured": bool(secrets_row.admin_password_hash), }, llmForm={ - "mainProvider": settings_row.main_provider, - "mainModel": settings_row.main_model, - "mainEndpoint": settings_row.main_endpoint, + "mainProvider": main_model.provider, + "mainModel": main_model.model_name, + "mainEndpoint": main_model.endpoint, "mainApiKey": "", - "mainApiKeyConfigured": bool(secrets_row.main_api_key_encrypted), - "backupProvider": settings_row.backup_provider, - "backupModel": settings_row.backup_model, - "backupEndpoint": settings_row.backup_endpoint, + "mainApiKeyConfigured": bool(main_model.api_key_encrypted), + "backupProvider": backup_model.provider, + "backupModel": backup_model.model_name, + "backupEndpoint": backup_model.endpoint, "backupApiKey": "", - "backupApiKeyConfigured": bool(secrets_row.backup_api_key_encrypted), - "vlmProvider": settings_row.vlm_provider, - "vlmModel": settings_row.vlm_model, - "vlmEndpoint": settings_row.vlm_endpoint, + "backupApiKeyConfigured": bool(backup_model.api_key_encrypted), + "vlmProvider": vlm_model.provider, + "vlmModel": vlm_model.model_name, + "vlmEndpoint": vlm_model.endpoint, "vlmApiKey": "", - "vlmApiKeyConfigured": bool(secrets_row.vlm_api_key_encrypted), - "embeddingProvider": settings_row.embedding_provider, - "embeddingModel": settings_row.embedding_model, - "embeddingEndpoint": settings_row.embedding_endpoint, + "vlmApiKeyConfigured": bool(vlm_model.api_key_encrypted), + "embeddingProvider": embedding_model.provider, + "embeddingModel": embedding_model.model_name, + "embeddingEndpoint": embedding_model.endpoint, "embeddingApiKey": "", - "embeddingApiKeyConfigured": bool(secrets_row.embedding_api_key_encrypted), + "embeddingApiKeyConfigured": bool(embedding_model.api_key_encrypted), }, logForm={ "level": settings_row.log_level, diff --git a/server/tests/test_auth_service.py b/server/tests/test_auth_service.py index fa06bdf..0fff7d1 100644 --- a/server/tests/test_auth_service.py +++ b/server/tests/test_auth_service.py @@ -6,8 +6,10 @@ from sqlalchemy.pool import StaticPool from app.db.base import Base from app.schemas.auth import LoginRequest +from app.schemas.settings import SettingsWrite from app.services.auth import AuthService from app.services.employee import EmployeeService +from app.services.settings import SettingsService def build_session() -> Session: @@ -35,18 +37,14 @@ def test_employee_can_login_with_seed_default_password() -> None: assert result.user.isAdmin is False -def test_admin_can_login_with_secret(monkeypatch) -> None: +def test_admin_can_login_with_database_password() -> None: with build_session() as db: - monkeypatch.setattr( - "app.services.auth.read_admin_secret", - lambda: { - "username": "superadmin", - "algorithm": "scrypt", - "salt": "00", - "derived_key": "00", - }, - ) - monkeypatch.setattr("app.services.auth.verify_admin_secret", lambda password, record: password == "admin123") + settings_service = SettingsService(db) + payload = settings_service.get_settings_snapshot().model_dump() + payload["adminForm"]["adminAccount"] = "superadmin" + payload["adminForm"]["newPassword"] = "admin123" + payload["adminForm"]["confirmPassword"] = "admin123" + settings_service.save_settings_snapshot(SettingsWrite(**payload)) result = AuthService(db).login( LoginRequest(username="superadmin", password="admin123") diff --git a/server/tests/test_settings_persistence.py b/server/tests/test_settings_persistence.py index a3f31c6..7c8669a 100644 --- a/server/tests/test_settings_persistence.py +++ b/server/tests/test_settings_persistence.py @@ -1,13 +1,18 @@ 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 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 @@ -21,12 +26,13 @@ def build_session(db_file: Path) -> Session: ) SystemSetting.__table__.create(bind=engine) SystemSettingSecret.__table__.create(bind=engine) + SystemModelSetting.__table__.create(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) return session_factory() def build_temp_secret_dir() -> Path: - return Path(tempfile.mkdtemp(prefix="xf-settings-test-", dir="D:\\tmp")) + return Path(tempfile.mkdtemp(prefix="xf-settings-test-")) def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) -> None: @@ -61,6 +67,11 @@ def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) -> assert saved_snapshot.adminForm.newPassword == "" assert saved_snapshot.adminForm.adminPasswordConfigured is True + model_row = db.get(SystemModelSetting, "main") + assert model_row is not None + assert model_row.model_name == "glm-4.5" + assert model_row.api_key_encrypted + assert service.load_saved_model_api_key("main") == "main-secret" assert service.verify_admin_login("admin-root", "54321") is not None assert service.verify_admin_login("admin@example.com", "54321") is not None @@ -82,3 +93,40 @@ def test_blank_secret_input_does_not_clear_saved_secret(monkeypatch) -> None: service.save_settings_snapshot(SettingsWrite(**second_payload)) assert service.load_saved_model_api_key("main") == "persisted-key" + + +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) + + password = "setup-secret" + salt = secrets.token_bytes(16) + derived_key = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=64) + admin_file.write_text( + json.dumps( + { + "algorithm": "scrypt", + "username": "setup-admin", + "salt": salt.hex(), + "derived_key": derived_key.hex(), + "key_length": 64, + "N": 16384, + "r": 8, + "p": 1, + } + ), + encoding="utf-8", + ) + + with build_session(temp_dir / "settings.db") as db: + service = SettingsService(db) + snapshot = service.get_settings_snapshot() + secrets_row = db.get(SystemSettingSecret, "default") + + 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 diff --git a/web/src/assets/styles/views/settings-view.css b/web/src/assets/styles/views/settings-view.css index 3e949b5..89598a7 100644 --- a/web/src/assets/styles/views/settings-view.css +++ b/web/src/assets/styles/views/settings-view.css @@ -349,6 +349,21 @@ box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12); } +.secret-bound-state { + min-height: 24px; + display: inline-flex; + align-items: center; + gap: 7px; + color: #047857; + font-size: 12px; + font-weight: 750; + line-height: 1.45; +} + +.secret-bound-state i { + font-size: 15px; +} + .test-feedback { display: flex; align-items: flex-start; diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js index eb0169b..f50888b 100644 --- a/web/src/composables/useSystemState.js +++ b/web/src/composables/useSystemState.js @@ -331,6 +331,7 @@ export function installSessionNavigation(router) { fetchBootstrapState() .then((state) => { applyBootstrapState(state) + setRuntimeApiBaseUrl(resolveBrowserApiBaseUrl(state)) router.isReady().then(() => reconcileEntryRoute(router)) }) .catch(() => { diff --git a/web/src/services/api.js b/web/src/services/api.js index 22559a0..e18a1cc 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -4,18 +4,47 @@ function normalizeApiBaseUrl(value) { return String(value || '/api/v1').replace(/\/$/, '') } +function isLoopbackHost(hostname) { + const normalized = String(hostname || '').trim().toLowerCase() + return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '0.0.0.0' || normalized === '::1' +} + +function resolveBrowserReachableApiBaseUrl(value) { + const normalized = normalizeApiBaseUrl(value) + + if (typeof window === 'undefined') { + return normalized + } + + try { + const apiUrl = new URL(normalized) + const browserHost = window.location.hostname + + if (isLoopbackHost(apiUrl.hostname) && browserHost && !isLoopbackHost(browserHost)) { + apiUrl.hostname = browserHost + return normalizeApiBaseUrl(apiUrl.toString()) + } + } catch { + return normalized + } + + return normalized +} + function readStoredApiBaseUrl() { if (typeof window === 'undefined') { return '' } - return window.localStorage.getItem(API_BASE_STORAGE_KEY) || '' + return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '') } -let runtimeApiBaseUrl = normalizeApiBaseUrl(readStoredApiBaseUrl() || import.meta.env.VITE_API_BASE_URL || '/api/v1') +let runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl( + readStoredApiBaseUrl() || import.meta.env.VITE_API_BASE_URL || '/api/v1' +) export function setRuntimeApiBaseUrl(value) { - runtimeApiBaseUrl = normalizeApiBaseUrl(value) + runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value) if (typeof window !== 'undefined') { window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl) @@ -46,7 +75,7 @@ export async function apiRequest(path, options = {}) { ...options }) } catch { - throw new Error('无法连接后端员工服务,请确认 FastAPI 已启动。') + throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。') } let payload = null diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index fae3ee6..2c56bb6 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -223,8 +223,13 @@ v-model="pageState.llmForm.mainApiKey" type="password" autocomplete="off" + @focus="clearModelSecretMask('main')" :placeholder="pageState.llmForm.mainApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'" /> + + + 已从数据库加密加载,测试会使用已保存密钥。 +
所有必要步骤已通过检测,可以写入配置并进入登录界面。
++ + {{ progressMessage }} +
BACKEND STARTUP
+{{ startupLog || '等待后端启动输出...' }}
+