1578 lines
65 KiB
Python
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)
|