diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx
index 1f7554d8..1257ab58 100644
--- a/frontend-web/src/App.tsx
+++ b/frontend-web/src/App.tsx
@@ -26,18 +26,20 @@ function AppProviders({ children }: { children: React.ReactNode }) {
-
-
-
- {children}
-
-
-
+ {/* QueryClientProvider must wrap ThemeProvider because the custom
+ ThemeProvider reads the admin-configured default via useQuery. */}
+
+
+
+ {children}
+
+
+
);
}
diff --git a/frontend-web/src/contexts/ThemeContext.tsx b/frontend-web/src/contexts/ThemeContext.tsx
index 55cda911..0aa7de26 100644
--- a/frontend-web/src/contexts/ThemeContext.tsx
+++ b/frontend-web/src/contexts/ThemeContext.tsx
@@ -7,6 +7,8 @@ import React, {
} from 'react';
import { useTheme as useNextTheme } from 'next-themes';
+import { useAppearanceSettings } from '@/core/system-settings/hooks';
+
// 主题类型
export type ThemeType = 'dark' | 'light';
@@ -83,13 +85,29 @@ const themes: Record = {
};
const STORAGE_KEY = 'strategy-theme';
+// Marker that the user has explicitly toggled the theme (via the appearance
+// settings page or any future toggle UI). When this flag is absent we treat
+// any value stored in ``strategy-theme`` as one we put there ourselves while
+// syncing the admin's default — so we'll re-sync on every boot, picking up
+// admin changes. When the flag is present we never override the user.
+const EXPLICIT_FLAG_KEY = 'strategy-theme-user-set';
function readPersistedTheme(): ThemeType {
if (typeof window === 'undefined') {
- return 'dark';
+ return 'light';
}
const saved = localStorage.getItem(STORAGE_KEY);
- return saved === 'light' ? 'light' : 'dark';
+ return saved === 'dark' ? 'dark' : 'light';
+}
+
+function hasExplicitChoice(): boolean {
+ if (typeof window === 'undefined') return false;
+ return localStorage.getItem(EXPLICIT_FLAG_KEY) === '1';
+}
+
+function markExplicitChoice(): void {
+ if (typeof window === 'undefined') return;
+ localStorage.setItem(EXPLICIT_FLAG_KEY, '1');
}
// 主题上下文
@@ -112,42 +130,68 @@ interface ThemeProviderProps {
export const ThemeProvider: React.FC = ({ children }) => {
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
const [mounted, setMounted] = useState(false);
+ // Admin-configured global default. If the user hasn't explicitly chosen
+ // a theme yet, this value drives the rendered theme — and re-syncs on
+ // every mount so an admin change propagates without users clearing
+ // localStorage by hand.
+ const { data: appearance } = useAppearanceSettings();
useEffect(() => {
setMounted(true);
}, []);
- // 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色
+ // 迁移 / 规范化 localStorage:去掉旧的 'theme' 键,并清掉一切非法值。
+ // 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作
+ // 「用户已选过」,admin 默认就再也注入不进来了。
useEffect(() => {
if (!mounted) return;
const current = localStorage.getItem(STORAGE_KEY);
const legacy = localStorage.getItem('theme');
- if (current !== 'light' && current !== 'dark') {
- const fallback =
- legacy === 'light' || legacy === 'dark' ? legacy : 'dark';
- setNextTheme(fallback);
- } else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
- setNextTheme(legacy);
+ if (current && current !== 'light' && current !== 'dark') {
+ // Garbage value — wipe it so the boot-sync below can take over.
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
+ // Migrate the legacy key as the user's explicit choice (it was set
+ // long enough ago that we should preserve their pick rather than
+ // override with admin default).
+ setNextTheme(legacy);
+ markExplicitChoice();
}
-
if (legacy) {
localStorage.removeItem('theme');
}
}, [mounted, setNextTheme]);
+ // Boot-time sync: when the user has never picked a theme themselves,
+ // apply whatever the admin configured. Re-runs whenever the admin
+ // setting changes (so a logged-in user sees the new default after
+ // refreshing without having to clear cookies).
+ useEffect(() => {
+ if (!mounted) return;
+ if (hasExplicitChoice()) return;
+ const adminDefault = appearance?.default_theme;
+ if (!adminDefault) return;
+ if (resolvedTheme !== adminDefault) {
+ setNextTheme(adminDefault);
+ }
+ }, [mounted, appearance?.default_theme, resolvedTheme, setNextTheme]);
+
const theme: ThemeType = mounted
- ? resolvedTheme === 'light'
- ? 'light'
- : 'dark'
+ ? resolvedTheme === 'dark'
+ ? 'dark'
+ : 'light'
: readPersistedTheme();
const toggleTheme = () => {
setNextTheme(theme === 'dark' ? 'light' : 'dark');
+ markExplicitChoice();
};
const setTheme = (newTheme: ThemeType) => {
setNextTheme(newTheme);
+ markExplicitChoice();
};
const themeConfig = themes[theme];
diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts
index 697e88c5..e6335706 100644
--- a/frontend-web/src/core/system-settings/api.ts
+++ b/frontend-web/src/core/system-settings/api.ts
@@ -65,6 +65,48 @@ export async function loadPrivateSquareStatus(): Promise {
return (await response.json()) as PrivateSquareStatus;
}
+export type ThemeMode = "light" | "dark";
+
+export interface AppearanceSettings {
+ default_theme: ThemeMode;
+}
+
+export interface AppearanceUpdate {
+ default_theme?: ThemeMode;
+}
+
+export async function loadAppearanceSettings(): Promise {
+ const response = await apiFetch(
+ `${getBackendBaseURL()}/api/system-settings/appearance`,
+ );
+ if (!response.ok) {
+ throw new Error(
+ `Failed to load appearance settings: ${response.status}`,
+ );
+ }
+ return (await response.json()) as AppearanceSettings;
+}
+
+export async function updateAppearanceSettings(
+ body: AppearanceUpdate,
+): Promise {
+ const response = await apiFetch(
+ `${getBackendBaseURL()}/api/system-settings/appearance`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!response.ok) {
+ const detail = await response
+ .json()
+ .catch(() => ({ detail: "Failed to update appearance" }));
+ throw new Error(detail.detail ?? "Failed to update appearance");
+ }
+ return (await response.json()) as AppearanceSettings;
+}
+
export async function updatePrivateSquareSettings(
body: PrivateSquareUpdate,
): Promise {
diff --git a/frontend-web/src/core/system-settings/hooks.ts b/frontend-web/src/core/system-settings/hooks.ts
index 9c2ee5fd..903737a5 100644
--- a/frontend-web/src/core/system-settings/hooks.ts
+++ b/frontend-web/src/core/system-settings/hooks.ts
@@ -1,16 +1,20 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
+ loadAppearanceSettings,
loadPrivateSquareStatus,
loadPromptPrefixSettings,
+ updateAppearanceSettings,
updatePrivateSquareSettings,
updatePromptPrefixSettings,
+ type AppearanceUpdate,
type PrivateSquareUpdate,
type PromptPrefixUpdate,
} from "./api";
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
+const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
export function usePromptPrefixSettings() {
return useQuery({
@@ -51,3 +55,25 @@ export function useUpdatePrivateSquareSettings() {
},
});
}
+
+export function useAppearanceSettings() {
+ return useQuery({
+ queryKey: APPEARANCE_KEY,
+ queryFn: () => loadAppearanceSettings(),
+ staleTime: 60_000,
+ refetchOnWindowFocus: false,
+ // The provider runs even on the login page (where the user has no auth)
+ // — gracefully tolerate the fetch failing rather than retrying.
+ retry: false,
+ });
+}
+
+export function useUpdateAppearanceSettings() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (body: AppearanceUpdate) => updateAppearanceSettings(body),
+ onSuccess: (data) => {
+ queryClient.setQueryData(APPEARANCE_KEY, data);
+ },
+ });
+}
diff --git a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py
index 48940387..aadb877e 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
@@ -124,3 +125,42 @@ async def update_private_square(body: PrivateSquareUpdateRequest, request: Reque
enabled=saved.private_square.enabled,
password_set=bool(saved.private_square.password_hash),
)
+
+
+class AppearanceResponse(BaseModel):
+ """Effective system-wide appearance defaults."""
+
+ default_theme: Literal["light", "dark"] = Field(..., description="System default theme — light or dark")
+
+
+class AppearanceUpdateRequest(BaseModel):
+ default_theme: Literal["light", "dark"] | None = Field(default=None)
+
+
+@router.get("/appearance", response_model=AppearanceResponse)
+async def get_appearance_settings() -> AppearanceResponse:
+ """Return the global default theme. Any logged-in user may read this."""
+ settings = load_system_settings().appearance
+ return AppearanceResponse(default_theme=settings.default_theme)
+
+
+@router.put("/appearance", response_model=AppearanceResponse)
+async def update_appearance_settings(
+ body: AppearanceUpdateRequest, request: Request
+) -> AppearanceResponse:
+ """Update the global appearance defaults. Admin only."""
+ await _require_admin(request)
+
+ current = load_system_settings()
+ fields = body.model_dump(exclude_unset=True)
+ update: dict[str, object] = {}
+ if "default_theme" in fields and fields["default_theme"] is not None:
+ update["default_theme"] = fields["default_theme"]
+ if update:
+ updated_appearance = current.appearance.model_copy(update=update)
+ saved = save_system_settings(
+ current.model_copy(update={"appearance": updated_appearance})
+ )
+ else:
+ saved = current
+ return AppearanceResponse(default_theme=saved.appearance.default_theme)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py
index 0a2d41ea..67e1a4d9 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py
@@ -17,6 +17,7 @@ import json
import logging
import threading
from pathlib import Path
+from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -37,6 +38,22 @@ class PromptPrefixSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
+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: Literal["light", "dark"] = Field(
+ default="light",
+ description="Theme applied for users who haven't picked their own (light / dark)",
+ )
+ model_config = ConfigDict(extra="ignore")
+
+
class PrivateSquareSettings(BaseModel):
"""Admin-configurable settings for the agent private-square (私密广场).
@@ -56,6 +73,7 @@ class SystemSettings(BaseModel):
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
+ appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
model_config = ConfigDict(extra="ignore")