"""Admin-configurable system-wide runtime settings.""" from __future__ import annotations import logging from typing import Literal from urllib.parse import urlparse from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field from app.gateway.auth.password import hash_password_async from app.gateway.deps import get_optional_user_from_request from deerflow.config import get_app_config from deerflow.config.system_settings import ( LeaderboardSettings, ModelConcurrencySettings, PluginDomainAgentMapping, PluginManagementSettings, PromptPrefixSettings, PublishSquare, PublishSquaresSettings, SkillCompressionSettings, get_module_tag_ids, list_publish_squares, load_system_settings, resolve_default_square_id, save_system_settings, set_module_tag_ids, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/system-settings", tags=["system-settings"]) class PromptPrefixResponse(BaseModel): enabled: bool = Field(..., description="Whether the toggle is exposed to users") switch_label: str = Field(..., description="Toggle label shown in the chat input") prompt_prefix: str = Field(..., description="Prefix text prepended to the first user message") class PromptPrefixUpdateRequest(BaseModel): enabled: bool | None = None switch_label: str | None = None prompt_prefix: str | None = None class PluginDomainAgentMappingItem(BaseModel): id: str = Field(..., description="Stable row id") domain: str = Field(..., description="Normalized host matched against pageContext.url") agent_id: str = Field(..., description="Agent id selected for the host") enabled: bool = Field(default=True, description="Whether the mapping is active") class PluginManagementResponse(BaseModel): domain_agent_mappings: list[PluginDomainAgentMappingItem] = Field(default_factory=list) class PluginManagementUpdateRequest(BaseModel): domain_agent_mappings: list[PluginDomainAgentMappingItem] | None = None class PrivateSquareStatusResponse(BaseModel): """Status of the private-square feature, readable by any logged-in user. The password hash itself is **never** returned — the response only signals whether the feature is enabled and whether a password has been set. """ enabled: bool = Field(..., description="Whether the private square is exposed to users") password_set: bool = Field(..., description="Whether a password is currently configured") class PrivateSquareUpdateRequest(BaseModel): enabled: bool | None = Field(default=None, description="Toggle the private-square feature") password: str | None = Field(default=None, description="New password in plain text. Pass an empty string to clear.") class CitationDisplayResponse(BaseModel): cleanup_words: list[str] = Field(default_factory=list, description="Global citation cleanup words") class CitationDisplayUpdateRequest(BaseModel): cleanup_words: list[str] | None = None class LeaderboardSettingsResponse(BaseModel): exclude_user_ids: list[str] = Field(default_factory=list) include_scheduled: bool = True include_admins: bool = True include_failed: bool = True cleanup_system_questions: bool = True class LeaderboardSettingsUpdateRequest(BaseModel): exclude_user_ids: list[str] | None = None include_scheduled: bool | None = None include_admins: bool | None = None include_failed: bool | None = None cleanup_system_questions: bool | None = None def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse: return PromptPrefixResponse( enabled=settings.enabled, switch_label=settings.switch_label, prompt_prefix=settings.prompt_prefix, ) def _normalize_plugin_domain(value: str | None) -> str: raw = (value or "").strip() if not raw: return "" candidate = raw if "://" in raw else f"https://{raw}" try: parsed = urlparse(candidate) except Exception: return "" return (parsed.netloc or parsed.path or "").strip().lower() def _plugin_management_to_response( settings: PluginManagementSettings, ) -> PluginManagementResponse: return PluginManagementResponse( domain_agent_mappings=[ PluginDomainAgentMappingItem( id=item.id, domain=item.domain, agent_id=item.agent_id, enabled=item.enabled, ) for item in settings.domain_agent_mappings ] ) async def _require_admin(request: Request) -> None: user = await get_optional_user_from_request(request) if user is None: return if getattr(user, "system_role", None) != "admin": raise HTTPException(status_code=403, detail="System settings can only be modified by an admin") @router.get("/prompt-prefix", response_model=PromptPrefixResponse) async def get_prompt_prefix() -> PromptPrefixResponse: """Return the current prompt-prefix configuration (readable by any user).""" return _to_response(load_system_settings().prompt_prefix) @router.put("/prompt-prefix", response_model=PromptPrefixResponse) async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request) -> PromptPrefixResponse: """Update the prompt-prefix configuration. Admin only.""" await _require_admin(request) current = load_system_settings() fields = body.model_dump(exclude_unset=True) updated_prefix = current.prompt_prefix.model_copy(update=fields) saved = save_system_settings(current.model_copy(update={"prompt_prefix": updated_prefix})) return _to_response(saved.prompt_prefix) @router.get("/plugin-management", response_model=PluginManagementResponse) async def get_plugin_management() -> PluginManagementResponse: """Return the iframe plugin domain -> agent mappings.""" return _plugin_management_to_response(load_system_settings().plugin_management) @router.put("/plugin-management", response_model=PluginManagementResponse) async def update_plugin_management( body: PluginManagementUpdateRequest, request: Request ) -> PluginManagementResponse: """Replace the iframe plugin domain -> agent mappings. Admin only.""" from uuid import uuid4 await _require_admin(request) current = load_system_settings() mappings: list[PluginDomainAgentMapping] = [] seen_domains: set[str] = set() for item in body.domain_agent_mappings or []: domain = _normalize_plugin_domain(item.domain) agent_id = (item.agent_id or "").strip() if not domain or not agent_id: continue if domain in seen_domains: continue seen_domains.add(domain) mappings.append( PluginDomainAgentMapping( id=(item.id or "").strip() or uuid4().hex, domain=domain, agent_id=agent_id, enabled=bool(item.enabled), ) ) updated = current.plugin_management.model_copy( update={"domain_agent_mappings": mappings} ) saved = save_system_settings( current.model_copy(update={"plugin_management": updated}) ) return _plugin_management_to_response(saved.plugin_management) class SkillCompressionResponse(BaseModel): enabled: bool = Field(..., description="Whether skill compression is active") search_top_n: int = Field(..., description="Max skills returned by the search_skills tool") keep_index_in_prompt: bool = Field(..., description="Whether compressed skills keep a compact name index in the prompt") class SkillCompressionUpdateRequest(BaseModel): enabled: bool | None = None search_top_n: int | None = Field(default=None, ge=1, le=50) keep_index_in_prompt: bool | None = None class ModelConcurrencyResponse(BaseModel): enabled: bool = Field(..., description="Whether per-model concurrency limiting is active") default_limit: int = Field(..., description="Fallback max concurrent runs for models without an explicit override") model_limits: dict[str, int] = Field(default_factory=dict, description="Per-model overrides: model_name -> max concurrent runs") user_default_limit: int = Field(..., description="Fallback max concurrent runs per user and model. 0 disables the per-user fallback") user_model_limits: dict[str, int] = Field(default_factory=dict, description="Per-user per-model overrides: model_name -> max concurrent runs for one user") class ModelConcurrencyUpdateRequest(BaseModel): enabled: bool | None = None default_limit: int | None = Field(default=None, ge=1, le=10_000) model_limits: dict[str, int] | None = None user_default_limit: int | None = Field(default=None, ge=0, le=10_000) user_model_limits: dict[str, int] | None = None def _skill_compression_to_response(settings: SkillCompressionSettings) -> SkillCompressionResponse: return SkillCompressionResponse( enabled=settings.enabled, search_top_n=settings.search_top_n, keep_index_in_prompt=settings.keep_index_in_prompt, ) def _model_concurrency_to_response(settings: ModelConcurrencySettings) -> ModelConcurrencyResponse: cleaned: dict[str, int] = {} for name, limit in (settings.model_limits or {}).items(): model_name = str(name).strip() if not model_name: continue try: normalized_limit = int(limit) except (TypeError, ValueError): continue if normalized_limit < 1: continue cleaned[model_name] = min(normalized_limit, 10_000) cleaned_user: dict[str, int] = {} for name, limit in (settings.user_model_limits or {}).items(): model_name = str(name).strip() if not model_name: continue try: normalized_limit = int(limit) except (TypeError, ValueError): continue if normalized_limit < 1: continue cleaned_user[model_name] = min(normalized_limit, 10_000) return ModelConcurrencyResponse( enabled=settings.enabled, default_limit=max(1, min(int(settings.default_limit), 10_000)), model_limits=cleaned, user_default_limit=max(0, min(int(getattr(settings, "user_default_limit", 0) or 0), 10_000)), user_model_limits=cleaned_user, ) @router.get("/skill-compression", response_model=SkillCompressionResponse) async def get_skill_compression() -> SkillCompressionResponse: """Return the skill-compression configuration (readable by any user). The frontend reads this to decide whether to surface the per-skill 常驻 (免压缩) toggle on the skill detail page. """ return _skill_compression_to_response(load_system_settings().skill_compression) @router.put("/skill-compression", response_model=SkillCompressionResponse) async def update_skill_compression(body: SkillCompressionUpdateRequest, request: Request) -> SkillCompressionResponse: """Update the skill-compression configuration. Admin only.""" await _require_admin(request) current = load_system_settings() fields = body.model_dump(exclude_unset=True) update: dict[str, object] = {} if "enabled" in fields and fields["enabled"] is not None: update["enabled"] = bool(fields["enabled"]) if "keep_index_in_prompt" in fields and fields["keep_index_in_prompt"] is not None: update["keep_index_in_prompt"] = bool(fields["keep_index_in_prompt"]) if "search_top_n" in fields and fields["search_top_n"] is not None: update["search_top_n"] = max(1, min(int(fields["search_top_n"]), 50)) compression = current.skill_compression.model_copy(update=update) saved = save_system_settings(current.model_copy(update={"skill_compression": compression})) # 压缩开关/参数变化会改变系统提示词的技能注入,清掉提示词缓存让其立即生效。 try: from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async await refresh_skills_system_prompt_cache_async() except Exception: logger.debug("Failed to refresh skills prompt cache after compression update", exc_info=True) return _skill_compression_to_response(saved.skill_compression) @router.get("/model-concurrency", response_model=ModelConcurrencyResponse) async def get_model_concurrency(request: Request) -> ModelConcurrencyResponse: """Return the per-model concurrency-limit configuration. Admin only.""" await _require_admin(request) return _model_concurrency_to_response(load_system_settings().model_concurrency) @router.put("/model-concurrency", response_model=ModelConcurrencyResponse) async def update_model_concurrency( body: ModelConcurrencyUpdateRequest, request: Request, ) -> ModelConcurrencyResponse: """Update the per-model concurrency-limit configuration. Admin only.""" await _require_admin(request) current = load_system_settings() fields = body.model_dump(exclude_unset=True) update: dict[str, object] = {} if "enabled" in fields and fields["enabled"] is not None: update["enabled"] = bool(fields["enabled"]) if "default_limit" in fields and fields["default_limit"] is not None: update["default_limit"] = max(1, min(int(fields["default_limit"]), 10_000)) if "model_limits" in fields and fields["model_limits"] is not None: cleaned: dict[str, int] = {} for name, limit in (body.model_limits or {}).items(): model_name = str(name).strip() if not model_name: continue try: normalized_limit = int(limit) except (TypeError, ValueError): continue if normalized_limit < 1: continue cleaned[model_name] = min(normalized_limit, 10_000) update["model_limits"] = cleaned if "user_default_limit" in fields and fields["user_default_limit"] is not None: update["user_default_limit"] = max(0, min(int(fields["user_default_limit"]), 10_000)) if "user_model_limits" in fields and fields["user_model_limits"] is not None: cleaned_user: dict[str, int] = {} for name, limit in (body.user_model_limits or {}).items(): model_name = str(name).strip() if not model_name: continue try: normalized_limit = int(limit) except (TypeError, ValueError): continue if normalized_limit < 1: continue cleaned_user[model_name] = min(normalized_limit, 10_000) update["user_model_limits"] = cleaned_user settings = current.model_concurrency.model_copy(update=update) saved = save_system_settings(current.model_copy(update={"model_concurrency": settings})) return _model_concurrency_to_response(saved.model_concurrency) def _leaderboard_to_response(settings: LeaderboardSettings) -> LeaderboardSettingsResponse: return LeaderboardSettingsResponse( exclude_user_ids=list(dict.fromkeys([uid for uid in settings.exclude_user_ids if uid])), include_scheduled=settings.include_scheduled, include_admins=settings.include_admins, include_failed=settings.include_failed, cleanup_system_questions=settings.cleanup_system_questions, ) @router.get("/leaderboard", response_model=LeaderboardSettingsResponse) async def get_leaderboard_settings(request: Request) -> LeaderboardSettingsResponse: """Return global leaderboard aggregation settings. Admin only.""" await _require_admin(request) return _leaderboard_to_response(load_system_settings().leaderboard) @router.put("/leaderboard", response_model=LeaderboardSettingsResponse) async def update_leaderboard_settings( body: LeaderboardSettingsUpdateRequest, request: Request, ) -> LeaderboardSettingsResponse: """Update global leaderboard aggregation settings. Admin only.""" await _require_admin(request) current = load_system_settings() fields = body.model_dump(exclude_unset=True) update: dict[str, object] = {} if "exclude_user_ids" in fields and fields["exclude_user_ids"] is not None: update["exclude_user_ids"] = list(dict.fromkeys([str(uid).strip() for uid in fields["exclude_user_ids"] if str(uid).strip()])) for key in ("include_scheduled", "include_admins", "include_failed", "cleanup_system_questions"): if key in fields and fields[key] is not None: update[key] = bool(fields[key]) leaderboard = current.leaderboard.model_copy(update=update) saved = save_system_settings(current.model_copy(update={"leaderboard": leaderboard})) return _leaderboard_to_response(saved.leaderboard) @router.get("/citation-display", response_model=CitationDisplayResponse) async def get_citation_display() -> CitationDisplayResponse: """Return global citation display cleanup settings.""" settings = load_system_settings().citation_display return CitationDisplayResponse(cleanup_words=settings.cleanup_words) @router.put("/citation-display", response_model=CitationDisplayResponse) async def update_citation_display( body: CitationDisplayUpdateRequest, request: Request, ) -> CitationDisplayResponse: """Update global citation display cleanup settings. Admin only.""" await _require_admin(request) current = load_system_settings() words = [ str(item).strip() for item in (body.cleanup_words or []) if str(item).strip() ] updated = current.citation_display.model_copy(update={"cleanup_words": words}) saved = save_system_settings( current.model_copy(update={"citation_display": updated}) ) return CitationDisplayResponse(cleanup_words=saved.citation_display.cleanup_words) @router.get("/private-square", response_model=PrivateSquareStatusResponse) async def get_private_square_status() -> PrivateSquareStatusResponse: """Return whether the private square is enabled and a password is set.""" settings = load_system_settings().private_square return PrivateSquareStatusResponse( enabled=settings.enabled, password_set=bool(settings.password_hash), ) @router.put("/private-square", response_model=PrivateSquareStatusResponse) async def update_private_square(body: PrivateSquareUpdateRequest, request: Request) -> PrivateSquareStatusResponse: """Update the private-square configuration. Admin only. Setting ``password`` to a non-empty string rehashes and stores it. Passing an empty string clears the password (effectively locking the private square so no one can read it). """ await _require_admin(request) current = load_system_settings() updated = current.private_square fields = body.model_dump(exclude_unset=True) update: dict[str, object] = {} if "enabled" in fields and fields["enabled"] is not None: update["enabled"] = bool(fields["enabled"]) if "password" in fields and fields["password"] is not None: plain = str(fields["password"]) if plain == "": update["password_hash"] = "" else: update["password_hash"] = await hash_password_async(plain) if update: updated = updated.model_copy(update=update) saved = save_system_settings(current.model_copy(update={"private_square": updated})) else: saved = current return PrivateSquareStatusResponse( enabled=saved.private_square.enabled, password_set=bool(saved.private_square.password_hash), ) class PublishSquareItem(BaseModel): id: str = Field(..., description="Stable square id") name: str = Field(..., description="Display name / tab label") description: str = Field(default="", description="Optional description") class PublishSquaresResponse(BaseModel): """The configured publish squares plus the resolved default. Readable by any logged-in user — squares are pure category tabs with no access control, so the gallery needs the list to render its sub-tabs and the publish flow needs it to populate the square picker. """ squares: list[PublishSquareItem] = Field(default_factory=list) default_square_id: str = Field(..., description="Square id used when a publisher picks none") class PublishSquaresUpdateRequest(BaseModel): squares: list[PublishSquareItem] | None = Field(default=None, description="Full ordered replacement list of squares") default_square_id: str | None = Field(default=None, description="Which square id is the publish default") def _publish_squares_response(settings: PublishSquaresSettings) -> PublishSquaresResponse: squares = list_publish_squares(settings) return PublishSquaresResponse( squares=[PublishSquareItem(id=s.id, name=s.name, description=s.description) for s in squares], default_square_id=resolve_default_square_id(settings), ) @router.get("/publish-squares", response_model=PublishSquaresResponse) async def get_publish_squares() -> PublishSquaresResponse: """Return the configured publish squares (readable by any logged-in user).""" return _publish_squares_response(load_system_settings().publish_squares) @router.put("/publish-squares", response_model=PublishSquaresResponse) async def update_publish_squares(body: PublishSquaresUpdateRequest, request: Request) -> PublishSquaresResponse: """Replace the publish-squares configuration. Admin only. ``squares`` is a full ordered replacement. Each square needs a non-empty name; a missing id is auto-generated from a slug of the name. Duplicate ids are de-duplicated (first wins). ``default_square_id`` must reference one of the surviving squares, else it falls back to the first. """ from uuid import uuid4 await _require_admin(request) current = load_system_settings() fields = body.model_dump(exclude_unset=True) update: dict[str, object] = {} if "squares" in fields and fields["squares"] is not None: normalized: list[PublishSquare] = [] seen_ids: set[str] = set() for raw in body.squares or []: name = (raw.name or "").strip() if not name: continue sid = (raw.id or "").strip() or uuid4().hex[:8] if sid in seen_ids: continue seen_ids.add(sid) normalized.append(PublishSquare(id=sid, name=name, description=(raw.description or "").strip())) update["squares"] = normalized if "default_square_id" in fields and fields["default_square_id"] is not None: update["default_square_id"] = str(fields["default_square_id"]).strip() if update: updated = current.publish_squares.model_copy(update=update) # Re-anchor the default to a surviving square id. if updated.squares and updated.default_square_id not in {s.id for s in updated.squares}: updated = updated.model_copy(update={"default_square_id": updated.squares[0].id}) saved = save_system_settings(current.model_copy(update={"publish_squares": updated})) else: saved = current return _publish_squares_response(saved.publish_squares) class AppearanceResponse(BaseModel): """Effective system-wide appearance defaults.""" default_theme: Literal["light", "dark", "lightblue"] = Field(..., description="System default theme — light, dark or lightblue") default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list") default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list") default_layout_mode: Literal["full", "compact"] = Field(..., description="System default interface layout — full (sidebar + header) or compact (hidden)") class AppearanceUpdateRequest(BaseModel): default_theme: Literal["light", "dark", "lightblue"] | None = Field(default=None) default_skill_view: Literal["card", "list"] | None = Field(default=None) default_agent_view: Literal["card", "list"] | None = Field(default=None) default_layout_mode: Literal["full", "compact"] | None = Field(default=None) def _appearance_response(appearance) -> AppearanceResponse: return AppearanceResponse( default_theme=appearance.default_theme, default_skill_view=appearance.default_skill_view, default_agent_view=appearance.default_agent_view, default_layout_mode=appearance.default_layout_mode, ) @router.get("/appearance", response_model=AppearanceResponse) async def get_appearance_settings() -> AppearanceResponse: """Return the global appearance defaults. Any logged-in user may read this.""" return _appearance_response(load_system_settings().appearance) @router.put("/appearance", response_model=AppearanceResponse) async def update_appearance_settings( body: AppearanceUpdateRequest, request: Request ) -> AppearanceResponse: """Update the global appearance defaults. Admin only.""" await _require_admin(request) current = load_system_settings() fields = body.model_dump(exclude_unset=True) update: dict[str, object] = {} for key in ("default_theme", "default_skill_view", "default_agent_view", "default_layout_mode"): if key in fields and fields[key] is not None: update[key] = fields[key] if update: updated_appearance = current.appearance.model_copy(update=update) saved = save_system_settings( current.model_copy(update={"appearance": updated_appearance}) ) else: saved = current return _appearance_response(saved.appearance) class LogSystemResponse(BaseModel): """External log-system entry shown in the admin settings menu. Sourced from ``config.yaml`` (``log_system`` section), so it is read-only here — there is no PUT counterpart. """ enabled: bool = Field(..., description="Whether the entry is shown in the settings menu") url: str = Field(..., description="External log-system URL opened in a new tab") label: str = Field(..., description="Menu label shown for the entry") @router.get("/log-system", response_model=LogSystemResponse) async def get_log_system(request: Request) -> LogSystemResponse: """Return the admin-configured external log-system link from config.yaml. Admin only.""" await _require_admin(request) cfg = get_app_config().log_system return LogSystemResponse(enabled=cfg.enabled, url=cfg.url, label=cfg.label) class AgentModuleConfigResponse(BaseModel): """Selected agent-tag ids for a「按类型聚合」module (zzJC / zzXD).""" key: str = Field(..., description="Module key, e.g. zzjc / zzxd") tag_ids: list[str] = Field(default_factory=list, description="Selected agent tag ids") class AgentModuleConfigUpdateRequest(BaseModel): tag_ids: list[str] = Field(default_factory=list, description="Full replacement list of selected agent tag ids") class LoginWhitelistResponse(BaseModel): enabled: bool = Field(..., description="是否启用登录白名单(关=所有人可登录,开=仅已放行用户/admin 可登录)") class LoginWhitelistUpdateRequest(BaseModel): enabled: bool | None = None @router.get("/login-whitelist", response_model=LoginWhitelistResponse) async def get_login_whitelist(request: Request) -> LoginWhitelistResponse: """Return the login-whitelist master switch. Admin only (它驱动谁能登录,不公开).""" await _require_admin(request) return LoginWhitelistResponse(enabled=load_system_settings().login_whitelist.enabled) @router.put("/login-whitelist", response_model=LoginWhitelistResponse) async def update_login_whitelist(body: LoginWhitelistUpdateRequest, request: Request) -> LoginWhitelistResponse: """Toggle the login-whitelist master switch. Admin only.""" await _require_admin(request) current = load_system_settings() update: dict[str, object] = {} fields = body.model_dump(exclude_unset=True) if "enabled" in fields and fields["enabled"] is not None: update["enabled"] = bool(fields["enabled"]) updated = current.login_whitelist.model_copy(update=update) saved = save_system_settings(current.model_copy(update={"login_whitelist": updated})) return LoginWhitelistResponse(enabled=saved.login_whitelist.enabled) @router.get("/agent-modules/{key}", response_model=AgentModuleConfigResponse) async def get_agent_module_config(key: str) -> AgentModuleConfigResponse: """Return the agent tag ids configured for ``key`` (readable by any user — the module's left sidebar needs it to filter the agent list).""" settings = load_system_settings().agent_modules return AgentModuleConfigResponse(key=key, tag_ids=get_module_tag_ids(settings, key)) @router.put("/agent-modules/{key}", response_model=AgentModuleConfigResponse) async def update_agent_module_config( key: str, body: AgentModuleConfigUpdateRequest, request: Request ) -> AgentModuleConfigResponse: """Replace the agent tag ids configured for ``key``. Admin only.""" await _require_admin(request) current = load_system_settings() updated = set_module_tag_ids(current.agent_modules, key, body.tag_ids) saved = save_system_settings(current.model_copy(update={"agent_modules": updated})) return AgentModuleConfigResponse(key=key, tag_ids=get_module_tag_ids(saved.agent_modules, key))