1038 lines
43 KiB
Python
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
|