Files

1578 lines
65 KiB
Python

"""Admin-configurable system-wide runtime settings."""
from __future__ import annotations
import asyncio
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_current_user_from_request, get_optional_user_from_request
from deerflow.config import get_app_config
from deerflow.config.system_settings import (
BUILTIN_THEME_IDS,
AppearanceRouteParam,
AuthenticationSettings,
CompactShortcutButton,
ConversationContextSettings,
CustomThemePreset,
HtmlMapSettings,
LeaderboardSettings,
ModelConcurrencySettings,
ModelPromptSuffix,
PluginDomainAgentMapping,
PluginManagementSettings,
PromptPrefixSettings,
PublishSquare,
PublishSquaresSettings,
RetryKeepPartialSettings,
RoundtableRoomLimitSettings,
SkillCompressionSettings,
StrategyProjectSettings,
WordHeadingNumberingStyle,
WriteFileCompatibilitySettings,
get_module_tag_ids,
list_publish_squares,
load_system_settings,
resolve_default_square_id,
save_system_settings,
set_module_tag_ids,
)
from deerflow.html_runtime.addresses import (
html_map_tile_url,
html_runtime_base_url,
normalize_html_map_tile_url,
normalize_html_runtime_base_url,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/system-settings", tags=["system-settings"])
@router.get("/model-prompt-suffixes", response_model=dict[str, ModelPromptSuffix])
async def get_model_prompt_suffixes(request: Request) -> dict[str, ModelPromptSuffix]:
await _require_model_prompt_admin(request)
return load_system_settings().model_prompt_suffixes
@router.put("/model-prompt-suffixes/{model_name:path}", response_model=ModelPromptSuffix)
async def update_model_prompt_suffix(model_name: str, body: ModelPromptSuffix, request: Request) -> ModelPromptSuffix:
await _require_model_prompt_admin(request)
current = load_system_settings()
# Retain editable entries for temporarily removed models so an administrator
# can still disable them. New entries must target a real configured model.
if get_app_config().get_model_config(model_name) is None and model_name not in current.model_prompt_suffixes:
raise HTTPException(status_code=404, detail="模型不存在")
if body.enabled and not body.prompt.strip():
raise HTTPException(status_code=422, detail="启用时请填写追加提示词")
current.model_prompt_suffixes[model_name] = body
save_system_settings(current)
return body
async def _require_model_prompt_admin(request: Request) -> None:
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="请先登录")
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="仅管理员可管理模型追加提示词")
class ApiReadCacheResponse(BaseModel):
enabled: bool = False
redis_configured: bool = False
ttl_seconds: int = 300
max_value_bytes: int = 524288
browser_status_enabled: bool = True
collections: list[dict] = Field(default_factory=list)
class ApiReadCacheUpdateRequest(BaseModel):
enabled: bool | None = None
ttl_seconds: int | None = Field(default=None, ge=1, le=3600)
browser_status_enabled: bool | None = None
collection_overrides: dict[str, bool] | None = None
async def _require_performance_admin(request: Request) -> None:
# Preserve no-auth deployments, but never swallow an invalid/missing login.
user = await get_current_user_from_request(request)
if user is not None and getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="仅管理员可管理接口性能设置")
@router.get("/api-read-cache", response_model=ApiReadCacheResponse)
async def get_api_read_cache_setting(request: Request) -> ApiReadCacheResponse:
"""Read the persisted administrator-controlled Redis cache switch."""
await _require_performance_admin(request)
from app.gateway.api_read_cache import api_read_cache_redis_configured, get_api_cache_controls, get_api_read_cache_settings
config = getattr(request.app.state, "config", None)
cache_config = getattr(config, "api_read_cache", None)
enabled, ttl_seconds = await get_api_read_cache_settings(config, force_refresh=True)
return ApiReadCacheResponse(
enabled=enabled,
redis_configured=api_read_cache_redis_configured(config),
ttl_seconds=ttl_seconds,
max_value_bytes=int(getattr(cache_config, "max_value_bytes", 524288) or 524288),
**await get_api_cache_controls(config),
)
@router.put("/api-read-cache", response_model=ApiReadCacheResponse)
async def update_api_read_cache_setting(
request: Request,
body: ApiReadCacheUpdateRequest,
) -> ApiReadCacheResponse:
"""Save the Redis cache switch to MySQL and apply it immediately."""
await _require_performance_admin(request)
from app.gateway.api_read_cache import (
API_READ_CACHE_COLLECTIONS,
api_read_cache_redis_configured,
get_api_cache_controls,
get_api_read_cache_settings,
invalidate_cached_collection,
save_api_read_cache_settings,
)
config = getattr(request.app.state, "config", None)
unknown = set(body.collection_overrides or {}) - set(API_READ_CACHE_COLLECTIONS)
if unknown:
raise HTTPException(status_code=422, detail=f"未知缓存接口:{', '.join(sorted(unknown))}")
try:
await save_api_read_cache_settings(
body.enabled, body.ttl_seconds, config=config,
collection_overrides=body.collection_overrides,
browser_status_enabled=body.browser_status_enabled,
)
await asyncio.gather(
*(
invalidate_cached_collection(config, collection, force=True)
for collection in API_READ_CACHE_COLLECTIONS
)
)
except Exception as exc:
logger.exception("Could not persist API Redis cache setting")
raise HTTPException(status_code=503, detail="无法将缓存设置保存到 MySQL") from exc
cache_config = getattr(config, "api_read_cache", None)
enabled, ttl_seconds = await get_api_read_cache_settings(config, force_refresh=True)
return ApiReadCacheResponse(
enabled=enabled,
redis_configured=api_read_cache_redis_configured(config),
ttl_seconds=ttl_seconds,
max_value_bytes=int(getattr(cache_config, "max_value_bytes", 524288) or 524288),
**await get_api_cache_controls(config),
)
class PromptPrefixResponse(BaseModel):
image_generation_unavailable_hint_enabled: bool = False
python_dependency_delete_protection_enabled: bool = False
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",
)
word_heading_numbering_style: WordHeadingNumberingStyle = Field(
default="hybrid_decimal",
description="Heading-numbering preset used by Markdown-to-Word export",
)
class KnowledgeBaseProductVersionResponse(BaseModel):
version: Literal["v1", "v2"] = "v1"
class KnowledgeBaseProductVersionUpdateRequest(BaseModel):
version: Literal["v1", "v2"]
class OrdinaryKnowledgeLanding(BaseModel):
mode: Literal["wiki", "weknora"] = "wiki"
class StrategyProjectSettingsResponse(BaseModel):
taiwan_strait_demo_enabled: bool = False
class StrategyProjectSettingsUpdateRequest(BaseModel):
taiwan_strait_demo_enabled: bool | None = None
@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)
def _strategy_project_settings_response(
settings: StrategyProjectSettings,
) -> StrategyProjectSettingsResponse:
return StrategyProjectSettingsResponse(
taiwan_strait_demo_enabled=settings.taiwan_strait_demo_enabled,
)
@router.get("/strategy-projects", response_model=StrategyProjectSettingsResponse)
async def get_strategy_project_settings() -> StrategyProjectSettingsResponse:
"""Return project-demo visibility. Any logged-in user may read it."""
return _strategy_project_settings_response(load_system_settings().strategy_projects)
@router.put("/strategy-projects", response_model=StrategyProjectSettingsResponse)
async def update_strategy_project_settings(
body: StrategyProjectSettingsUpdateRequest,
request: Request,
) -> StrategyProjectSettingsResponse:
"""Update project-demo visibility. Admin only."""
await _require_admin(request)
current = load_system_settings()
fields = body.model_dump(exclude_unset=True, exclude_none=True)
updated = current.strategy_projects.model_copy(update=fields)
saved = save_system_settings(current.model_copy(update={"strategy_projects": updated}))
return _strategy_project_settings_response(saved.strategy_projects)
class PromptPrefixUpdateRequest(BaseModel):
image_generation_unavailable_hint_enabled: bool | None = None
python_dependency_delete_protection_enabled: bool | None = None
enabled: bool | None = None
switch_label: str | None = None
prompt_prefix: str | None = None
ordinary_qa_markdown_format_enabled: bool | None = None
word_heading_numbering_style: WordHeadingNumberingStyle | 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):
retrieved_files_enabled: bool = True
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):
retrieved_files_enabled: bool | None = None
reference_mode_enabled: bool | None = None
cleanup_words: list[str] | None = None
class HtmlMapSettingsResponse(BaseModel):
engine: Literal["echarts", "leaflet"] = "echarts"
class HtmlMapSettingsUpdateRequest(BaseModel):
engine: Literal["echarts", "leaflet"] | None = None
class HtmlRuntimeSettingsResponse(BaseModel):
base_url: str
map_tile_url: str
class HtmlRuntimeSettingsUpdateRequest(BaseModel):
base_url: str
map_tile_url: str
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(
image_generation_unavailable_hint_enabled=settings.image_generation_unavailable_hint_enabled,
python_dependency_delete_protection_enabled=settings.python_dependency_delete_protection_enabled,
enabled=settings.enabled,
switch_label=settings.switch_label,
prompt_prefix=settings.prompt_prefix,
ordinary_qa_markdown_format_enabled=settings.ordinary_qa_markdown_format_enabled,
word_heading_numbering_style=settings.word_heading_numbering_style,
)
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")
_WX_COMMON_AGENTS_MANAGER_USERNAME = "wx"
def _username_of(user: object) -> str:
"""Resolve the trusted account name from the authenticated user's email."""
email = getattr(user, "email", None) or ""
return str(email).split("@", 1)[0].strip().lower()
async def _require_wx_common_agents_manager(request: Request) -> None:
"""Allow wx (and administrators) to choose the shared wx category."""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) == "admin":
return
if _username_of(user) == _WX_COMMON_AGENTS_MANAGER_USERNAME:
return
raise HTTPException(
status_code=403,
detail="Only an admin or the wx account can modify the wx common agents",
)
@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
class MemoryRuntimeResponse(BaseModel):
enabled: bool = Field(
default=False,
description="Whether generic/personal Q&A memory is enabled deployment-wide",
)
class MemoryRuntimeUpdateRequest(BaseModel):
enabled: bool
class WriteFileCompatibilityResponse(BaseModel):
enabled: bool = Field(
default=False,
description=(
"Whether all compatible models use the buffered-gateway "
"write_file transport"
),
)
class WriteFileCompatibilityUpdateRequest(BaseModel):
enabled: bool
class RetryKeepPartialResponse(BaseModel):
enabled: bool = Field(
default=True,
description=(
"Keep the already-streamed partial answer visible when a model "
"call is silently retried"
),
)
class RetryKeepPartialUpdateRequest(BaseModel):
enabled: bool
class ConversationContextResponse(BaseModel):
max_run_steps: int = 1000
recent_turn_window_enabled: bool = Field(
default=False,
description="Whether all lead-agent model requests keep only the latest three dialogue turns",
)
recent_turns: int = Field(
default=3,
description="Fixed number of real user turns retained by the sliding window",
)
max_conversation_rounds: int = Field(
default=20,
ge=1,
le=50,
description="Maximum real user turns allowed in one ordinary chat thread",
)
use_question_as_title: bool = Field(
default=False,
description="Whether the first user question is the final title, without post-answer title generation",
)
class ConversationContextUpdateRequest(BaseModel):
max_run_steps: int | None = Field(default=None, ge=20, le=10000, strict=True)
recent_turn_window_enabled: bool | None = None
max_conversation_rounds: int | None = Field(default=None, ge=1, le=50)
use_question_as_title: bool | None = None
class RoundtableRoomLimitResponse(BaseModel):
enabled: bool = True
max_rooms: int = 3
max_hold_minutes: int = 30
max_cycles: int = 8
write_file_compatibility_enabled: bool = False
class RoundtableRoomLimitUpdateRequest(BaseModel):
enabled: bool | None = None
max_rooms: int | None = Field(default=None, ge=1, le=20)
max_hold_minutes: int | None = Field(default=None, ge=1, le=1440)
max_cycles: int | None = Field(default=None, ge=2, le=100)
write_file_compatibility_enabled: bool | None = None
class OrdinaryKnowledgeRetrievalResponse(BaseModel):
smart_reasoning_enabled: bool = Field(
default=False,
description="Whether users may select smart multi-round ordinary-knowledge retrieval",
)
advanced_management_enabled: bool = Field(
default=False,
description="Whether advanced ordinary-knowledge management entries are exposed",
)
class OrdinaryKnowledgeRetrievalUpdateRequest(BaseModel):
smart_reasoning_enabled: bool | None = None
advanced_management_enabled: bool | None = None
def _knowledge_retrieval_settings_store(request: Request):
store = getattr(request.app.state, "knowledge_retrieval_settings_store", None)
if store is None:
raise HTTPException(
status_code=503,
detail="Knowledge retrieval settings store is not available",
)
return store
@router.get("/memory", response_model=MemoryRuntimeResponse)
async def get_memory_runtime_setting() -> MemoryRuntimeResponse:
return MemoryRuntimeResponse(enabled=load_system_settings().memory.enabled)
@router.put("/memory", response_model=MemoryRuntimeResponse)
async def update_memory_runtime_setting(
body: MemoryRuntimeUpdateRequest,
request: Request,
) -> MemoryRuntimeResponse:
await _require_admin(request)
current = load_system_settings()
updated = current.memory.model_copy(update={"enabled": body.enabled})
saved = save_system_settings(current.model_copy(update={"memory": updated}))
logger.info("Q&A memory runtime switch changed: enabled=%s", saved.memory.enabled)
return MemoryRuntimeResponse(enabled=saved.memory.enabled)
@router.get(
"/write-file-compatibility",
response_model=WriteFileCompatibilityResponse,
)
async def get_write_file_compatibility_setting() -> WriteFileCompatibilityResponse:
settings = load_system_settings().write_file_compatibility
return WriteFileCompatibilityResponse(enabled=settings.enabled)
@router.put(
"/write-file-compatibility",
response_model=WriteFileCompatibilityResponse,
)
async def update_write_file_compatibility_setting(
body: WriteFileCompatibilityUpdateRequest,
request: Request,
) -> WriteFileCompatibilityResponse:
await _require_admin(request)
current = load_system_settings()
updated = WriteFileCompatibilitySettings(enabled=body.enabled)
saved = save_system_settings(
current.model_copy(update={"write_file_compatibility": updated})
)
logger.info(
"Global write_file buffered-gateway switch changed: enabled=%s",
saved.write_file_compatibility.enabled,
)
return WriteFileCompatibilityResponse(
enabled=saved.write_file_compatibility.enabled
)
@router.get("/retry-keep-partial", response_model=RetryKeepPartialResponse)
async def get_retry_keep_partial_setting() -> RetryKeepPartialResponse:
"""Return the retry-keeps-partial display switch (readable by chat clients)."""
settings = load_system_settings().retry_keep_partial
return RetryKeepPartialResponse(enabled=settings.enabled)
@router.put("/retry-keep-partial", response_model=RetryKeepPartialResponse)
async def update_retry_keep_partial_setting(
body: RetryKeepPartialUpdateRequest,
request: Request,
) -> RetryKeepPartialResponse:
await _require_admin(request)
current = load_system_settings()
updated = RetryKeepPartialSettings(enabled=body.enabled)
saved = save_system_settings(current.model_copy(update={"retry_keep_partial": updated}))
logger.info(
"Retry-keeps-partial display switch changed: enabled=%s",
saved.retry_keep_partial.enabled,
)
return RetryKeepPartialResponse(enabled=saved.retry_keep_partial.enabled)
def _conversation_context_response(settings: ConversationContextSettings) -> ConversationContextResponse:
return ConversationContextResponse(
max_run_steps=settings.max_run_steps,
recent_turn_window_enabled=settings.recent_turn_window_enabled,
recent_turns=3,
max_conversation_rounds=settings.max_conversation_rounds,
use_question_as_title=settings.use_question_as_title,
)
@router.get("/conversation-context", response_model=ConversationContextResponse)
async def get_conversation_context_setting() -> ConversationContextResponse:
"""Return the global recent-turn window mode (readable by chat clients)."""
return _conversation_context_response(load_system_settings().conversation_context)
@router.put("/conversation-context", response_model=ConversationContextResponse)
async def update_conversation_context_setting(
body: ConversationContextUpdateRequest,
request: Request,
) -> ConversationContextResponse:
"""Update the recent-turn window or ordinary-chat round cap. Admin only."""
await _require_admin(request)
current = load_system_settings()
updated_fields = body.model_dump(exclude_unset=True, exclude_none=True)
if not updated_fields:
raise HTTPException(status_code=422, detail="至少需要提供一个上下文设置项")
updated = current.conversation_context.model_copy(update=updated_fields)
saved = save_system_settings(current.model_copy(update={"conversation_context": updated}))
logger.info(
"Conversation runtime settings changed: recent_turn_window=%s use_question_as_title=%s max_conversation_rounds=%s max_run_steps=%s",
saved.conversation_context.recent_turn_window_enabled,
saved.conversation_context.use_question_as_title,
saved.conversation_context.max_conversation_rounds,
saved.conversation_context.max_run_steps,
)
return _conversation_context_response(saved.conversation_context)
def _roundtable_room_limit_response(settings: RoundtableRoomLimitSettings) -> RoundtableRoomLimitResponse:
return RoundtableRoomLimitResponse(
enabled=settings.enabled,
max_rooms=settings.max_rooms,
max_hold_minutes=settings.max_hold_minutes,
max_cycles=settings.max_cycles,
write_file_compatibility_enabled=settings.write_file_compatibility_enabled,
)
@router.get("/roundtable-room-limit", response_model=RoundtableRoomLimitResponse)
async def get_roundtable_room_limit_setting() -> RoundtableRoomLimitResponse:
"""Return the global step-2 room and closeout settings."""
return _roundtable_room_limit_response(load_system_settings().roundtable_room_limit)
@router.put("/roundtable-room-limit", response_model=RoundtableRoomLimitResponse)
async def update_roundtable_room_limit_setting(
body: RoundtableRoomLimitUpdateRequest,
request: Request,
) -> RoundtableRoomLimitResponse:
"""Update global room limits and the coordinator closeout round. Admin only."""
await _require_admin(request)
current = load_system_settings()
updates: dict[str, object] = {}
if body.enabled is not None:
updates["enabled"] = body.enabled
if body.max_rooms is not None:
updates["max_rooms"] = body.max_rooms
if body.max_hold_minutes is not None:
updates["max_hold_minutes"] = body.max_hold_minutes
if body.max_cycles is not None:
updates["max_cycles"] = body.max_cycles
if body.write_file_compatibility_enabled is not None:
updates["write_file_compatibility_enabled"] = body.write_file_compatibility_enabled
updated = current.roundtable_room_limit.model_copy(update=updates)
saved = save_system_settings(current.model_copy(update={"roundtable_room_limit": updated}))
logger.info(
"Roundtable runtime limits changed: enabled=%s max_rooms=%s max_hold_minutes=%s max_cycles=%s write_file_compatibility=%s",
saved.roundtable_room_limit.enabled,
saved.roundtable_room_limit.max_rooms,
saved.roundtable_room_limit.max_hold_minutes,
saved.roundtable_room_limit.max_cycles,
saved.roundtable_room_limit.write_file_compatibility_enabled,
)
return _roundtable_room_limit_response(saved.roundtable_room_limit)
@router.get(
"/ordinary-knowledge-retrieval",
response_model=OrdinaryKnowledgeRetrievalResponse,
)
async def get_ordinary_knowledge_retrieval_setting(
request: Request,
) -> OrdinaryKnowledgeRetrievalResponse:
"""Return the DB-backed smart-reasoning availability flag."""
store = _knowledge_retrieval_settings_store(request)
enabled, advanced_enabled = await asyncio.gather(
store.get(),
store.get_advanced_management_enabled(),
)
return OrdinaryKnowledgeRetrievalResponse(
smart_reasoning_enabled=enabled,
advanced_management_enabled=advanced_enabled,
)
@router.put(
"/ordinary-knowledge-retrieval",
response_model=OrdinaryKnowledgeRetrievalResponse,
)
async def update_ordinary_knowledge_retrieval_setting(
body: OrdinaryKnowledgeRetrievalUpdateRequest,
request: Request,
) -> OrdinaryKnowledgeRetrievalResponse:
"""Enable or disable smart ordinary-knowledge retrieval. Admin only."""
await _require_admin(request)
actor = await get_optional_user_from_request(request)
actor_id = getattr(actor, "id", None)
actor_value = str(actor_id) if actor_id is not None else None
store = _knowledge_retrieval_settings_store(request)
if body.smart_reasoning_enabled is not None:
await store.set(
body.smart_reasoning_enabled,
updated_by=actor_value,
)
if body.advanced_management_enabled is not None:
await store.set_advanced_management_enabled(
body.advanced_management_enabled,
updated_by=actor_value,
)
enabled, advanced_enabled = await asyncio.gather(
store.get(),
store.get_advanced_management_enabled(),
)
logger.info(
"Ordinary-knowledge settings changed: smart_reasoning=%s advanced_management=%s",
enabled,
advanced_enabled,
)
return OrdinaryKnowledgeRetrievalResponse(
smart_reasoning_enabled=enabled,
advanced_management_enabled=advanced_enabled,
)
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,
retrieved_files_enabled=settings.retrieved_files_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 "retrieved_files_enabled" in fields and fields["retrieved_files_enabled"] is not None:
update["retrieved_files_enabled"] = bool(fields["retrieved_files_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,
retrieved_files_enabled=saved.citation_display.retrieved_files_enabled,
cleanup_words=saved.citation_display.cleanup_words,
)
@router.get("/html-map", response_model=HtmlMapSettingsResponse)
async def get_html_map_settings() -> HtmlMapSettingsResponse:
"""Return the map engine used by newly generated HTML pages."""
settings: HtmlMapSettings = load_system_settings().html_map
return HtmlMapSettingsResponse(engine=settings.engine)
@router.put("/html-map", response_model=HtmlMapSettingsResponse)
async def update_html_map_settings(
body: HtmlMapSettingsUpdateRequest,
request: Request,
) -> HtmlMapSettingsResponse:
"""Choose ECharts or the Leaflet map webpage for new HTML maps. Admin only."""
await _require_admin(request)
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
engine = fields.get("engine", current.html_map.engine)
updated = current.html_map.model_copy(update={"engine": engine or "echarts"})
saved = save_system_settings(current.model_copy(update={"html_map": updated}))
return HtmlMapSettingsResponse(engine=saved.html_map.engine)
@router.get("/html-runtime", response_model=HtmlRuntimeSettingsResponse)
async def get_html_runtime_settings() -> HtmlRuntimeSettingsResponse:
"""Return the addresses newly generated HTML pages will use."""
return HtmlRuntimeSettingsResponse(
base_url=html_runtime_base_url(),
map_tile_url=html_map_tile_url(),
)
@router.put("/html-runtime", response_model=HtmlRuntimeSettingsResponse)
async def update_html_runtime_settings(
body: HtmlRuntimeSettingsUpdateRequest,
request: Request,
) -> HtmlRuntimeSettingsResponse:
"""Save the browser-reachable runtime and map-tile addresses. Admin only."""
await _require_admin(request)
try:
base_url = normalize_html_runtime_base_url(body.base_url)
map_tile_url = normalize_html_map_tile_url(body.map_tile_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
current = load_system_settings()
updated = current.html_runtime.model_copy(
update={"base_url": base_url, "map_tile_url": map_tile_url}
)
saved = save_system_settings(current.model_copy(update={"html_runtime": updated}))
return HtmlRuntimeSettingsResponse(
base_url=saved.html_runtime.base_url,
map_tile_url=saved.html_runtime.map_tile_url,
)
@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)")
default_composer_width_full: int = Field(..., description="System default composer width percent for full layout. 100 keeps the built-in width.")
default_composer_width_compact: int = Field(..., description="System default composer width percent for compact layout. 100 keeps the built-in width.")
default_composer_height_full: int = Field(..., description="System default composer height percent for full layout. 100 keeps the built-in height.")
default_composer_height_compact: int = Field(..., description="System default composer height percent for compact layout. 100 keeps the built-in height.")
default_agent_composer_width: int = Field(..., description="System default agent composer width percent. 100 keeps the built-in width.")
default_agent_composer_height: int = Field(..., description="System default agent composer height percent. 100 keeps the built-in height.")
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)
default_composer_width_full: int | None = Field(default=None)
default_composer_width_compact: int | None = Field(default=None)
default_composer_height_full: int | None = Field(default=None)
default_composer_height_compact: int | None = Field(default=None)
default_agent_composer_width: int | None = Field(default=None)
default_agent_composer_height: int | 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,
default_composer_width_full=appearance.default_composer_width_full,
default_composer_width_compact=appearance.default_composer_width_compact,
default_composer_height_full=appearance.default_composer_height_full,
default_composer_height_compact=appearance.default_composer_height_compact,
default_agent_composer_width=appearance.default_agent_composer_width,
default_agent_composer_height=appearance.default_agent_composer_height,
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",
"default_composer_width_full",
"default_composer_width_compact",
"default_composer_height_full",
"default_composer_height_compact",
"default_agent_composer_width",
"default_agent_composer_height",
):
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 WxCommonAgentsResponse(BaseModel):
"""The selected Agent Gallery category for the wx chat route."""
agent_type: str | None = Field(default=None)
class WxCommonAgentsUpdateRequest(BaseModel):
agent_type: str | None = Field(default=None, max_length=128)
class LoginWhitelistResponse(BaseModel):
enabled: bool = Field(..., description="是否启用登录白名单(关=所有人可登录,开=仅已放行用户/admin 可登录)")
@router.get("/authentication", response_model=AuthenticationSettings)
async def get_authentication_settings(request: Request) -> AuthenticationSettings:
user = await get_current_user_from_request(request)
if user.system_role != "admin":
raise HTTPException(status_code=403, detail="仅管理员可设置登录方式")
return load_system_settings().authentication
@router.put("/authentication", response_model=AuthenticationSettings)
async def update_authentication_settings(body: AuthenticationSettings, request: Request) -> AuthenticationSettings:
user = await get_current_user_from_request(request)
if user.system_role != "admin":
raise HTTPException(status_code=403, detail="仅管理员可设置登录方式")
if not body.password_login_enabled and not body.sso_enabled:
raise HTTPException(status_code=400, detail="请至少保留一种登录方式")
if body.registration_enabled and not body.password_login_enabled:
raise HTTPException(status_code=400, detail="开启自助注册前,请先开启账号密码登录")
current = load_system_settings()
return save_system_settings(current.model_copy(update={"authentication": body})).authentication
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))
def _normalize_wx_common_agent_type(
value: str | None,
current: PublishSquaresSettings,
) -> str | None:
"""Validate a wx category against the live Agent Gallery category set."""
if value is None:
return None
normalized = value.strip()
if not normalized:
return None
if normalized in {"mine", "sentiment"}:
return normalized
if normalized.startswith("square:"):
square_id = normalized.removeprefix("square:")
if square_id in {square.id for square in list_publish_squares(current)}:
return normalized
raise HTTPException(status_code=422, detail="Unknown wx common-agent category")
@router.get("/wx-common-agents", response_model=WxCommonAgentsResponse)
async def get_wx_common_agents() -> WxCommonAgentsResponse:
"""Return the category used by wx route's common-agent panel."""
return WxCommonAgentsResponse(
agent_type=load_system_settings().wx_common_agents.agent_type,
)
@router.put("/wx-common-agents", response_model=WxCommonAgentsResponse)
async def update_wx_common_agents(
body: WxCommonAgentsUpdateRequest,
request: Request,
) -> WxCommonAgentsResponse:
"""Set the wx route's Agent Gallery category. wx or an admin may update it."""
await _require_wx_common_agents_manager(request)
current = load_system_settings()
agent_type = _normalize_wx_common_agent_type(body.agent_type, current.publish_squares)
updated = current.wx_common_agents.model_copy(update={"agent_type": agent_type})
saved = save_system_settings(current.model_copy(update={"wx_common_agents": updated}))
return WxCommonAgentsResponse(agent_type=saved.wx_common_agents.agent_type)