Files

1038 lines
43 KiB
Python

"""System-wide runtime settings editable from the admin UI.
Backed by a single JSON file under the DeerFlow runtime home (default
``.deer-flow/system_settings.json``). Unlike :mod:`extensions_config` this
file is **not** part of the project source tree — it is per-deployment
state, and it stays writable at runtime.
The only feature stored here at the moment is the **prompt prefix toggle**
shown in the chat input: an administrator can configure a label and a
prefix string, and any user can flip the toggle on/off; when enabled, the
prefix is prepended to the first human message of a new thread.
"""
from __future__ import annotations
import json
import logging
import re
import threading
from pathlib import Path
from typing import Literal
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field, field_validator
from deerflow.config.llmwiki_config import LlmWikiRuntimeOverride
from deerflow.config.runtime_paths import runtime_home
logger = logging.getLogger(__name__)
_SETTINGS_FILE = "system_settings.json"
_lock = threading.Lock()
# Built-ins remain source-controlled, while administrator-created themes use a
# stable prefixed id and are persisted in the runtime settings JSON.
BUILTIN_THEME_IDS = frozenset({"light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"})
CUSTOM_THEME_ID_PREFIX = "custom-theme-"
_CUSTOM_THEME_ID_RE = re.compile(r"^custom-theme-[a-z0-9-]{8,64}$")
_HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
def _normalize_optional_hex_color(value: str | None, field_name: str) -> str | None:
"""Accept an optional, safe six-digit colour without allowing raw CSS."""
if value is None or not str(value).strip():
return None
normalized = str(value).strip().upper()
if not _HEX_COLOR_RE.fullmatch(normalized):
raise ValueError(f"{field_name} must be a six-digit hexadecimal value such as #0062D9")
return normalized
class CustomThemeColorOverrides(BaseModel):
"""Optional advanced adjustments to an otherwise generated colour scale.
The short list intentionally covers interaction surfaces only. It avoids
turning the theme editor into a fragile form containing every CSS token.
"""
button_hover: str | None = Field(default=None, description="Primary button hover colour")
navigation_gradient_start: str | None = Field(
default=None,
description="Left colour for the configurable top-navigation gradient",
)
navigation_gradient_end: str | None = Field(
default=None,
description="Right colour for the configurable top-navigation gradient",
)
selected_background: str | None = Field(default=None, description="Selected/active item background")
border: str | None = Field(default=None, description="Card and input border colour")
muted: str | None = Field(default=None, description="Muted/secondary surface colour")
muted_text: str | None = Field(default=None, description="Secondary text colour")
destructive: str | None = Field(default=None, description="Delete and danger action colour")
brand_title: str | None = Field(default=None, description="Branded section-title text colour")
primary_foreground: str | None = Field(
default=None,
description="Text colour on primary surfaces such as the logo and status badges",
)
model_config = ConfigDict(extra="ignore")
@field_validator(
"button_hover",
"navigation_gradient_start",
"navigation_gradient_end",
"selected_background",
"border",
"muted",
"muted_text",
"destructive",
"brand_title",
"primary_foreground",
)
@classmethod
def _validate_color(cls, value: str | None, info) -> str | None:
return _normalize_optional_hex_color(value, f"Theme override {info.field_name}")
class CustomThemePreset(BaseModel):
"""One administrator-managed theme generated from a single brand color.
The frontend derives all surface, text, focus and hover tokens from these
safe inputs at runtime. Storing only the brand color and base mode keeps
arbitrary CSS out of runtime settings and lets contrast rules remain
centrally enforced.
"""
id: str = Field(default_factory=lambda: f"{CUSTOM_THEME_ID_PREFIX}{uuid4().hex}")
name: str = Field(default="自定义主题", min_length=1, max_length=32)
primary: str = Field(default="#0062D9", description="Six-digit CSS hexadecimal brand color")
text_color: str | None = Field(
default=None,
description="Optional primary-button text color; omitted = automatic button foreground",
)
mode: Literal["light", "dark"] = Field(default="light", description="Surface family used to derive the palette")
overrides: CustomThemeColorOverrides = Field(default_factory=CustomThemeColorOverrides)
enabled: bool = Field(default=True)
sort_order: int = Field(default=0, ge=0, le=999)
model_config = ConfigDict(extra="ignore")
@field_validator("id")
@classmethod
def _validate_id(cls, value: str) -> str:
normalized = str(value or "").strip().lower()
if not _CUSTOM_THEME_ID_RE.fullmatch(normalized):
raise ValueError("Custom theme id must use the custom-theme- prefix")
return normalized
@field_validator("name")
@classmethod
def _normalize_name(cls, value: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("Theme name cannot be empty")
return normalized
@field_validator("primary")
@classmethod
def _validate_primary(cls, value: str) -> str:
normalized = str(value or "").strip().upper()
if not _HEX_COLOR_RE.fullmatch(normalized):
raise ValueError("Theme primary color must be a six-digit hexadecimal value such as #0062D9")
return normalized
@field_validator("text_color")
@classmethod
def _validate_text_color(cls, value: str | None) -> str | None:
return _normalize_optional_hex_color(value, "Theme text color")
WordHeadingNumberingStyle = Literal[
"hybrid_decimal",
"official_chinese",
"decimal",
"chapter_section",
"none",
]
class PromptPrefixSettings(BaseModel):
"""Admin-configurable first-message prompt prefix."""
enabled: bool = Field(default=False, description="Whether the prefix toggle is offered to users at all")
image_generation_unavailable_hint_enabled: bool = Field(
default=False,
description="Tell users image generation is unavailable instead of attempting to generate images.",
)
python_dependency_delete_protection_enabled: bool = Field(
default=False,
description="Inject guidance against standalone Python dependency deletion; installation and replacement remain allowed.",
)
switch_label: str = Field(default="加入立场前缀", description="Toggle label shown in the chat input")
prompt_prefix: str = Field(default="", description="Text prepended to the first user message when enabled")
ordinary_qa_markdown_format_enabled: bool = Field(
default=False,
description="Whether the 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 every Markdown-to-Word export.",
)
model_config = ConfigDict(extra="ignore")
class SkillKnowledgeAutoSyncSettings(BaseModel):
"""Watcher/scheduler policy for bindings that explicitly opt in."""
watch_enabled: bool = True
schedule_enabled: bool = True
schedule_cron: str = Field(default="0 */6 * * *", min_length=5, max_length=128)
debounce_seconds: int = Field(default=120, ge=10, le=86_400)
write_remote_enabled: bool = True
review_mode: Literal["required", "auto_high_confidence", "off"] = "auto_high_confidence"
model_config = ConfigDict(extra="ignore")
class AppearanceRouteParam(BaseModel):
"""Runtime query-param descriptor for top-bar shortcut buttons.
The frontend resolves login params from ``userInfo`` and theme params from
the current UI theme, matching the light-app center's link builder.
"""
key: str = Field(default="", description="Query parameter name appended to the shortcut URL; path parameters do not use a key")
paramType: Literal["login", "theme", "path"] = Field(default="login", description="Whether the value is a login query parameter, a theme query parameter, or a path segment")
source: str | None = Field(default=None, description="login param source field in userInfo, or 'other'")
customValue: str | None = Field(default=None, description="Literal value when source is 'other'")
themeValue: str | None = Field(default=None, description="Theme-value mapping string, e.g. red=red;dark-blue=dark")
model_config = ConfigDict(extra="ignore")
def _default_shortcut_route_params() -> list[AppearanceRouteParam]:
return [
AppearanceRouteParam(
key="access_token",
paramType="login",
source="access_token",
customValue="",
themeValue="red=red;green=green;blue=blue;dark-blue=dark-blue",
)
]
class CompactShortcutButton(BaseModel):
"""Configurable shortcut shown in top bars before the theme switcher."""
id: str = Field(default_factory=lambda: uuid4().hex, description="Stable row id for editing")
label: str = Field(default="快捷入口", description="Button text shown next to the icon")
url: str = Field(default="", description="Target URL opened in a new tab")
route_params: list[AppearanceRouteParam] = Field(
default_factory=_default_shortcut_route_params,
description="Login/theme query parameters or keyless path segments resolved at launch time",
)
enabled: bool = Field(default=True, description="Whether this shortcut is rendered")
sort_order: int = Field(default=0, description="Display order in top bars")
model_config = ConfigDict(extra="ignore")
def _default_compact_shortcut_buttons() -> list[CompactShortcutButton]:
return [
CompactShortcutButton(
id="default-compact-shortcut",
label="快捷入口",
url="",
enabled=True,
sort_order=0,
)
]
class AppearanceSettings(BaseModel):
"""System-wide appearance defaults that any *new* user inherits.
Once a user explicitly toggles their theme in the UI, that choice is
persisted in the browser and supersedes this default — so changing
``default_theme`` here only affects users who have never touched the
toggle, and any future first-time loads.
"""
default_theme: str = Field(
default="light",
description="Theme applied for users who haven't picked their own (a built-in id or custom-theme-* id)",
)
default_skill_view: Literal["card", "list"] = Field(
default="list",
description="Default skill list display mode for users who haven't picked their own (card / list)",
)
default_agent_view: Literal["card", "list"] = Field(
default="card",
description="Default agent list display mode for users who haven't picked their own (card / list)",
)
default_layout_mode: Literal["full", "compact"] = Field(
default="compact",
description="Default interface layout for users who haven't picked their own — compact (hidden) / full (sidebar + header)",
)
default_composer_width_full: int = Field(
default=100,
description="Default ordinary-chat composer width percent for full layout, used when a user has not chosen their own. 100 keeps the built-in width.",
)
default_composer_width_compact: int = Field(
default=100,
description="Default ordinary-chat composer width percent for compact layout, used when a user has not chosen their own. 100 keeps the built-in width.",
)
default_composer_height_full: int = Field(
default=100,
description="Default ordinary-chat composer height percent for full layout, used when a user has not chosen their own. 100 keeps the built-in height.",
)
default_composer_height_compact: int = Field(
default=100,
description="Default ordinary-chat composer height percent for compact layout, used when a user has not chosen their own. 100 keeps the built-in height.",
)
default_agent_composer_width: int = Field(
default=100,
description="Default agent-chat composer width percent, used when a user has not chosen their own. 100 keeps the built-in width; the page still caps it at the parent column.",
)
default_agent_composer_height: int = Field(
default=100,
description="Default agent-chat composer height percent, used when a user has not chosen their own. 100 keeps the built-in height.",
)
compact_shortcut_buttons: list[CompactShortcutButton] = Field(
default_factory=_default_compact_shortcut_buttons,
description="Shortcut buttons rendered before the theme switcher in top bars",
)
custom_themes: list[CustomThemePreset] = Field(
default_factory=list,
description="Administrator-managed runtime themes available to all users",
)
model_config = ConfigDict(extra="ignore")
@staticmethod
def _clamp_scale(value: object, low: int, high: int) -> int:
if value is None or value == "":
return 100
try:
parsed = int(round(float(value))) # type: ignore[arg-type]
except (TypeError, ValueError):
return 100
return min(high, max(low, parsed))
@field_validator("default_composer_width_full", "default_composer_width_compact", mode="before")
@classmethod
def _clamp_default_composer_width(cls, value: object) -> int:
return cls._clamp_scale(value, 60, 150)
@field_validator(
"default_composer_height_full",
"default_composer_height_compact",
"default_agent_composer_height",
mode="before",
)
@classmethod
def _clamp_default_composer_height(cls, value: object) -> int:
return cls._clamp_scale(value, 70, 200)
@field_validator("default_agent_composer_width", mode="before")
@classmethod
def _clamp_default_agent_composer_width(cls, value: object) -> int:
return cls._clamp_scale(value, 60, 400)
@field_validator("default_theme")
@classmethod
def _validate_default_theme(cls, value: str) -> str:
normalized = str(value or "").strip().lower()
if normalized in BUILTIN_THEME_IDS or _CUSTOM_THEME_ID_RE.fullmatch(normalized):
return normalized
raise ValueError("Unknown theme id")
class CitationDisplaySettings(BaseModel):
"""System-wide controls for citation mode entry and display cleanup."""
retrieved_files_enabled: bool = True
reference_mode_enabled: bool = Field(
default=False,
description="Whether the reference-mode entry points are exposed to users.",
)
cleanup_words: list[str] = Field(
default_factory=list,
description="Words or phrases used to remove noisy advertisement/disclaimer lines from citation display.",
)
model_config = ConfigDict(extra="ignore")
class PrivateSquareSettings(BaseModel):
"""Admin-configurable settings for the agent private-square (私密广场).
The password gates read access to all agents flagged ``is_private=True``.
The hash is stored opaquely — the router layer is responsible for hashing
and verifying so the harness package keeps zero dependencies on
``app.gateway.auth``.
"""
enabled: bool = Field(default=False, description="Whether the private square is offered to users at all")
password_hash: str = Field(default="", description="Opaque hash of the admin-configured password. Empty = no password set yet.")
model_config = ConfigDict(extra="ignore")
#: Id of the always-present built-in default square (used when the admin has
#: configured no squares, or when a published item picks none).
DEFAULT_SQUARE_ID = "default"
class PublishSquare(BaseModel):
"""One admin-configured publish square (发布广场).
A pure organizational category that published agents/skills can be filed
under — unlike the password-gated :class:`PrivateSquareSettings`, a square
has **no access control**: every logged-in user can browse every square.
Each agent/skill belongs to at most one square (a single ``square_id``
field on the row), so an item can never be "published twice".
"""
id: str = Field(..., description="Stable square id (slug/uuid); used as agents.square_id / skills.square_id")
name: str = Field(..., description="Display name shown as the square's tab label")
description: str = Field(default="", description="Optional description shown in the admin config / tab hint")
model_config = ConfigDict(extra="ignore")
class PublishSquaresSettings(BaseModel):
"""Admin-configurable list of publish squares.
``squares`` is display-ordered. ``default_square_id`` selects the square a
publisher lands in when they do not pick one explicitly. There is always at
least one effective square — :func:`list_publish_squares` injects a built-in
"默认广场" when the admin has configured none, so the publish flow always has
a valid target.
"""
squares: list[PublishSquare] = Field(default_factory=list, description="Ordered list of configured squares")
default_square_id: str = Field(default=DEFAULT_SQUARE_ID, description="Square id used when a publisher picks none")
model_config = ConfigDict(extra="ignore")
def list_publish_squares(settings: PublishSquaresSettings) -> list[PublishSquare]:
"""Return the effective square list, always non-empty.
Falls back to a single built-in default square when the admin has not
configured any, so callers never have to special-case the empty state.
"""
if settings.squares:
return list(settings.squares)
return [PublishSquare(id=DEFAULT_SQUARE_ID, name="默认广场", description="")]
def resolve_default_square_id(settings: PublishSquaresSettings) -> str:
"""Resolve the effective default square id against the configured set."""
ids = [s.id for s in settings.squares]
if settings.default_square_id and settings.default_square_id in ids:
return settings.default_square_id
return ids[0] if ids else DEFAULT_SQUARE_ID
def normalize_square_id(square_id: str | None, settings: PublishSquaresSettings) -> str:
"""Coerce an arbitrary ``square_id`` to a valid configured square id.
Empty / unknown / orphaned (square later deleted) ids all collapse to the
effective default square, so a published item always resolves to a real
tab.
"""
default_id = resolve_default_square_id(settings)
if not square_id:
return default_id
valid = {s.id for s in list_publish_squares(settings)}
return square_id if square_id in valid else default_id
class LeaderboardSettings(BaseModel):
"""Admin-configurable defaults for the user leaderboard analytics."""
exclude_user_ids: list[str] = Field(default_factory=list, description="User ids excluded from leaderboard aggregation.")
include_scheduled: bool = Field(default=True, description="Whether scheduled task runs participate in leaderboard statistics.")
include_admins: bool = Field(default=True, description="Whether admin accounts participate in leaderboard statistics.")
include_failed: bool = Field(default=True, description="Whether failed runs and provider calls participate in leaderboard statistics.")
cleanup_system_questions: bool = Field(default=True, description="Hide attachment/system marker questions such as <uploaded_files>.")
model_config = ConfigDict(extra="ignore")
class SkillCompressionSettings(BaseModel):
"""Admin-configurable skill compression (技能压缩).
When ``enabled``, only skills explicitly marked 常驻(免压缩) (``always_on``)
are injected full-text into the lead agent's system prompt. Every other
enabled skill is "compressed": kept out of the prompt body and surfaced to
the agent on demand via the ``search_skills`` keyword-retrieval tool (and a
compact name index when ``keep_index_in_prompt`` is true). When disabled the
behaviour is unchanged — every enabled skill is injected full-text.
"""
enabled: bool = Field(default=False, description="Whether skill compression is active (master switch)")
search_top_n: int = Field(default=5, ge=1, le=50, description="Max skills returned by the search_skills tool")
keep_index_in_prompt: bool = Field(
default=True,
description="Whether compressed skills still appear as a compact name-only index in the prompt",
)
model_config = ConfigDict(extra="ignore")
class AgentModuleSettings(BaseModel):
"""Admin-configured「按类型聚合」module → selected agent-tag ids.
Keyed by module key (e.g. ``"zzjc"`` / ``"zzxd"``). Each value is the list
of agent **tag ids** whose agents that module's left sidebar shows. A missing
key / empty list means "not configured" — the frontend falls back to showing
all visible agents grouped by tag.
"""
modules: dict[str, list[str]] = Field(
default_factory=dict,
description="module key → selected agent tag ids",
)
model_config = ConfigDict(extra="ignore")
class WxCommonAgentsSettings(BaseModel):
"""The gallery category shown in the wx chat route's common-agent panel.
``None`` keeps the original recommendation logic. ``mine`` and
``sentiment`` are built-in category values. Publish-square categories are
persisted as ``square:<square_id>`` so they cannot collide with the
built-ins.
"""
agent_type: str | None = Field(
default=None,
description="Selected gallery category; null retains the default recommendation logic",
)
model_config = ConfigDict(extra="ignore")
class PluginDomainAgentMapping(BaseModel):
"""One configured host -> agent mapping for iframe chat auto-selection."""
id: str = Field(..., description="Stable row id for client-side editing")
domain: str = Field(..., description="Normalized host matched against pageContext.url")
agent_id: str = Field(..., description="Agent id selected when the host matches")
enabled: bool = Field(default=True, description="Whether this mapping is active")
model_config = ConfigDict(extra="ignore")
class PluginManagementSettings(BaseModel):
"""Admin-configurable iframe plugin mappings."""
domain_agent_mappings: list[PluginDomainAgentMapping] = Field(
default_factory=list,
description="Ordered host -> agent mappings used by the iframe chat entry page.",
)
model_config = ConfigDict(extra="ignore")
def get_module_tag_ids(settings: AgentModuleSettings, key: str) -> list[str]:
"""Return the configured agent-tag ids for ``key`` (empty when unset)."""
return list(settings.modules.get(key, []))
def set_module_tag_ids(settings: AgentModuleSettings, key: str, tag_ids: list[str]) -> AgentModuleSettings:
"""Return a copy with ``key`` set to a de-duplicated, stripped tag-id list."""
cleaned = list(dict.fromkeys([str(t).strip() for t in tag_ids if str(t).strip()]))
modules = dict(settings.modules)
modules[key] = cleaned
return settings.model_copy(update={"modules": modules})
class AuthenticationSettings(BaseModel):
"""Public login capabilities; administrator writes take effect immediately."""
password_login_enabled: bool = False
registration_enabled: bool = False
sso_enabled: bool = True
single_session_enabled: bool = False
model_config = ConfigDict(extra="forbid")
class LoginWhitelistSettings(BaseModel):
"""登录白名单总开关(管理员可在「白名单管理」页切换,即时生效、免重启)。
黑名单模式:``enabled`` 关(默认) → 忽略每个用户的 ``approved`` 放行标记,
人人可登录;开 → 仅被管理员显式「取消放行」(``approved=False``) 的用户被 403,
新账号默认 ``approved=True`` 故从未登录过的用户照样放行,admin 永远放行。
空/默认即关,故部署后行为零变化。
"""
enabled: bool = Field(default=False, description="是否启用登录白名单;关=所有人可登录,开=仅已放行用户(或 admin)可登录")
model_config = ConfigDict(extra="ignore")
class ConcurrencyMonitorSettings(BaseModel):
"""管理员「实时并发监控」开关(排行榜页的系统压力监控)。
``enabled`` 控制**后台并发采样器**是否持续把当前并发调用数写库(供并发量
折线图按分钟/小时聚合)。**默认关**(opt-in,由管理员在排行榜页手动开启),
以对线上服务零影响为先;开启后采样器才开始写库,关闭则立即停止(每 tick 都
重读该开关,免重启),实时快照查询与「停止」始终可用、零常驻成本。设计上即便
开启也对线上零影响:采样仅一条极小 INSERT、有保留期清理、聚合查询有行数上限、
全程 best-effort。
"""
enabled: bool = Field(default=False, description="是否启用后台并发采样(默认关;开→开始写库,关→停止写库、折线图不再新增历史点)")
model_config = ConfigDict(extra="ignore")
class ModelConcurrencySettings(BaseModel):
"""Admin-configurable per-model concurrent Q&A limits."""
enabled: bool = Field(
default=False,
description="Whether per-model concurrency limiting is enabled.",
)
default_limit: int = Field(
default=10,
ge=1,
le=10_000,
description="Fallback max concurrent runs for models without an explicit override.",
)
model_limits: dict[str, int] = Field(
default_factory=dict,
description="Optional per-model overrides: model_name -> max concurrent runs.",
)
user_default_limit: int = Field(
default=0,
ge=0,
le=10_000,
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="Optional per-user per-model overrides: model_name -> max concurrent runs for one user.",
)
model_config = ConfigDict(extra="ignore")
class MemoryRuntimeSettings(BaseModel):
"""Deployment-wide switch for generic/personal Q&A memory.
Memory is deliberately opt-in. When this setting is false the lead agent
does not mount memory middleware or memory tools, does not inject stored
generic memory into prompts, and does not enqueue generic background memory
extraction. Project-scoped Q&A memory is an independent subsystem governed
by each project's own setting and is intentionally not controlled here.
"""
enabled: bool = Field(
default=False,
description="Whether generic/personal Q&A memory recall, injection and persistence are enabled.",
)
model_config = ConfigDict(extra="ignore")
class ConversationContextSettings(BaseModel):
"""Deployment-wide short-context policy for chat model requests.
The recent-turn window is deliberately opt-in. When enabled, the lead
agent keeps the persisted conversation intact but sends only the latest
three real user turns (plus their assistant/tool continuations) to the
model. This avoids an extra summarization-model call while preserving the
complete transcript for the UI and checkpoint recovery.
"""
max_run_steps: int = Field(default=1000, ge=20, le=10000, strict=True)
recent_turn_window_enabled: bool = Field(
default=False,
description="Whether model requests use the fixed recent-three-turn 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 used as the final conversation title, "
"skipping post-answer LLM title generation."
),
)
model_config = ConfigDict(extra="ignore")
class WriteFileCompatibilitySettings(BaseModel):
"""Deployment-wide compatibility for gateways buffering tool arguments."""
enabled: bool = Field(
default=False,
description=(
"Whether every compatible model uses the buffered-gateway "
"write_file streaming adapter."
),
)
model_config = ConfigDict(extra="ignore")
class RetryKeepPartialSettings(BaseModel):
"""Keep already-streamed partial answers visible across silent LLM retries.
The LLM error middleware silently retries failed model calls. The failed
attempt never reaches the checkpoint, so the display reconciliation drops
its already-streamed partial answer — users see the text "cleared" when
the retry starts thinking again. Enabled keeps that partial visible in
the chat (frontend display concern, read at stream time).
"""
enabled: bool = Field(
default=True,
description=(
"Keep the already-streamed partial answer visible when a model "
"call is silently retried, instead of letting the display "
"reconciliation clear it."
),
)
model_config = ConfigDict(extra="ignore")
class RoundtableRoomLimitSettings(BaseModel):
"""Global runtime limits for multi-agent roundtable step 2.
The count is deployment-wide: every user's foreground step-2 session and
every non-terminal background roundtable job share one pool. One room is
one session, not one seat and not one model call. Turning the switch off
keeps recording occupancy but stops rejecting new entries.
"""
enabled: bool = Field(
default=True,
description="Whether a full roundtable room pool blocks entry into step 2.",
)
max_rooms: int = Field(
default=3,
ge=1,
le=20,
description="Maximum simultaneous step-2 roundtable rooms across all users.",
)
max_hold_minutes: int = Field(
default=30,
ge=1,
le=1440,
description=(
"Hard lifetime of one step-2 room. Heartbeats preserve network liveness "
"but never extend this deadline."
),
)
max_cycles: int = Field(
default=8,
ge=2,
le=100,
description=(
"Coordinator round that switches to tool-free final closeout. "
"The value is captured when an orchestration starts."
),
)
write_file_compatibility_enabled: bool = Field(
default=False,
description=(
"When true, roundtable leader/seat/step-3 runs use buffered-gateway "
"write_file streaming. Ordinary chat keeps the global switch."
),
)
model_config = ConfigDict(extra="ignore")
class LocalWikiEncoderSettings(BaseModel):
"""Runtime override for the administrator-managed offline Wiki encoder."""
mode: Literal["configured", "local"] = Field(
default="configured",
description=(
"Use the embedding provider from config.yaml, or explicitly override it "
"with the administrator-managed offline BGE-M3 encoder."
),
)
model_path: str = Field(
default="",
max_length=4096,
description="Absolute or project-relative directory containing the offline BGE-M3 ONNX model.",
)
model_revision: str = Field(
default="",
max_length=64,
description="Opaque revision changed whenever an offline model directory is activated.",
)
model_config = ConfigDict(extra="ignore")
class HtmlMapSettings(BaseModel):
"""Which map engine newly generated HTML pages should use."""
engine: Literal["echarts", "leaflet"] = Field(
default="echarts",
description="echarts uses local GeoJSON; leaflet is the tile-based map webpage",
)
model_config = ConfigDict(extra="ignore")
_APP_IDENTITY_LOGO_FILE_RE = re.compile(r"^logo-[a-f0-9]{32}\.(?:png|jpg|webp)$")
class AppIdentitySettings(BaseModel):
"""Name and logo shown in this application's own chrome.
Empty ``system_name`` and a missing logo keep the built-in title and the
static theme logos. This block is independent of generated-page branding.
"""
system_name: str = Field(default="", max_length=80)
logo_filename: str | None = None
logo_media_type: Literal["image/png", "image/jpeg", "image/webp"] | None = None
logo_version: str | None = None
model_config = ConfigDict(extra="ignore")
@field_validator("system_name")
@classmethod
def _normalize_app_system_name(cls, value: str | None) -> str:
normalized = " ".join(str(value or "").split())
return normalized
@field_validator("logo_filename")
@classmethod
def _validate_app_logo_filename(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = str(value).strip().lower()
if not _APP_IDENTITY_LOGO_FILE_RE.fullmatch(normalized):
raise ValueError("Invalid application logo filename")
return normalized
def app_identity_logo_path(
settings: AppIdentitySettings | None = None,
) -> Path | None:
"""Resolve the uploaded application logo without accepting an arbitrary path."""
current = settings or load_system_settings().app_identity
if not current.logo_filename:
return None
path = runtime_home() / "app-identity" / current.logo_filename
return path if path.is_file() else None
class HtmlRuntimeSettings(BaseModel):
"""Addresses written into newly generated HTML pages.
Empty strings mean the settings dialog has not saved this block. Readers
then keep an older config.yaml value, and finally the built-in default.
"""
base_url: str = Field(
default="",
max_length=2048,
description="Browser-reachable Gateway base for ECharts, G6, Leaflet and generated-page images",
)
map_tile_url: str = Field(
default="",
max_length=2048,
description="Leaflet tile template containing {z}, {x} and {y}",
)
model_config = ConfigDict(extra="ignore")
class StrategyProjectSettings(BaseModel):
"""Global visibility controls for the strategy-project experience."""
taiwan_strait_demo_enabled: bool = Field(
default=False,
description="Whether Taiwan Strait demo entry points are visible in My Projects.",
)
model_config = ConfigDict(extra="ignore")
class ModelPromptSuffix(BaseModel):
"""Administrator-owned instructions appended to one configured model's system prompt."""
enabled: bool = False
prompt: str = Field(default="", max_length=20000)
model_config = ConfigDict(extra="forbid")
class SystemSettings(BaseModel):
"""Top-level system settings document."""
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
model_prompt_suffixes: dict[str, ModelPromptSuffix] = Field(default_factory=dict)
login_whitelist: LoginWhitelistSettings = Field(default_factory=LoginWhitelistSettings)
authentication: AuthenticationSettings = Field(default_factory=AuthenticationSettings)
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
publish_squares: PublishSquaresSettings = Field(default_factory=PublishSquaresSettings)
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
skill_compression: SkillCompressionSettings = Field(default_factory=SkillCompressionSettings)
plugin_management: PluginManagementSettings = Field(default_factory=PluginManagementSettings)
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
wx_common_agents: WxCommonAgentsSettings = Field(default_factory=WxCommonAgentsSettings)
concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings)
model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings)
memory: MemoryRuntimeSettings = Field(default_factory=MemoryRuntimeSettings)
conversation_context: ConversationContextSettings = Field(default_factory=ConversationContextSettings)
write_file_compatibility: WriteFileCompatibilitySettings = Field(
default_factory=WriteFileCompatibilitySettings
)
retry_keep_partial: RetryKeepPartialSettings = Field(
default_factory=RetryKeepPartialSettings
)
roundtable_room_limit: RoundtableRoomLimitSettings = Field(default_factory=RoundtableRoomLimitSettings)
local_wiki_encoder: LocalWikiEncoderSettings = Field(default_factory=LocalWikiEncoderSettings)
strategy_projects: StrategyProjectSettings = Field(default_factory=StrategyProjectSettings)
html_map: HtmlMapSettings = Field(default_factory=HtmlMapSettings)
html_runtime: HtmlRuntimeSettings = Field(default_factory=HtmlRuntimeSettings)
app_identity: AppIdentitySettings = Field(default_factory=AppIdentitySettings)
llmwiki: LlmWikiRuntimeOverride = Field(default_factory=LlmWikiRuntimeOverride)
skill_knowledge_auto_sync: SkillKnowledgeAutoSyncSettings = Field(default_factory=SkillKnowledgeAutoSyncSettings)
knowledge_base_product_version: Literal["v1", "v2"] = Field(
default="v1",
description=(
"v1 keeps assistant-knowledge day-to-day surfaces admin-only; "
"v2 releases them to ordinary users while ordinary knowledge remains the default landing."
),
)
ordinary_knowledge_landing: Literal["wiki", "weknora"] = "wiki"
model_config = ConfigDict(extra="ignore")
def _settings_path() -> Path:
home = runtime_home()
home.mkdir(parents=True, exist_ok=True)
return home / _SETTINGS_FILE
# ── mtime 缓存 ────────────────────────────────────────────────────────────
# ``load_system_settings`` 处在登录等热路径上(一次登录会读 3 次:登录方式
# 开关、白名单、单会话策略),而每次调用都是「整文件读盘 + JSON 解析 +
# pydantic 校验」。按 (路径, mtime) 缓存解析结果:文件未变直接返回缓存值的
# 深拷贝(调用方可能就地改写返回值,不能共享实例);任何写入——
# ``save_system_settings`` 的原子替换——都会更新 mtime,下一次调用自动重读,
# 无需显式清缓存。
_settings_cache_key: tuple[str, int] | None = None
_settings_cache_value: SystemSettings | None = None
def load_system_settings() -> SystemSettings:
"""Read the on-disk system settings, returning defaults when missing.
Parsed results are cached by (path, mtime); unchanged files return a deep
copy so callers may safely mutate the returned model without poisoning the
shared cache.
"""
global _settings_cache_key, _settings_cache_value
path = _settings_path()
try:
mtime = path.stat().st_mtime_ns
except OSError:
return SystemSettings()
key = (str(path), mtime)
if _settings_cache_value is not None and _settings_cache_key == key:
return _settings_cache_value.model_copy(deep=True)
if not path.is_file():
return SystemSettings()
try:
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
logger.warning("System settings file %s is not a JSON object; ignoring", path)
return SystemSettings()
settings = SystemSettings.model_validate(raw)
except Exception as exc:
logger.warning("Failed to read system settings from %s: %s", path, exc)
return SystemSettings()
_settings_cache_key = key
_settings_cache_value = settings
return settings.model_copy(deep=True)
def get_skill_compression_settings() -> SkillCompressionSettings:
"""Convenience accessor for the skill-compression block (safe defaults on error)."""
try:
return load_system_settings().skill_compression
except Exception:
logger.debug("Failed to read skill compression settings; using defaults", exc_info=True)
return SkillCompressionSettings()
def get_concurrency_monitor_settings() -> ConcurrencyMonitorSettings:
"""Convenience accessor for the concurrency-monitor block (safe defaults on error)."""
try:
return load_system_settings().concurrency_monitor
except Exception:
logger.debug("Failed to read concurrency monitor settings; using defaults", exc_info=True)
return ConcurrencyMonitorSettings()
def get_model_concurrency_settings() -> ModelConcurrencySettings:
"""Convenience accessor for the model-concurrency block (safe defaults on error)."""
try:
return load_system_settings().model_concurrency
except Exception:
logger.debug("Failed to read model concurrency settings; using defaults", exc_info=True)
return ModelConcurrencySettings()
def get_memory_runtime_settings() -> MemoryRuntimeSettings:
"""Return the admin-controlled Q&A memory switch, failing closed."""
try:
return load_system_settings().memory
except Exception:
logger.debug("Failed to read memory runtime setting; keeping memory disabled", exc_info=True)
return MemoryRuntimeSettings()
def get_conversation_context_settings() -> ConversationContextSettings:
"""Return the admin-controlled recent-turn policy, failing closed."""
try:
return load_system_settings().conversation_context
except Exception:
logger.debug("Failed to read conversation context setting; keeping the recent-turn window disabled", exc_info=True)
return ConversationContextSettings()
def get_write_file_compatibility_settings() -> WriteFileCompatibilitySettings:
"""Return the global buffered-gateway switch, failing closed."""
try:
return load_system_settings().write_file_compatibility
except Exception:
logger.debug(
"Failed to read write_file compatibility setting; keeping native transport",
exc_info=True,
)
return WriteFileCompatibilitySettings()
def get_roundtable_room_limit_settings() -> RoundtableRoomLimitSettings:
"""Return the admin-controlled roundtable room cap, defaulting to 3 enabled rooms."""
try:
return load_system_settings().roundtable_room_limit
except Exception:
logger.debug("Failed to read roundtable room limit; keeping the default cap", exc_info=True)
return RoundtableRoomLimitSettings()
def save_system_settings(settings: SystemSettings) -> SystemSettings:
"""Persist the settings document atomically, returning the saved value."""
global _settings_cache_key, _settings_cache_value
path = _settings_path()
payload = settings.model_dump(mode="json")
with _lock:
# Multiple Gateway workers share this file. A unique temporary name
# prevents their atomic replaces from racing over one common `.tmp`.
tmp = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
try:
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
finally:
tmp.unlink(missing_ok=True)
# mtime 变化本就足以让缓存失效;这里显式清一次,兜底个别文件系统
# mtime 粒度过粗(同一秒内连续保存两次)读回旧值的极端情况。
_settings_cache_key = None
_settings_cache_value = None
return settings