860 lines
36 KiB
Python
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))
|