功能:完善自定义主题与独立登录会话
This commit is contained in:
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)
|
||||
|
||||
|
||||
+122
-6
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user