deerflow-code/offline-backend-20260512/backend/app/gateway/routers/system_settings.py
2026-09-07 18:24:55 +08:00

860 lines
36 KiB
Python

"""Admin-configurable system-wide runtime settings."""
from __future__ import annotations
import logging
from typing import Literal
from urllib.parse import urlparse
from uuid import uuid4
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 (
BUILTIN_THEME_IDS,
AppearanceRouteParam,
CompactShortcutButton,
CustomThemePreset,
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")
ordinary_qa_markdown_format_enabled: bool = Field(
default=False,
description="Whether formal Markdown heading rules are injected into ordinary Q&A system prompts",
)
class KnowledgeBaseProductVersionResponse(BaseModel):
version: Literal["v1", "v2"] = "v1"
class KnowledgeBaseProductVersionUpdateRequest(BaseModel):
version: Literal["v1", "v2"]
class OrdinaryKnowledgeLanding(BaseModel):
mode: Literal["wiki", "weknora"] = "wiki"
@router.get("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
async def get_ordinary_knowledge_landing() -> OrdinaryKnowledgeLanding:
return OrdinaryKnowledgeLanding(mode=load_system_settings().ordinary_knowledge_landing)
@router.put("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
async def update_ordinary_knowledge_landing(body: OrdinaryKnowledgeLanding, request: Request) -> OrdinaryKnowledgeLanding:
await _require_admin(request)
saved = save_system_settings(load_system_settings().model_copy(update={"ordinary_knowledge_landing": body.mode}))
return OrdinaryKnowledgeLanding(mode=saved.ordinary_knowledge_landing)
class PromptPrefixUpdateRequest(BaseModel):
enabled: bool | None = None
switch_label: str | None = None
prompt_prefix: str | None = None
ordinary_qa_markdown_format_enabled: bool | 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):
reference_mode_enabled: bool = Field(default=False, description="Whether reference-mode entry points are exposed to users")
cleanup_words: list[str] = Field(default_factory=list, description="Global citation cleanup words")
class CitationDisplayUpdateRequest(BaseModel):
reference_mode_enabled: bool | None = None
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,
ordinary_qa_markdown_format_enabled=settings.ordinary_qa_markdown_format_enabled,
)
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("/knowledge-base-product-version", response_model=KnowledgeBaseProductVersionResponse)
async def get_knowledge_base_product_version() -> KnowledgeBaseProductVersionResponse:
return KnowledgeBaseProductVersionResponse(version=load_system_settings().knowledge_base_product_version)
@router.put("/knowledge-base-product-version", response_model=KnowledgeBaseProductVersionResponse)
async def update_knowledge_base_product_version(
body: KnowledgeBaseProductVersionUpdateRequest,
request: Request,
) -> KnowledgeBaseProductVersionResponse:
await _require_admin(request)
saved = save_system_settings(load_system_settings().model_copy(update={"knowledge_base_product_version": body.version}))
return KnowledgeBaseProductVersionResponse(version=saved.knowledge_base_product_version)
@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 settings."""
settings = load_system_settings().citation_display
return CitationDisplayResponse(
reference_mode_enabled=settings.reference_mode_enabled,
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 settings. Admin only."""
await _require_admin(request)
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
if "reference_mode_enabled" in fields and fields["reference_mode_enabled"] is not None:
update["reference_mode_enabled"] = bool(fields["reference_mode_enabled"])
if "cleanup_words" in fields and fields["cleanup_words"] is not None:
update["cleanup_words"] = [str(item).strip() for item in fields["cleanup_words"] if str(item).strip()]
updated = current.citation_display.model_copy(update=update)
saved = save_system_settings(current.model_copy(update={"citation_display": updated}))
return CitationDisplayResponse(
reference_mode_enabled=saved.citation_display.reference_mode_enabled,
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: str = Field(..., description="System default theme — built-in id or enabled custom-theme-* id")
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)")
compact_shortcut_buttons: list[CompactShortcutButton] = Field(default_factory=list, description="Shortcut buttons rendered in top bars")
custom_themes: list[CustomThemePreset] = Field(default_factory=list, description="Administrator-managed runtime theme presets")
class AppearanceUpdateRequest(BaseModel):
default_theme: str | 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)
compact_shortcut_buttons: list[CompactShortcutButton] | None = Field(default=None)
custom_themes: list[CustomThemePreset] | None = Field(default=None)
def _normalize_compact_shortcut_buttons(
buttons: list[CompactShortcutButton] | None,
) -> list[CompactShortcutButton]:
normalized: list[CompactShortcutButton] = []
seen_ids: set[str] = set()
for index, item in enumerate(buttons or []):
label = (item.label or "").strip() or "快捷入口"
url = (item.url or "").strip()
row_id = (item.id or "").strip() or uuid4().hex
if row_id in seen_ids:
row_id = uuid4().hex
seen_ids.add(row_id)
route_params: list[AppearanceRouteParam] = []
for param in item.route_params or []:
if param.paramType == "path":
source = (param.source or "access_token").strip() or "access_token"
route_params.append(
AppearanceRouteParam(
key="",
paramType="path",
source=source,
customValue=(param.customValue or "").strip() if source == "other" else None,
)
)
continue
key = (param.key or "").strip()
if not key:
continue
if param.paramType == "theme":
route_params.append(
AppearanceRouteParam(
key=key,
paramType="theme",
themeValue=(param.themeValue or "").strip() or "red=red;green=green;blue=blue;dark-blue=dark-blue",
)
)
continue
source = (param.source or "access_token").strip() or "access_token"
route_params.append(
AppearanceRouteParam(
key=key,
paramType="login",
source=source,
customValue=(param.customValue or "").strip() if source == "other" else None,
)
)
normalized.append(
CompactShortcutButton(
id=row_id,
label=label,
url=url,
route_params=route_params,
enabled=bool(item.enabled),
sort_order=index,
)
)
return normalized
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,
compact_shortcut_buttons=sorted(
appearance.compact_shortcut_buttons,
key=lambda item: item.sort_order,
),
custom_themes=sorted(appearance.custom_themes, key=lambda item: item.sort_order),
)
_MAX_CUSTOM_THEMES = 12
def _normalize_custom_themes(themes: list[CustomThemePreset] | None) -> list[CustomThemePreset]:
"""Reject unsafe/ambiguous theme rows and assign a deterministic order."""
source = themes or []
if len(source) > _MAX_CUSTOM_THEMES:
raise HTTPException(status_code=422, detail=f"At most {_MAX_CUSTOM_THEMES} custom themes are allowed")
normalized: list[CustomThemePreset] = []
seen_ids: set[str] = set()
seen_names: set[str] = set()
for index, item in enumerate(source):
if item.id in seen_ids:
raise HTTPException(status_code=422, detail="Custom theme ids must be unique")
name_key = item.name.casefold()
if name_key in seen_names:
raise HTTPException(status_code=422, detail="Custom theme names must be unique")
seen_ids.add(item.id)
seen_names.add(name_key)
normalized.append(item.model_copy(update={"sort_order": index}))
return normalized
def _resolve_default_theme(theme_id: str | None, custom_themes: list[CustomThemePreset]) -> str:
"""Keep defaults valid if an administrator disables or removes a preset."""
normalized = (theme_id or "").strip().lower()
if normalized in BUILTIN_THEME_IDS:
return normalized
if any(item.id == normalized and item.enabled for item in custom_themes):
return normalized
return "light"
@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_skill_view", "default_agent_view", "default_layout_mode"):
if key in fields and fields[key] is not None:
update[key] = fields[key]
if "compact_shortcut_buttons" in fields and body.compact_shortcut_buttons is not None:
update["compact_shortcut_buttons"] = _normalize_compact_shortcut_buttons(body.compact_shortcut_buttons)
custom_themes = _normalize_custom_themes(body.custom_themes) if "custom_themes" in fields and body.custom_themes is not None else list(current.appearance.custom_themes)
if "custom_themes" in fields and body.custom_themes is not None:
update["custom_themes"] = custom_themes
requested_default = body.default_theme if "default_theme" in fields else current.appearance.default_theme
update["default_theme"] = _resolve_default_theme(requested_default, custom_themes)
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))