"""System-wide runtime settings editable from the admin UI. Backed by a single JSON file under the DeerFlow runtime home (default ``.deer-flow/system_settings.json``). Unlike :mod:`extensions_config` this file is **not** part of the project source tree — it is per-deployment state, and it stays writable at runtime. The only feature stored here at the moment is the **prompt prefix toggle** shown in the chat input: an administrator can configure a label and a prefix string, and any user can flip the toggle on/off; when enabled, the prefix is prepended to the first human message of a new thread. """ from __future__ import annotations import json import logging import threading from pathlib import Path from typing import Literal from uuid import uuid4 from pydantic import BaseModel, ConfigDict, Field from deerflow.config.runtime_paths import runtime_home logger = logging.getLogger(__name__) _SETTINGS_FILE = "system_settings.json" _lock = threading.Lock() class PromptPrefixSettings(BaseModel): """Admin-configurable first-message prompt prefix.""" enabled: bool = Field(default=False, description="Whether the prefix toggle is offered to users at all") switch_label: str = Field(default="加入立场前缀", description="Toggle label shown in the chat input") prompt_prefix: str = Field(default="", description="Text prepended to the first user message when enabled") model_config = ConfigDict(extra="ignore") class AppearanceRouteParam(BaseModel): """Runtime query-param descriptor for top-bar shortcut buttons. The frontend resolves login params from ``userInfo`` and theme params from the current UI theme, matching the light-app center's link builder. """ key: str = Field(default="", description="Query parameter name appended to the shortcut URL") paramType: Literal["login", "theme"] = Field(default="login", description="Where the parameter value comes from") source: str | None = Field(default=None, description="login param source field in userInfo, or 'other'") customValue: str | None = Field(default=None, description="Literal value when source is 'other'") themeValue: str | None = Field(default=None, description="Theme-value mapping string, e.g. red=red;dark-blue=dark") model_config = ConfigDict(extra="ignore") def _default_shortcut_route_params() -> list[AppearanceRouteParam]: return [ AppearanceRouteParam( key="access_token", paramType="login", source="access_token", customValue="", themeValue="red=red;blue=blue;dark-blue=dark-blue", ) ] class CompactShortcutButton(BaseModel): """Configurable shortcut shown in top bars before the theme switcher.""" id: str = Field(default_factory=lambda: uuid4().hex, description="Stable row id for editing") label: str = Field(default="快捷入口", description="Button text shown next to the icon") url: str = Field(default="", description="Target URL opened in a new tab") route_params: list[AppearanceRouteParam] = Field( default_factory=_default_shortcut_route_params, description="Query parameters resolved at launch time and appended to url", ) enabled: bool = Field(default=True, description="Whether this shortcut is rendered") sort_order: int = Field(default=0, description="Display order in top bars") model_config = ConfigDict(extra="ignore") def _default_compact_shortcut_buttons() -> list[CompactShortcutButton]: return [ CompactShortcutButton( id="default-compact-shortcut", label="快捷入口", url="", enabled=True, sort_order=0, ) ] class AppearanceSettings(BaseModel): """System-wide appearance defaults that any *new* user inherits. Once a user explicitly toggles their theme in the UI, that choice is persisted in the browser and supersedes this default — so changing ``default_theme`` here only affects users who have never touched the toggle, and any future first-time loads. """ default_theme: Literal["light", "dark", "lightblue"] = Field( default="light", description="Theme applied for users who haven't picked their own (light / dark / lightblue)", ) default_skill_view: Literal["card", "list"] = Field( default="list", description="Default skill list display mode for users who haven't picked their own (card / list)", ) default_agent_view: Literal["card", "list"] = Field( default="card", description="Default agent list display mode for users who haven't picked their own (card / list)", ) default_layout_mode: Literal["full", "compact"] = Field( default="full", description="Default interface layout for users who haven't picked their own — full (sidebar + header) / compact (hidden)", ) compact_shortcut_buttons: list[CompactShortcutButton] = Field( default_factory=_default_compact_shortcut_buttons, description="Shortcut buttons rendered before the theme switcher in top bars", ) model_config = ConfigDict(extra="ignore") class CitationDisplaySettings(BaseModel): """System-wide cleanup rules for citation cards and detail dialogs.""" cleanup_words: list[str] = Field( default_factory=list, description="Words or phrases used to remove noisy advertisement/disclaimer lines from citation display.", ) model_config = ConfigDict(extra="ignore") class PrivateSquareSettings(BaseModel): """Admin-configurable settings for the agent private-square (私密广场). The password gates read access to all agents flagged ``is_private=True``. The hash is stored opaquely — the router layer is responsible for hashing and verifying so the harness package keeps zero dependencies on ``app.gateway.auth``. """ enabled: bool = Field(default=False, description="Whether the private square is offered to users at all") password_hash: str = Field(default="", description="Opaque hash of the admin-configured password. Empty = no password set yet.") model_config = ConfigDict(extra="ignore") #: Id of the always-present built-in default square (used when the admin has #: configured no squares, or when a published item picks none). DEFAULT_SQUARE_ID = "default" class PublishSquare(BaseModel): """One admin-configured publish square (发布广场). A pure organizational category that published agents/skills can be filed under — unlike the password-gated :class:`PrivateSquareSettings`, a square has **no access control**: every logged-in user can browse every square. Each agent/skill belongs to at most one square (a single ``square_id`` field on the row), so an item can never be "published twice". """ id: str = Field(..., description="Stable square id (slug/uuid); used as agents.square_id / skills.square_id") name: str = Field(..., description="Display name shown as the square's tab label") description: str = Field(default="", description="Optional description shown in the admin config / tab hint") model_config = ConfigDict(extra="ignore") class PublishSquaresSettings(BaseModel): """Admin-configurable list of publish squares. ``squares`` is display-ordered. ``default_square_id`` selects the square a publisher lands in when they do not pick one explicitly. There is always at least one effective square — :func:`list_publish_squares` injects a built-in "默认广场" when the admin has configured none, so the publish flow always has a valid target. """ squares: list[PublishSquare] = Field(default_factory=list, description="Ordered list of configured squares") default_square_id: str = Field(default=DEFAULT_SQUARE_ID, description="Square id used when a publisher picks none") model_config = ConfigDict(extra="ignore") def list_publish_squares(settings: PublishSquaresSettings) -> list[PublishSquare]: """Return the effective square list, always non-empty. Falls back to a single built-in default square when the admin has not configured any, so callers never have to special-case the empty state. """ if settings.squares: return list(settings.squares) return [PublishSquare(id=DEFAULT_SQUARE_ID, name="默认广场", description="")] def resolve_default_square_id(settings: PublishSquaresSettings) -> str: """Resolve the effective default square id against the configured set.""" ids = [s.id for s in settings.squares] if settings.default_square_id and settings.default_square_id in ids: return settings.default_square_id return ids[0] if ids else DEFAULT_SQUARE_ID def normalize_square_id(square_id: str | None, settings: PublishSquaresSettings) -> str: """Coerce an arbitrary ``square_id`` to a valid configured square id. Empty / unknown / orphaned (square later deleted) ids all collapse to the effective default square, so a published item always resolves to a real tab. """ default_id = resolve_default_square_id(settings) if not square_id: return default_id valid = {s.id for s in list_publish_squares(settings)} return square_id if square_id in valid else default_id class LeaderboardSettings(BaseModel): """Admin-configurable defaults for the user leaderboard analytics.""" exclude_user_ids: list[str] = Field(default_factory=list, description="User ids excluded from leaderboard aggregation.") include_scheduled: bool = Field(default=True, description="Whether scheduled task runs participate in leaderboard statistics.") include_admins: bool = Field(default=True, description="Whether admin accounts participate in leaderboard statistics.") include_failed: bool = Field(default=True, description="Whether failed runs and provider calls participate in leaderboard statistics.") cleanup_system_questions: bool = Field(default=True, description="Hide attachment/system marker questions such as .") model_config = ConfigDict(extra="ignore") class SkillCompressionSettings(BaseModel): """Admin-configurable skill compression (技能压缩). When ``enabled``, only skills explicitly marked 常驻(免压缩) (``always_on``) are injected full-text into the lead agent's system prompt. Every other enabled skill is "compressed": kept out of the prompt body and surfaced to the agent on demand via the ``search_skills`` keyword-retrieval tool (and a compact name index when ``keep_index_in_prompt`` is true). When disabled the behaviour is unchanged — every enabled skill is injected full-text. """ enabled: bool = Field(default=False, description="Whether skill compression is active (master switch)") search_top_n: int = Field(default=5, ge=1, le=50, description="Max skills returned by the search_skills tool") keep_index_in_prompt: bool = Field( default=True, description="Whether compressed skills still appear as a compact name-only index in the prompt", ) model_config = ConfigDict(extra="ignore") class AgentModuleSettings(BaseModel): """Admin-configured「按类型聚合」module → selected agent-tag ids. Keyed by module key (e.g. ``"zzjc"`` / ``"zzxd"``). Each value is the list of agent **tag ids** whose agents that module's left sidebar shows. A missing key / empty list means "not configured" — the frontend falls back to showing all visible agents grouped by tag. """ modules: dict[str, list[str]] = Field( default_factory=dict, description="module key → selected agent tag ids", ) model_config = ConfigDict(extra="ignore") class PluginDomainAgentMapping(BaseModel): """One configured host -> agent mapping for iframe chat auto-selection.""" id: str = Field(..., description="Stable row id for client-side editing") domain: str = Field(..., description="Normalized host matched against pageContext.url") agent_id: str = Field(..., description="Agent id selected when the host matches") enabled: bool = Field(default=True, description="Whether this mapping is active") model_config = ConfigDict(extra="ignore") class PluginManagementSettings(BaseModel): """Admin-configurable iframe plugin mappings.""" domain_agent_mappings: list[PluginDomainAgentMapping] = Field( default_factory=list, description="Ordered host -> agent mappings used by the iframe chat entry page.", ) model_config = ConfigDict(extra="ignore") def get_module_tag_ids(settings: AgentModuleSettings, key: str) -> list[str]: """Return the configured agent-tag ids for ``key`` (empty when unset).""" return list(settings.modules.get(key, [])) def set_module_tag_ids( settings: AgentModuleSettings, key: str, tag_ids: list[str] ) -> AgentModuleSettings: """Return a copy with ``key`` set to a de-duplicated, stripped tag-id list.""" cleaned = list(dict.fromkeys([str(t).strip() for t in tag_ids if str(t).strip()])) modules = dict(settings.modules) modules[key] = cleaned return settings.model_copy(update={"modules": modules}) class LoginWhitelistSettings(BaseModel): """登录白名单总开关(管理员可在「白名单管理」页切换,即时生效、免重启)。 黑名单模式:``enabled`` 关(默认) → 忽略每个用户的 ``approved`` 放行标记, 人人可登录;开 → 仅被管理员显式「取消放行」(``approved=False``) 的用户被 403, 新账号默认 ``approved=True`` 故从未登录过的用户照样放行,admin 永远放行。 空/默认即关,故部署后行为零变化。 """ enabled: bool = Field(default=False, description="是否启用登录白名单;关=所有人可登录,开=仅已放行用户(或 admin)可登录") model_config = ConfigDict(extra="ignore") class ConcurrencyMonitorSettings(BaseModel): """管理员「实时并发监控」开关(排行榜页的系统压力监控)。 ``enabled`` 控制**后台并发采样器**是否持续把当前并发调用数写库(供并发量 折线图按分钟/小时聚合)。**默认关**(opt-in,由管理员在排行榜页手动开启), 以对线上服务零影响为先;开启后采样器才开始写库,关闭则立即停止(每 tick 都 重读该开关,免重启),实时快照查询与「停止」始终可用、零常驻成本。设计上即便 开启也对线上零影响:采样仅一条极小 INSERT、有保留期清理、聚合查询有行数上限、 全程 best-effort。 """ enabled: bool = Field(default=False, description="是否启用后台并发采样(默认关;开→开始写库,关→停止写库、折线图不再新增历史点)") model_config = ConfigDict(extra="ignore") class ModelConcurrencySettings(BaseModel): """Admin-configurable per-model concurrent Q&A limits.""" enabled: bool = Field( default=False, description="Whether per-model concurrency limiting is enabled.", ) default_limit: int = Field( default=10, ge=1, le=10_000, description="Fallback max concurrent runs for models without an explicit override.", ) model_limits: dict[str, int] = Field( default_factory=dict, description="Optional per-model overrides: model_name -> max concurrent runs.", ) model_config = ConfigDict(extra="ignore") class SystemSettings(BaseModel): """Top-level system settings document.""" prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings) login_whitelist: LoginWhitelistSettings = Field(default_factory=LoginWhitelistSettings) private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings) publish_squares: PublishSquaresSettings = Field(default_factory=PublishSquaresSettings) appearance: AppearanceSettings = Field(default_factory=AppearanceSettings) citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings) leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings) skill_compression: SkillCompressionSettings = Field(default_factory=SkillCompressionSettings) plugin_management: PluginManagementSettings = Field(default_factory=PluginManagementSettings) agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings) concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings) model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings) model_config = ConfigDict(extra="ignore") def _settings_path() -> Path: home = runtime_home() home.mkdir(parents=True, exist_ok=True) return home / _SETTINGS_FILE def load_system_settings() -> SystemSettings: """Read the on-disk system settings, returning defaults when missing.""" path = _settings_path() if not path.is_file(): return SystemSettings() try: raw = json.loads(path.read_text(encoding="utf-8")) if not isinstance(raw, dict): logger.warning("System settings file %s is not a JSON object; ignoring", path) return SystemSettings() return SystemSettings.model_validate(raw) except Exception as exc: logger.warning("Failed to read system settings from %s: %s", path, exc) return SystemSettings() def get_skill_compression_settings() -> SkillCompressionSettings: """Convenience accessor for the skill-compression block (safe defaults on error).""" try: return load_system_settings().skill_compression except Exception: logger.debug("Failed to read skill compression settings; using defaults", exc_info=True) return SkillCompressionSettings() def get_concurrency_monitor_settings() -> ConcurrencyMonitorSettings: """Convenience accessor for the concurrency-monitor block (safe defaults on error).""" try: return load_system_settings().concurrency_monitor except Exception: logger.debug("Failed to read concurrency monitor settings; using defaults", exc_info=True) return ConcurrencyMonitorSettings() def get_model_concurrency_settings() -> ModelConcurrencySettings: """Convenience accessor for the model-concurrency block (safe defaults on error).""" try: return load_system_settings().model_concurrency except Exception: logger.debug("Failed to read model concurrency settings; using defaults", exc_info=True) return ModelConcurrencySettings() def save_system_settings(settings: SystemSettings) -> SystemSettings: """Persist the settings document atomically, returning the saved value.""" path = _settings_path() payload = settings.model_dump(mode="json") with _lock: tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") tmp.replace(path) return settings