功能:完善自定义主题与独立登录会话

This commit is contained in:
董宏凯 committed 2026-07-26 18:19:50 +08:00
1 parent 3779bb69ef
commit f1e53ef5c0
23 files changed
+1742 -196

No files matched your search

@@ -1003,6 +1003,22 @@ When using `make dev` from root, the frontend automatically connects through ngi
## Key Features
### Administrator-managed custom themes
`system_settings.json` → `appearance` now stores `custom_themes` alongside
the built-in default theme. A row is `CustomThemePreset` (`id`, `name`,
six-digit `primary`, optional primary-button `text_color`, `mode`, `overrides`, `enabled`,
`sort_order`). `overrides` is intentionally a short interaction-only list:
`button_hover`, `selected_background`, `border`, `muted`, `muted_text`, `destructive`, `brand_title`, and `primary_foreground`.
All values must be six-digit hex strings; the frontend derives every remaining
semantic CSS/TDesign token so admins do not maintain a full raw token sheet.
Ids must be `custom-theme-*`, names and ids are unique, and the router limits
the list to 12. `GET/PUT /api/system-settings/appearance` is the single
read/write surface: reads are available to all users, writes require admin.
The router re-orders rows deterministically and only permits an enabled custom
theme as `default_theme`; removing or disabling the default safely falls back
to `light`. Regression coverage: `tests/test_appearance_theme.py`.
### File Upload
Multi-file upload with automatic document conversion:
@@ -414,6 +414,19 @@ make format # Format code (ruff)
uv run pytest
```
### Runtime appearance themes
The admin appearance settings are persisted in `.deer-flow/system_settings.json`.
Besides the built-in themes, an administrator can create up to 12 runtime
themes through `PUT /api/system-settings/appearance`. Each theme stores a
safe six-digit `primary` colour, an optional primary-button `text_color`,
`light`/`dark` mode, name, enabled flag and sort order. The frontend derives
the rest of the visual tokens; the optional `overrides` object only exposes
eight interaction colours (`button_hover`, `selected_background`, `border`,
`muted`, `muted_text`, `destructive`, `brand_title`, `primary_foreground`) for exceptional brand requirements. A custom theme id
must use the `custom-theme-` prefix. Disabled or removed custom themes cannot
remain the system default and automatically fall back to `light`.
---
## Technology Stack
@@ -14,8 +14,10 @@ from app.gateway.auth.password import hash_password_async
from app.gateway.deps import get_optional_user_from_request
from deerflow.config import get_app_config
from deerflow.config.system_settings import (
BUILTIN_THEME_IDS,
AppearanceRouteParam,
CompactShortcutButton,
CustomThemePreset,
LeaderboardSettings,
ModelConcurrencySettings,
PluginDomainAgentMapping,
@@ -24,7 +26,6 @@ from deerflow.config.system_settings import (
PublishSquare,
PublishSquaresSettings,
SkillCompressionSettings,
SystemSettings,
get_module_tag_ids,
list_publish_squares,
load_system_settings,
@@ -173,9 +174,7 @@ async def get_plugin_management() -> PluginManagementResponse:
@router.put("/plugin-management", response_model=PluginManagementResponse)
async def update_plugin_management(
body: PluginManagementUpdateRequest, request: Request
) -> PluginManagementResponse:
async def update_plugin_management(body: PluginManagementUpdateRequest, request: Request) -> PluginManagementResponse:
"""Replace the iframe plugin domain -> agent mappings. Admin only."""
from uuid import uuid4
@@ -201,12 +200,8 @@ async def update_plugin_management(
)
)
updated = current.plugin_management.model_copy(
update={"domain_agent_mappings": mappings}
)
saved = save_system_settings(
current.model_copy(update={"plugin_management": updated})
)
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)
@@ -395,15 +390,9 @@ async def update_citation_display(
await _require_admin(request)
current = load_system_settings()
words = [
str(item).strip()
for item in (body.cleanup_words or [])
if str(item).strip()
]
words = [str(item).strip() for item in (body.cleanup_words or []) if str(item).strip()]
updated = current.citation_display.model_copy(update={"cleanup_words": words})
saved = save_system_settings(
current.model_copy(update={"citation_display": updated})
)
saved = save_system_settings(current.model_copy(update={"citation_display": updated}))
return CitationDisplayResponse(cleanup_words=saved.citation_display.cleanup_words)
@@ -536,21 +525,21 @@ async def update_publish_squares(body: PublishSquaresUpdateRequest, request: Req
class AppearanceResponse(BaseModel):
"""Effective system-wide appearance defaults."""
default_theme: Literal["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"] = Field(
..., description="System default theme — light, dark, lightblue, lightgreen, iceblue or lightyellow"
)
default_theme: str = Field(..., description="System default theme — built-in id or enabled custom-theme-* id")
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
default_layout_mode: Literal["full", "compact"] = Field(..., description="System default interface layout — full (sidebar + header) or compact (hidden)")
compact_shortcut_buttons: list[CompactShortcutButton] = Field(default_factory=list, description="Shortcut buttons rendered in top bars")
custom_themes: list[CustomThemePreset] = Field(default_factory=list, description="Administrator-managed runtime theme presets")
class AppearanceUpdateRequest(BaseModel):
default_theme: Literal["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"] | None = Field(default=None)
default_theme: str | None = Field(default=None)
default_skill_view: Literal["card", "list"] | None = Field(default=None)
default_agent_view: Literal["card", "list"] | None = Field(default=None)
default_layout_mode: Literal["full", "compact"] | None = Field(default=None)
compact_shortcut_buttons: list[CompactShortcutButton] | None = Field(default=None)
custom_themes: list[CustomThemePreset] | None = Field(default=None)
def _normalize_compact_shortcut_buttons(
@@ -576,8 +565,7 @@ def _normalize_compact_shortcut_buttons(
AppearanceRouteParam(
key=key,
paramType="theme",
themeValue=(param.themeValue or "").strip()
or "red=red;green=green;blue=blue;dark-blue=dark-blue",
themeValue=(param.themeValue or "").strip() or "red=red;green=green;blue=blue;dark-blue=dark-blue",
)
)
continue
@@ -588,9 +576,7 @@ def _normalize_compact_shortcut_buttons(
key=key,
paramType="login",
source=source,
customValue=(param.customValue or "").strip()
if source == "other"
else None,
customValue=(param.customValue or "").strip() if source == "other" else None,
)
)
@@ -617,9 +603,46 @@ def _appearance_response(appearance) -> AppearanceResponse:
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."""
@@ -627,27 +650,26 @@ async def get_appearance_settings() -> AppearanceResponse:
@router.put("/appearance", response_model=AppearanceResponse)
async def update_appearance_settings(
body: AppearanceUpdateRequest, request: Request
) -> 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_theme", "default_skill_view", "default_agent_view", "default_layout_mode"):
for key in ("default_skill_view", "default_agent_view", "default_layout_mode"):
if key in fields and fields[key] is not None:
update[key] = fields[key]
if "compact_shortcut_buttons" in fields and body.compact_shortcut_buttons is not None:
update["compact_shortcut_buttons"] = _normalize_compact_shortcut_buttons(
body.compact_shortcut_buttons
)
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})
)
saved = save_system_settings(current.model_copy(update={"appearance": updated_appearance}))
else:
saved = current
return _appearance_response(saved.appearance)
@@ -723,9 +745,7 @@ async def get_agent_module_config(key: str) -> AgentModuleConfigResponse:
@router.put("/agent-modules/{key}", response_model=AgentModuleConfigResponse)
async def update_agent_module_config(
key: str, body: AgentModuleConfigUpdateRequest, request: Request
) -> 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)
@@ -15,12 +15,13 @@ 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
from pydantic import BaseModel, ConfigDict, Field, field_validator
from deerflow.config.runtime_paths import runtime_home
@@ -29,6 +30,111 @@ 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")
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",
"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")
class PromptPrefixSettings(BaseModel):
"""Admin-configurable first-message prompt prefix."""
@@ -102,9 +208,9 @@ class AppearanceSettings(BaseModel):
toggle, and any future first-time loads.
"""
default_theme: Literal["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"] = Field(
default_theme: str = Field(
default="light",
description="Theme applied for users who haven't picked their own (light / dark / lightblue / lightgreen / iceblue / lightyellow)",
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",
@@ -122,8 +228,20 @@ class AppearanceSettings(BaseModel):
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")
@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 cleanup rules for citation cards and detail dialogs."""
@@ -290,9 +408,7 @@ def get_module_tag_ids(settings: AgentModuleSettings, key: str) -> list[str]:
return list(settings.modules.get(key, []))
def set_module_tag_ids(
settings: AgentModuleSettings, key: str, tag_ids: list[str]
) -> AgentModuleSettings:
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)