diff --git a/frontend-web/src/components/ui/button.tsx b/frontend-web/src/components/ui/button.tsx index 836b8705..6baca704 100644 --- a/frontend-web/src/components/ui/button.tsx +++ b/frontend-web/src/components/ui/button.tsx @@ -10,7 +10,7 @@ const buttonVariants = cva( variants: { variant: { default: - "cursor-pointer bg-primary text-primary-foreground hover:bg-primary/90", + "cursor-pointer bg-primary text-[var(--button-foreground,var(--primary-foreground))] hover:bg-primary/90", destructive: "cursor-pointer bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: diff --git a/frontend-web/src/components/workspace/agent-icon-color.ts b/frontend-web/src/components/workspace/agent-icon-color.ts index b035cda0..56b7e693 100644 --- a/frontend-web/src/components/workspace/agent-icon-color.ts +++ b/frontend-web/src/components/workspace/agent-icon-color.ts @@ -47,10 +47,18 @@ type AgentAvatarTheme = | "lightyellow" | "light" | "lightblue" - | "lightgreen"; + | "lightgreen" + | `custom-theme-${string}`; /** Returns a stable avatar gradient and readable icon color for each theme. */ export function agentColor(id: string, theme: AgentAvatarTheme) { + if (theme.startsWith("custom-theme-")) { + return { + from: "var(--accent)", + to: "var(--brand-200)", + foreground: "var(--accent-foreground)", + }; + } let hash = 0; for (let i = 0; i < id.length; i++) { hash = (hash * 31 + id.charCodeAt(i)) >>> 0; diff --git a/frontend-web/src/components/workspace/appearance-controls.tsx b/frontend-web/src/components/workspace/appearance-controls.tsx index decf3371..03f8f81d 100644 --- a/frontend-web/src/components/workspace/appearance-controls.tsx +++ b/frontend-web/src/components/workspace/appearance-controls.tsx @@ -1,7 +1,7 @@ "use client"; import { Check, ExternalLink, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; @@ -84,8 +84,23 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone } export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) { const { theme, setTheme } = useTheme(); + const { data: appearance } = useAppearanceSettings(); const [open, setOpen] = useState(false); const closeTimer = useRef | null>(null); + const swatches = useMemo( + () => [ + ...THEME_SWATCHES, + ...(appearance?.custom_themes ?? []) + .filter((preset) => preset.enabled) + .sort((a, b) => a.sort_order - b.sort_order) + .map((preset) => ({ + id: preset.id as ThemeType, + label: preset.name, + color: preset.primary, + })), + ], + [appearance?.custom_themes], + ); const cancelClose = () => { if (closeTimer.current) { @@ -124,9 +139,9 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) { {open ? (
-
-
- {THEME_SWATCHES.map((swatch) => { +
+
+ {swatches.map((swatch) => { const active = theme === swatch.id; return (
@@ -617,26 +647,40 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { 浅黄色 -

- 当前系统默认: - {globalDefault === "light" - ? "浅色" - : globalDefault === "dark" - ? "深色" - : globalDefault === "lightgreen" - ? "绿色" - : globalDefault === "iceblue" - ? "冰川蓝" - : globalDefault === "lightyellow" - ? "浅黄色" - : "浅蓝"} -

+ +

+ 当前系统默认:{globalDefaultLabel} +

- - )} - - {isAdmin && ( + + )} + + {isAdmin && ( +
+ + + + +
+ )} + + {isAdmin && ( <> >; - label: string; - description: string; - active: boolean; - mode: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow"; - onSelect: (mode: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow") => void; -}) { - const previewMode = mode; + description, + active, + mode, + previewDark, + accentColor, + onSelect, +}: { + icon: ComponentType>; + label: string; + description: string; + active: boolean; + mode: ThemeType; + previewDark?: boolean; + accentColor?: string; + onSelect: (mode: ThemeType) => void; +}) { + const previewMode = mode; + const isDarkPreview = previewDark ?? previewMode === "dark"; return ( + {open && ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + setPendingValue(normalizeHexColor(String(color), fallback))} + /> +
+ + +
+
+ )} +
+ ); +} + +function CustomThemePreview({ theme }: { theme: CustomThemePreset }) { + const tokens = getCustomThemeTokens(theme) as CSSProperties; + const buttonContrast = customThemeTextContrast(theme); + const isButtonContrastLow = buttonContrast < 4.5; + + return ( +
+
+ + C + + + 实时预览 + + + 主按钮对比度 {buttonContrast.toFixed(1)}:1 + +
+
+
+
+ {theme.name || "未命名主题"} +
+
+ 页面正文自动生成;主按钮文字可单独指定。 +
+
+ + {isButtonContrastLow && ( +

+ 当前按钮文字会按您的颜色设置展示,但对比度低于 4.5:1,建议改用更深的主色或恢复自动。 +

+ )} +
+
+ ); +} + +function AdvancedThemeColorControls({ + theme, + onPatch, +}: { + theme: CustomThemePreset; + onPatch: (patch: Partial) => void; +}) { + const automaticTokens = getCustomThemeAutomaticTokens({ + primary: theme.primary, + mode: theme.mode, + }); + + const setOverride = (key: CustomThemeAdvancedKey, value: string) => { + const overrides = { ...(theme.overrides ?? {}) }; + if (!value.trim()) { + delete overrides[key]; + } else { + overrides[key] = value; + } + onPatch({ overrides }); + }; + + return ( +
+
+
+
更多配色
+

+ 默认由主色自动推导。仅在品牌规范有明确要求时,选择单项覆盖;“恢复自动”会重新跟随主色。 +

+
+ + 已覆盖 {Object.keys(theme.overrides ?? {}).length} 项 + +
+
+ {ADVANCED_COLOR_FIELDS.map((field) => { + const explicit = theme.overrides?.[field.key]; + const automatic = automaticTokens[field.token] ?? "#0062D9"; + const value = explicit ?? automatic; + return ( +
+
+
+
{field.label}
+
+ {field.description} +
+
+ {explicit ? ( + + ) : ( + + 自动 + + )} +
+
+ setOverride(field.key, color)} + ariaLabel={`${theme.name || "自定义主题"}${field.label}选色器`} + /> + setOverride(field.key, event.target.value)} + onBlur={(event) => + setOverride( + field.key, + event.target.value.trim() + ? normalizeHexColor(event.target.value) + : "", + ) + } + aria-label={`${theme.name || "自定义主题"}${field.label}十六进制值`} + /> +
+ {!explicit && ( +

+ 当前自动值:{automatic} +

+ )} +
+ ); + })} +
+
+ ); +} + +export function CustomThemeLibrarySettings() { + const { data: appearance } = useAppearanceSettings(); + const updateAppearance = useUpdateAppearanceSettings(); + const [drafts, setDrafts] = useState([]); + const [advancedThemeIds, setAdvancedThemeIds] = useState>( + () => new Set(), + ); + + useEffect(() => { + setDrafts(normalizeThemeDrafts(appearance?.custom_themes)); + }, [appearance?.custom_themes]); + + const hasChanges = useMemo( + () => + JSON.stringify(normalizeThemeDrafts(appearance?.custom_themes)) !== + JSON.stringify(normalizeThemeDrafts(drafts)), + [appearance?.custom_themes, drafts], + ); + + const patchTheme = (id: string, patch: Partial) => { + setDrafts((items) => + items.map((item) => (item.id === id ? { ...item, ...patch } : item)), + ); + }; + + const toggleAdvancedTheme = (id: string) => { + setAdvancedThemeIds((ids) => { + const next = new Set(ids); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleSave = async () => { + const cleaned = normalizeThemeDrafts(drafts).map((theme) => ({ + ...theme, + name: theme.name.trim(), + primary: normalizeHexColor(theme.primary), + })); + if (cleaned.some((theme) => !theme.name)) { + toast.error("请为每个主题填写名称"); + return; + } + const names = cleaned.map((theme) => theme.name.toLocaleLowerCase()); + if (new Set(names).size !== names.length) { + toast.error("主题名称不能重复"); + return; + } + + try { + await updateAppearance.mutateAsync({ custom_themes: cleaned }); + toast.success("自定义主题库已保存,所有用户可在主题切换器中选择已启用主题"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存主题库失败"); + } + }; + + const handleSetDefault = async (theme: CustomThemePreset) => { + if (!theme.enabled || appearance?.default_theme === theme.id) return; + if (!(appearance?.custom_themes ?? []).some((preset) => preset.id === theme.id)) { + toast.info("请先保存主题库,再将新主题设为系统默认"); + return; + } + try { + await updateAppearance.mutateAsync({ default_theme: theme.id }); + toast.success(`已将“${theme.name || "自定义主题"}”设为系统默认主题`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "设置系统默认主题失败"); + } + }; + + return ( +
+
+

+ 默认只需设置主色、主按钮文字与明暗模式;系统会自动生成页面正文、背景、边框、弱化、选中和悬停色。点击“更多”后才按需覆盖个别交互色,停用后不再向用户展示,最多可保存 {MAX_CUSTOM_THEMES} 个。 +

+
+ + +
+
+ + {drafts.length === 0 ? ( +
+ +
还没有自定义主题
+

+ 添加后,用户可以从右上角主题切换器中自行选择。 +

+
+ ) : ( +
+ {drafts.map((theme, index) => { + const isDefault = appearance?.default_theme === theme.id; + const advancedOpen = advancedThemeIds.has(theme.id); + const automaticTokens = getCustomThemeAutomaticTokens({ + primary: theme.primary, + mode: theme.mode, + }); + const textColor = theme.text_color ?? automaticTokens["--foreground"] ?? "#202A3A"; + return ( +
+
+
+ + 主题 {index + 1} + {isDefault && ( + + + 系统默认 + + )} +
+
+ + + + +
+
+ +
+ + + +
+
+ 主按钮文字 + {theme.text_color ? ( + + ) : ( + 自动 + )} +
+ + patchTheme(theme.id, { text_color: event.target.value }) + } + onBlur={(event) => + patchTheme(theme.id, { + text_color: event.target.value.trim() + ? normalizeHexColor(event.target.value) + : null, + }) + } + aria-label={`${theme.name || "自定义主题"}主按钮文字十六进制值`} + /> +
+
+ +
+
+ 选择主色 + patchTheme(theme.id, { primary })} + ariaLabel={`${theme.name || "自定义主题"}主色选色器`} + /> +
+
+ 选择主按钮文字 + patchTheme(theme.id, { text_color })} + ariaLabel={`${theme.name || "自定义主题"}主按钮文字选色器`} + /> +
+
+ {SUGGESTED_COLORS.map((color) => ( +
+
+ + {advancedOpen && ( +
+ patchTheme(theme.id, patch)} + /> +
+ )} + + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 1c93ecec..9fef27ae 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -184,7 +184,7 @@ export function WorkspaceNavChatList({ - + {/* - 岗位会商 + 岗位会商1 - + */} = { +const themes: Record = { dark: { type: 'dark', name: '深色主题', @@ -183,16 +189,30 @@ const STORAGE_KEY = 'strategy-theme'; // admin changes. When the flag is present we never override the user. const EXPLICIT_FLAG_KEY = 'strategy-theme-user-set'; +const CUSTOM_THEME_TOKEN_NAMES = Object.keys( + getCustomThemeTokens({ primary: '#0062D9', mode: 'light' }), +); + +function isBuiltinTheme(value: string | null | undefined): value is BuiltinThemeId { + return typeof value === 'string' && (BUILTIN_THEME_IDS as readonly string[]).includes(value); +} + +function getEnabledCustomTheme( + id: string | null | undefined, + presets: CustomThemePreset[] | undefined, +): CustomThemePreset | undefined { + return isCustomThemeId(id) + ? presets?.find((preset) => preset.id === id && preset.enabled) + : undefined; +} + function readPersistedTheme(): ThemeType { if (typeof window === 'undefined') { return 'light'; } const saved = localStorage.getItem(STORAGE_KEY); - if (saved === 'dark') return 'dark'; - if (saved === 'lightblue') return 'lightblue'; - if (saved === 'lightgreen') return 'lightgreen'; - if (saved === 'iceblue') return 'iceblue'; - if (saved === 'lightyellow') return 'lightyellow'; + if (isBuiltinTheme(saved)) return saved; + if (isCustomThemeId(saved)) return saved; return 'light'; } @@ -249,15 +269,7 @@ export const ThemeProvider: React.FC = ({ children }) => { const current = localStorage.getItem(STORAGE_KEY); const legacy = localStorage.getItem('theme'); - if ( - current && - current !== 'light' && - current !== 'dark' && - current !== 'lightblue' && - current !== 'lightgreen' && - current !== 'iceblue' && - current !== 'lightyellow' - ) { + if (current && !isBuiltinTheme(current) && !isCustomThemeId(current)) { // Garbage value — wipe it so the boot-sync below can take over. localStorage.removeItem(STORAGE_KEY); } @@ -288,25 +300,47 @@ export const ThemeProvider: React.FC = ({ children }) => { }, [mounted, forcedTheme, appearance?.default_theme, resolvedTheme, setNextTheme]); const effectiveTheme = forcedTheme ?? resolvedTheme; - const theme: ThemeType = mounted - ? effectiveTheme === 'dark' - ? 'dark' - : effectiveTheme === 'lightblue' - ? 'lightblue' - : effectiveTheme === 'lightgreen' - ? 'lightgreen' - : effectiveTheme === 'iceblue' - ? 'iceblue' - : effectiveTheme === 'lightyellow' - ? 'lightyellow' - : 'light' - : readPersistedTheme(); + const persistedTheme = readPersistedTheme(); + const requestedTheme = (mounted ? effectiveTheme : persistedTheme) ?? 'light'; + const customTheme = getEnabledCustomTheme( + requestedTheme, + appearance?.custom_themes, + ); + const theme: ThemeType = isBuiltinTheme(requestedTheme) + ? requestedTheme + : customTheme?.id ?? 'light'; useEffect(() => { if (typeof document === 'undefined') return; const root = document.documentElement; + // next-themes adds a class for every value passed to setTheme(). Custom + // themes use data attributes and CSS variables instead, so remove its + // transient class before applying the generated token set. + for (const className of [...root.classList]) { + if (isCustomThemeId(className)) { + root.classList.remove(className); + } + } + for (const tokenName of CUSTOM_THEME_TOKEN_NAMES) { + root.style.removeProperty(tokenName); + } + root.removeAttribute('data-custom-theme'); + + if (customTheme) { + for (const [tokenName, tokenValue] of Object.entries( + getCustomThemeTokens(customTheme), + )) { + root.style.setProperty(tokenName, tokenValue); + } + root.setAttribute('data-theme', customTheme.id); + root.setAttribute('data-custom-theme', 'true'); + root.setAttribute('theme-mode', customTheme.mode); + root.classList.toggle('dark', customTheme.mode === 'dark'); + return; + } + if (theme === 'dark') { root.setAttribute('theme-mode', 'dark'); root.classList.add('dark'); @@ -327,7 +361,7 @@ export const ThemeProvider: React.FC = ({ children }) => { } else { root.removeAttribute('data-theme'); } - }, [theme]); + }, [customTheme, theme]); const toggleTheme = () => { setNextTheme(theme === 'dark' ? 'light' : 'dark'); @@ -339,7 +373,7 @@ export const ThemeProvider: React.FC = ({ children }) => { markExplicitChoice(); }; - const themeConfig = themes[theme]; + const themeConfig = themes[isBuiltinTheme(theme) ? theme : 'light']; return ( { return classes.filter(Boolean).join(' '); diff --git a/frontend-web/src/core/branding/brand-assets.ts b/frontend-web/src/core/branding/brand-assets.ts index f78779ce..219e59f0 100644 --- a/frontend-web/src/core/branding/brand-assets.ts +++ b/frontend-web/src/core/branding/brand-assets.ts @@ -4,6 +4,7 @@ import fallbackLogoLightblue from "@/assets/img/brand-logo-lightblue.png"; import { readRuntimeEnv } from "@/env"; export type BrandTheme = "light" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow" | "dark"; +type BrandAssetTheme = BrandTheme | `custom-theme-${string}`; const headerFallbackAssets: Record = { light: fallbackLogoLight, @@ -61,18 +62,26 @@ function readRuntimeBrandAsset(key: string): string | undefined { } } -export function getHeaderBrandAsset(theme: BrandTheme): string { - return readRuntimeBrandAsset(headerRuntimeKeys[theme]) ?? headerFallbackAssets[theme]; +function resolveBrandTheme(theme: BrandAssetTheme): BrandTheme { + if (!theme.startsWith("custom-theme-")) return theme as BrandTheme; + return typeof document !== "undefined" && document.documentElement.getAttribute("theme-mode") === "dark" + ? "dark" + : "light"; } -export function getHeaderBrandFallback(theme: BrandTheme): string { - return headerFallbackAssets[theme]; +export function getHeaderBrandAsset(theme: BrandAssetTheme): string { + const resolved = resolveBrandTheme(theme); + return readRuntimeBrandAsset(headerRuntimeKeys[resolved]) ?? headerFallbackAssets[resolved]; +} + +export function getHeaderBrandFallback(theme: BrandAssetTheme): string { + return headerFallbackAssets[resolveBrandTheme(theme)]; } /** * Welcome content deliberately has no bundled image fallback. An empty * runtime URL keeps the original CM text + gradient treatment. */ -export function getWelcomeBrandAsset(theme: BrandTheme): string | undefined { - return readRuntimeBrandAsset(welcomeRuntimeKeys[theme]); +export function getWelcomeBrandAsset(theme: BrandAssetTheme): string | undefined { + return readRuntimeBrandAsset(welcomeRuntimeKeys[resolveBrandTheme(theme)]); } diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 1b56eda0..2000e93c 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -65,12 +65,12 @@ export const sidebarMenuItems: MenuItem[] = [ icon: Users, path: "/page/strategy/qa/multi-agent-roundtable", }, - { - id: "position-roundtable", - label: "岗位会商", - icon: Briefcase, - path: "/page/strategy/qa/position-roundtable", - }, + // { + // id: "position-roundtable", + // label: "岗位会商", + // icon: Briefcase, + // path: "/page/strategy/qa/position-roundtable", + // }, { id: "dashboard-agent", label: "大屏绘制", diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index 4d45a3e1..f915d14b 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -276,7 +276,40 @@ export async function updatePublishSquares( return data; } -export type ThemeMode = "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow"; +export const BUILTIN_THEME_IDS = [ + "light", + "dark", + "lightblue", + "lightgreen", + "iceblue", + "lightyellow", +] as const; + +export type BuiltinThemeId = (typeof BUILTIN_THEME_IDS)[number]; +export type ThemeMode = BuiltinThemeId | `custom-theme-${string}`; +export type CustomThemeBaseMode = "light" | "dark"; + +export interface CustomThemeColorOverrides { + button_hover?: string | null; + selected_background?: string | null; + border?: string | null; + muted?: string | null; + muted_text?: string | null; + destructive?: string | null; + brand_title?: string | null; + primary_foreground?: string | null; +} + +export interface CustomThemePreset { + id: `custom-theme-${string}`; + name: string; + primary: string; + text_color?: string | null; + mode: CustomThemeBaseMode; + overrides?: CustomThemeColorOverrides; + enabled: boolean; + sort_order: number; +} export type ViewMode = "card" | "list"; export type LayoutMode = "full" | "compact"; export type CompactShortcutParamType = "login" | "theme"; @@ -304,6 +337,7 @@ export interface AppearanceSettings { default_agent_view: ViewMode; default_layout_mode: LayoutMode; compact_shortcut_buttons: CompactShortcutButton[]; + custom_themes: CustomThemePreset[]; } export interface AppearanceUpdate { @@ -312,6 +346,7 @@ export interface AppearanceUpdate { default_agent_view?: ViewMode; default_layout_mode?: LayoutMode; compact_shortcut_buttons?: CompactShortcutButton[]; + custom_themes?: CustomThemePreset[]; } export async function loadAppearanceSettings(): Promise { diff --git a/frontend-web/src/core/theme/custom-theme.ts b/frontend-web/src/core/theme/custom-theme.ts new file mode 100644 index 00000000..e1dde872 --- /dev/null +++ b/frontend-web/src/core/theme/custom-theme.ts @@ -0,0 +1,284 @@ +import type { + CustomThemeColorOverrides, + CustomThemePreset, +} from "@/core/system-settings/api"; + +export const CUSTOM_THEME_PREFIX = "custom-theme-"; + +type Rgb = { r: number; g: number; b: number }; + +const HEX_COLOR = /^#[0-9a-f]{6}$/i; +const WHITE: Rgb = { r: 255, g: 255, b: 255 }; +const BLACK: Rgb = { r: 0, g: 0, b: 0 }; + +export const CUSTOM_THEME_ADVANCED_KEYS = [ + "button_hover", + "selected_background", + "border", + "muted", + "muted_text", + "destructive", + "brand_title", + "primary_foreground", +] as const; + +export type CustomThemeAdvancedKey = (typeof CUSTOM_THEME_ADVANCED_KEYS)[number]; + +export type CustomThemeTokenInput = Pick< + CustomThemePreset, + "primary" | "mode" +> & + Partial>; + +export function isCustomThemeId(value: string | null | undefined): value is `custom-theme-${string}` { + return typeof value === "string" && value.startsWith(CUSTOM_THEME_PREFIX); +} + +export function normalizeHexColor(value: string, fallback = "#0062D9"): string { + const normalized = value.trim().toUpperCase(); + return HEX_COLOR.test(normalized) ? normalized : fallback; +} + +function optionalHexColor(value: string | null | undefined): string | undefined { + if (!value?.trim() || !HEX_COLOR.test(value.trim())) return undefined; + return value.trim().toUpperCase(); +} + +function toRgb(value: string): Rgb { + const hex = normalizeHexColor(value).slice(1); + return { + r: Number.parseInt(hex.slice(0, 2), 16), + g: Number.parseInt(hex.slice(2, 4), 16), + b: Number.parseInt(hex.slice(4, 6), 16), + }; +} + +function toHex({ r, g, b }: Rgb): string { + const channel = (value: number) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0"); + return `#${channel(r)}${channel(g)}${channel(b)}`.toUpperCase(); +} + +function mix(from: Rgb, to: Rgb, amount: number): Rgb { + const ratio = Math.min(1, Math.max(0, amount)); + return { + r: from.r + (to.r - from.r) * ratio, + g: from.g + (to.g - from.g) * ratio, + b: from.b + (to.b - from.b) * ratio, + }; +} + +function relativeLuminance({ r, g, b }: Rgb): number { + const linear = [r, g, b].map((channel) => { + const value = channel / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!; +} + +function contrastRatio(a: Rgb, b: Rgb): number { + const [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x); + return (lighter! + 0.05) / (darker! + 0.05); +} + +function readableForeground(background: Rgb): string { + return contrastRatio(background, BLACK) >= contrastRatio(background, WHITE) ? "#172033" : "#FFFFFF"; +} + +function ensureContrast(color: Rgb, background: Rgb, toward: Rgb, minimum = 4.5): Rgb { + let candidate = color; + for (let step = 1; step <= 10 && contrastRatio(candidate, background) < minimum; step += 1) { + candidate = mix(candidate, toward, 0.18); + } + return candidate; +} + +function rgbChannels(color: Rgb): string { + return `${Math.round(color.r)} ${Math.round(color.g)} ${Math.round(color.b)}`; +} + +/** + * Produce the semantic CSS tokens consumed by Tailwind/shadcn and TDesign. + * Inputs are restricted to a safe six-digit primary colour, optional primary + * button text colour, a small set of interaction overrides and a light/dark surface + * family, so callers never interpolate arbitrary CSS values. + */ +export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record { + const primary = toRgb(theme.primary); + const isDark = theme.mode === "dark"; + const overrides: CustomThemeColorOverrides = theme.overrides ?? {}; + const background = isDark ? mix({ r: 13, g: 19, b: 32 }, primary, 0.12) : mix(WHITE, primary, 0.035); + const card = isDark ? mix({ r: 18, g: 27, b: 44 }, primary, 0.12) : WHITE; + // Normal page copy stays automatic so selecting white for a teal/blue + // action does not accidentally make a light-page's body text white. + const preferredForeground = toRgb(isDark ? "#F4F7FB" : "#202A3A"); + const foregroundRgb = ensureContrast( + preferredForeground, + background, + toRgb(readableForeground(background)), + ); + const foreground = toHex(foregroundRgb); + const defaultMutedForeground = toRgb(isDark ? "#AAB4C2" : "#667085"); + const mutedForegroundRgb = optionalHexColor(overrides.muted_text) + ? ensureContrast( + toRgb(overrides.muted_text!), + background, + toRgb(readableForeground(background)), + 3, + ) + : defaultMutedForeground; + const mutedForeground = toHex(mutedForegroundRgb); + const defaultAccent = isDark ? mix(background, primary, 0.3) : mix(WHITE, primary, 0.14); + const accent = optionalHexColor(overrides.selected_background) + ? toRgb(overrides.selected_background!) + : defaultAccent; + const accentHover = isDark ? mix(accent, primary, 0.2) : mix(accent, primary, 0.14); + const defaultBorder = isDark ? mix(background, WHITE, 0.16) : mix(WHITE, primary, 0.28); + const border = optionalHexColor(overrides.border) ? toRgb(overrides.border!) : defaultBorder; + const input = isDark ? mix(background, WHITE, 0.22) : mix(WHITE, primary, 0.38); + const action = ensureContrast( + mix(primary, isDark ? WHITE : BLACK, isDark ? 0.2 : 0.3), + isDark ? background : WHITE, + isDark ? WHITE : BLACK, + ); + const actionHover = isDark ? mix(action, WHITE, 0.12) : mix(action, BLACK, 0.12); + const actionActive = isDark ? mix(action, WHITE, 0.22) : mix(action, BLACK, 0.22); + const defaultPrimaryHover = isDark ? mix(primary, WHITE, 0.1) : mix(primary, BLACK, 0.1); + const primaryHover = optionalHexColor(overrides.button_hover) + ? toRgb(overrides.button_hover!) + : defaultPrimaryHover; + const primaryActive = isDark ? mix(primary, WHITE, 0.2) : mix(primary, BLACK, 0.2); + const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary); + const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground; + const brandTitle = optionalHexColor(overrides.brand_title) ?? foreground; + const defaultSecondary = isDark ? mix(background, WHITE, 0.08) : mix(WHITE, primary, 0.075); + const secondary = optionalHexColor(overrides.muted) ? toRgb(overrides.muted!) : defaultSecondary; + const sidebar = isDark ? mix(background, BLACK, 0.08) : mix(WHITE, primary, 0.018); + const ring = rgbChannels(primary); + const destructive = optionalHexColor(overrides.destructive) ?? (isDark ? "#FF777B" : "#CF4A4A"); + const primaryHex = toHex(primary); + const actionHex = toHex(action); + const actionHoverHex = toHex(actionHover); + const actionActiveHex = toHex(actionActive); + const accentHex = toHex(accent); + const accentHoverHex = toHex(accentHover); + const backgroundHex = toHex(background); + const cardHex = toHex(card); + const borderHex = toHex(border); + const inputHex = toHex(input); + const secondaryHex = toHex(secondary); + const primaryHoverHex = toHex(primaryHover); + const primaryActiveHex = toHex(primaryActive); + const sidebarHex = toHex(sidebar); + + return { + "--workspace-brand": primaryHex, + "--workspace-brand-foreground": primaryForeground, + "--brand-200": accentHex, + "--brand-900": actionHex, + "--brand-950": actionActiveHex, + "--theme-button-hover": primaryHoverHex, + "--theme-selected-background": accentHex, + "--background": backgroundHex, + "--foreground": foreground, + "--card": cardHex, + "--card-foreground": foreground, + "--popover": cardHex, + "--popover-foreground": foreground, + "--primary": primaryHex, + "--primary-foreground": primaryForeground, + "--button-foreground": buttonForeground, + "--brand-title-foreground": brandTitle, + "--secondary": secondaryHex, + "--secondary-foreground": actionHex, + "--muted": secondaryHex, + "--muted-foreground": mutedForeground, + "--accent": accentHex, + "--accent-foreground": actionHex, + "--destructive": destructive, + "--border": borderHex, + "--input": inputHex, + "--ring": `rgb(${ring} / 0.48)`, + "--chart-1": actionHex, + "--chart-2": toHex(mix(primary, { r: 20, g: 145, b: 150 }, 0.45)), + "--chart-3": toHex(mix(primary, { r: 108, g: 96, b: 175 }, 0.45)), + "--chart-4": toHex(mix(primary, { r: 220, g: 137, b: 54 }, 0.45)), + "--chart-5": toHex(mix(primary, { r: 57, g: 139, b: 110 }, 0.45)), + "--sidebar": sidebarHex, + "--sidebar-foreground": foreground, + "--sidebar-primary": primaryHex, + "--sidebar-primary-foreground": primaryForeground, + "--sidebar-accent": accentHex, + "--sidebar-accent-foreground": actionHex, + "--sidebar-border": borderHex, + "--sidebar-ring": `rgb(${ring} / 0.48)`, + "--legacy-accent-rgb": rgbChannels(accent), + "--legacy-background-rgb": rgbChannels(background), + "--legacy-border-rgb": rgbChannels(border), + "--legacy-brand-200-rgb": rgbChannels(accent), + "--legacy-card-rgb": rgbChannels(card), + "--legacy-destructive-rgb": rgbChannels(toRgb(destructive)), + "--legacy-foreground-rgb": rgbChannels(foregroundRgb), + "--legacy-input-rgb": rgbChannels(input), + "--legacy-muted-rgb": rgbChannels(secondary), + "--legacy-muted-foreground-rgb": rgbChannels(mutedForegroundRgb), + "--legacy-primary-rgb": rgbChannels(primary), + "--legacy-primary-foreground-rgb": rgbChannels(toRgb(primaryForeground)), + "--legacy-ring-rgb": ring, + "--legacy-secondary-rgb": rgbChannels(secondary), + "--legacy-sidebar-accent-rgb": rgbChannels(accent), + "--legacy-sidebar-border-rgb": rgbChannels(border), + "--legacy-sidebar-foreground-rgb": rgbChannels(foregroundRgb), + "--legacy-workspace-brand-rgb": rgbChannels(primary), + "--td-brand-color-1": backgroundHex, + "--td-brand-color-2": secondaryHex, + "--td-brand-color-3": accentHex, + "--td-brand-color-4": accentHoverHex, + "--td-brand-color-5": primaryHex, + "--td-brand-color-6": primaryHex, + "--td-brand-color-7": actionHex, + "--td-brand-color-8": actionHoverHex, + "--td-brand-color-9": actionActiveHex, + "--td-brand-color-10": actionActiveHex, + "--td-brand-color": primaryHex, + "--td-brand-color-hover": primaryHoverHex, + "--td-brand-color-focus": accentHex, + "--td-brand-color-active": primaryActiveHex, + "--td-brand-color-disabled": accentHoverHex, + "--td-brand-color-light": accentHex, + "--td-brand-color-light-hover": accentHoverHex, + "--td-text-color-primary": foreground, + "--td-text-color-secondary": mutedForeground, + "--td-text-color-brand": actionHex, + "--td-text-color-link": actionHex, + "--td-text-color-anti": buttonForeground, + "--td-bg-color-page": backgroundHex, + "--td-bg-color-container": cardHex, + "--td-bg-color-container-hover": secondaryHex, + "--td-bg-color-container-active": accentHex, + "--td-bg-color-secondarycontainer": secondaryHex, + "--td-bg-color-secondarycontainer-hover": accentHex, + "--td-bg-color-secondarycontainer-active": accentHoverHex, + "--td-border-level-1-color": borderHex, + "--td-component-stroke": borderHex, + "--td-border-level-2-color": inputHex, + "--td-component-border": inputHex, + }; +} + +/** Tokens produced from the automatic palette only, used by the editor to + * show which advanced values are currently inheriting from the system. */ +export function getCustomThemeAutomaticTokens( + theme: Pick, +): Record { + return getCustomThemeTokens(theme); +} + +export function customThemeContrast(primary: string): number { + const color = toRgb(primary); + return Math.max(contrastRatio(color, BLACK), contrastRatio(color, WHITE)); +} + +export function customThemeTextContrast(theme: CustomThemeTokenInput): number { + const tokens = getCustomThemeTokens(theme); + return contrastRatio(toRgb(tokens["--button-foreground"]!), toRgb(tokens["--primary"]!)); +} diff --git a/frontend-web/src/pages/KnowledgeGraphPage.tsx b/frontend-web/src/pages/KnowledgeGraphPage.tsx index 0d6c4543..3a3ba15c 100644 --- a/frontend-web/src/pages/KnowledgeGraphPage.tsx +++ b/frontend-web/src/pages/KnowledgeGraphPage.tsx @@ -28,7 +28,7 @@ import { getNodeTitle, } from "@/components/knowledge-graph/graphUtils"; import "@/components/knowledge-graph/graphStyles.css"; -import { useTheme } from "@/contexts/ThemeContext"; +import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext"; import { useGraphBlacklist, useGraphBlacklistActions, @@ -799,7 +799,7 @@ export default function KnowledgeGraphPage() { searchMatchIds={searchMatchIds} layoutType={layoutType} showLabels={showLabels} - theme={theme} + theme={themeToBaseMode(theme)} onSelectNode={handleSelectNode} onTooltip={setTooltip} onZoomChange={setZoomLevel} diff --git a/frontend-web/src/pages/LightAppListPage.tsx b/frontend-web/src/pages/LightAppListPage.tsx index 5031a9f6..b78181a1 100644 --- a/frontend-web/src/pages/LightAppListPage.tsx +++ b/frontend-web/src/pages/LightAppListPage.tsx @@ -35,6 +35,7 @@ import { toast } from "sonner"; import { Pagination } from "@/components/ui/pagination"; import { cn } from "@/lib/utils"; import { useTheme, type ThemeType } from "@/contexts/ThemeContext"; +import { isCustomThemeId } from "@/core/theme/custom-theme"; import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps"; import { buildLightAppUrl } from "@/strategy-components/lib/light-app"; @@ -65,6 +66,17 @@ function hashIndex(str: string, mod: number): number { } function getLightAccent(theme: ThemeType) { + if (isCustomThemeId(theme)) { + return { + dot: "bg-[var(--primary)]", + badge: "border-[var(--border)] bg-[var(--accent)] text-[var(--accent-foreground)]", + focus: "focus:border-[var(--brand-900)] focus:ring-[var(--ring)]", + text: "text-[var(--accent-foreground)]", + hoverText: "group-hover:text-[var(--accent-foreground)]", + link: "text-[var(--accent-foreground)] hover:text-[var(--brand-950)]", + button: "bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] shadow-sm hover:bg-[var(--theme-button-hover)]", + }; + } if (theme === "lightgreen") { return { dot: "bg-[#07C05F]", diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index a9bc8fb0..f924deb0 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -90,12 +90,14 @@ export default function LoginPage() { () => readEmbedParam(searchParams.toString()) === 'on', [searchParams], ); - // External consumers may still send `from=iframe` for the legacy protocol - // chat. `chatMode=full` is an explicit escape hatch: always use the regular - // full ChatPage instead, which is the route expected by ordinary iframe - // nesting and browser deep links. + // Wujie is an application container, not the Brainy iframe protocol. It + // must therefore land on the regular full ChatPage even when an old portal + // URL still carries `from=iframe`. Plain embeds can request the same route + // explicitly with `chatMode=full`. const fullChatRequested = useMemo( - () => searchParams.get('chatMode')?.trim().toLowerCase() === FULL_CHAT_MODE, + () => + isWujieEnv() || + searchParams.get('chatMode')?.trim().toLowerCase() === FULL_CHAT_MODE, [searchParams], ); const redirectPath = useMemo( @@ -148,6 +150,15 @@ export default function LoginPage() { const normalized = raw.startsWith('/') ? raw : `/${raw}`; const [pathOnly, ownQuery = ''] = normalized.split('?'); const target = new URLSearchParams(ownQuery); + // Keep the Wujie/full-chat decision on the target URL. The destination + // route uses it to retain the full agent/history workspace sidebar. + if ( + fullChatRequested && + pathOnly === DEFAULT_REDIRECT_PATH && + !target.has('chatMode') + ) { + target.set('chatMode', FULL_CHAT_MODE); + } // 登录流程自身消费的参数,不透传到目标页(尤以鉴权类为甚)。 const LOGIN_OWNED = new Set([ 'redirect', 'route', 'ticket', 'userId', 'env', 'theme', 'yUserId', @@ -160,7 +171,7 @@ export default function LoginPage() { }); const qs = target.toString(); return qs ? `${pathOnly}?${qs}` : pathOnly; - }, [searchParams]); + }, [fullChatRequested, searchParams]); const externalParams = useMemo( () => ({ theme: searchParams.get('theme') ?? '', diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 6b44be99..78b48b25 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react'; import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom'; import { getStoredAuth } from '@/core/auth'; +import { isWujieEnv } from '@/core/embed/wujie'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { PromptInputProvider } from '@/components/ai-elements/prompt-input'; @@ -254,6 +255,66 @@ function NewChatEntryRoute({ ); } +/** + * `chats_iframe` is reserved for the Brainy postMessage protocol. Some legacy + * Wujie links still target it (or carry `from=iframe` during login), but Wujie + * needs the normal full chat workspace instead. Redirect it before mounting + * IframeChatPage; `chatMode=iframe` remains an explicit opt-in escape hatch + * for a Wujie integration that really implements that protocol. + */ +function IframeChatEntryRoute({ + layoutProps, + hasThreadParam, +}: { + layoutProps: { + pageSidebarCollapsed: boolean; + pageSidebarHidden: boolean; + workspaceSidebarHidden: boolean; + headerHidden: boolean; + compactWorkspaceSidebar: boolean; + compactActionsHidden: boolean; + pageSidebarDark: boolean; + forceWorkspaceSidebarCollapsed: boolean; + }; + hasThreadParam: boolean; +}) { + const location = useLocation(); + const { thread_id } = useParams<{ thread_id: string }>(); + const requestedMode = new URLSearchParams(location.search) + .get('chatMode') + ?.trim() + .toLowerCase(); + + if (isWujieEnv() && requestedMode !== 'iframe') { + const targetParams = new URLSearchParams(location.search); + targetParams.set('chatMode', 'full'); + const targetPath = hasThreadParam && thread_id + ? `/page/workspace/chats/${thread_id}` + : '/page/workspace/chats/new'; + const targetSearch = targetParams.toString(); + return ( + + ); + } + + return ( + + + + + + ); +} + export default function WorkspaceRoutes({ pageSidebarCollapsed = false, pageSidebarHidden = false, @@ -312,33 +373,11 @@ export default function WorkspaceRoutes({ /> - - - - - } + element={} /> - - - - - } + element={} /> -

当前行动

+

当前行动

行动名称

@@ -3011,7 +3011,7 @@ function RoundtableEntryRail({ -

当前任务

+

当前任务

@@ -3036,7 +3036,7 @@ function RoundtableEntryRail({ -

业务链

+

业务链

{chains.length} @@ -3249,7 +3249,7 @@ function RoundtableEntryRail({ -

分析记录

+

分析记录

{/* 按业务链筛选 —— 仅当存在多于 1 个可选业务链时才显示图标按钮。 */} {chainFilterOptions.length > 1 && ( diff --git a/frontend-web/src/styles/globals.css b/frontend-web/src/styles/globals.css index 498bf0ee..0db166ff 100644 --- a/frontend-web/src/styles/globals.css +++ b/frontend-web/src/styles/globals.css @@ -1845,3 +1845,117 @@ html.lightyellow .gq-history-item--active .bg-primary { .lightyellow .bg-primary\/70 { background-color: rgba(243, 251, 217, 0.70); } .lightyellow .bg-primary\/90 { background-color: rgba(243, 251, 217, 0.90); } } + +/* Runtime-created themes deliberately use semantic variables rather than a + generated class per colour. These compatibility rules keep the selected, + hovered and compact-navigation states readable on Chrome 109 as well. */ +html[data-custom-theme="true"] .agent-avatar { + background-color: var(--accent); + color: var(--accent-foreground); + box-shadow: inset 0 0 0 1px rgb(var(--legacy-workspace-brand-rgb) / 0.2); +} + +html[data-custom-theme="true"] .agent-sidebar-card--active, +html[data-custom-theme="true"] .gq-sidebar .agent-sidebar-card--active, +html[data-custom-theme="true"] .gq-history-item--active { + border-color: rgb(var(--legacy-workspace-brand-rgb) / 0.52) !important; + background-color: var(--accent) !important; + color: var(--accent-foreground) !important; +} + +html[data-custom-theme="true"] .workspace-compact-new-chat, +html[data-custom-theme="true"] .workspace-nav-new-chat { + border-color: rgb(var(--legacy-workspace-brand-rgb) / 0.38) !important; + color: var(--accent-foreground) !important; +} + +html[data-custom-theme="true"] .workspace-compact-new-chat :is(svg, span), +html[data-custom-theme="true"] .workspace-nav-new-chat :is(svg, span), +html[data-custom-theme="true"] .workspace-nav-item:hover :is(svg, span), +html[data-custom-theme="true"] .workspace-nav-item[data-active="true"] :is(svg, span), +html[data-custom-theme="true"] .gq-history-item--active :is(svg, span), +html[data-custom-theme="true"] .gq-sidebar .agent-sidebar-card--active .text-primary { + color: var(--accent-foreground) !important; +} + +html[data-custom-theme="true"] .workspace-compact-new-chat:hover, +html[data-custom-theme="true"] .workspace-compact-new-chat[data-active="true"], +html[data-custom-theme="true"] .workspace-nav-new-chat:hover, +html[data-custom-theme="true"] .workspace-nav-new-chat[data-active="true"], +html[data-custom-theme="true"] .workspace-nav-item:hover, +html[data-custom-theme="true"] .workspace-nav-item[data-active="true"] { + border-color: rgb(var(--legacy-workspace-brand-rgb) / 0.6) !important; + background-color: var(--accent) !important; + color: var(--accent-foreground) !important; + box-shadow: inset 3px 0 0 var(--brand-900); +} + +html[data-custom-theme="true"] .gq-new-chat-button { + border-color: var(--brand-900) !important; + background-color: var(--accent) !important; + color: var(--accent-foreground) !important; +} + +html[data-custom-theme="true"] .gq-new-chat-button:hover { + background-color: var(--theme-button-hover) !important; +} + +/* The shared primary Button uses Tailwind's hover:bg-primary/90 utility. + Let a custom theme's optional button-hover override reach those buttons + too, without changing the fixed built-in theme palettes. */ +html[data-custom-theme="true"] [class~="hover:bg-primary/90"]:hover { + background-color: var(--theme-button-hover) !important; +} + +/* Keep the user-selected primary button text local to actions. Brand marks, + badges and other non-button primary surfaces retain their automatic, legible + foreground. */ +html[data-custom-theme="true"] :is(button, a)[class~="bg-primary"] { + color: var(--button-foreground, var(--primary-foreground)) !important; +} + +/* Scoped brand-title styling: built-in themes continue using their existing + title classes, while custom themes can tune the icon-adjacent headings. */ +html[data-custom-theme="true"] .theme-brand-title { + color: var(--brand-title-foreground) !important; +} + +/* The destructive token intentionally has its own advanced setting. */ +html[data-custom-theme="true"] [class~="hover:bg-destructive/90"]:hover { + background-color: rgb(var(--legacy-destructive-rgb) / 0.9) !important; +} + +html[data-custom-theme="true"] .gq-sidebar .agent-sidebar-card--active .bg-primary, +html[data-custom-theme="true"] .gq-history-item--active .bg-primary { + background-color: var(--brand-900) !important; +} + +@supports not (color: oklch(1 0 0)) { + html[data-custom-theme="true"] .bg-primary\/5 { background-color: rgb(var(--legacy-primary-rgb) / 0.05); } + html[data-custom-theme="true"] .bg-primary\/8 { background-color: rgb(var(--legacy-primary-rgb) / 0.08); } + html[data-custom-theme="true"] .bg-primary\/10 { background-color: rgb(var(--legacy-primary-rgb) / 0.10); } + html[data-custom-theme="true"] .bg-primary\/14 { background-color: rgb(var(--legacy-primary-rgb) / 0.14); } + html[data-custom-theme="true"] .bg-primary\/15 { background-color: rgb(var(--legacy-primary-rgb) / 0.15); } + html[data-custom-theme="true"] .bg-primary\/20 { background-color: rgb(var(--legacy-primary-rgb) / 0.20); } + html[data-custom-theme="true"] .bg-primary\/25 { background-color: rgb(var(--legacy-primary-rgb) / 0.25); } + html[data-custom-theme="true"] .bg-primary\/30 { background-color: rgb(var(--legacy-primary-rgb) / 0.30); } + html[data-custom-theme="true"] .bg-primary\/60 { background-color: rgb(var(--legacy-primary-rgb) / 0.60); } + html[data-custom-theme="true"] .bg-primary\/70 { background-color: rgb(var(--legacy-primary-rgb) / 0.70); } + html[data-custom-theme="true"] .bg-primary\/90 { background-color: rgb(var(--legacy-primary-rgb) / 0.90); } + html[data-custom-theme="true"] .bg-destructive\/5 { background-color: rgb(var(--legacy-destructive-rgb) / 0.05); } + html[data-custom-theme="true"] .border-destructive\/30 { border-color: rgb(var(--legacy-destructive-rgb) / 0.30); } + html[data-custom-theme="true"] .border-primary\/15 { border-color: rgb(var(--legacy-primary-rgb) / 0.15); } + html[data-custom-theme="true"] .border-primary\/20 { border-color: rgb(var(--legacy-primary-rgb) / 0.20); } + html[data-custom-theme="true"] .border-primary\/25 { border-color: rgb(var(--legacy-primary-rgb) / 0.25); } + html[data-custom-theme="true"] .border-primary\/30 { border-color: rgb(var(--legacy-primary-rgb) / 0.30); } + html[data-custom-theme="true"] .border-primary\/35 { border-color: rgb(var(--legacy-primary-rgb) / 0.35); } + html[data-custom-theme="true"] .border-primary\/40 { border-color: rgb(var(--legacy-primary-rgb) / 0.40); } + html[data-custom-theme="true"] .border-primary\/50 { border-color: rgb(var(--legacy-primary-rgb) / 0.50); } + html[data-custom-theme="true"] .border-primary\/60 { border-color: rgb(var(--legacy-primary-rgb) / 0.60); } + html[data-custom-theme="true"] .ring-primary\/15 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.15); } + html[data-custom-theme="true"] .ring-primary\/20 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.20); } + html[data-custom-theme="true"] .ring-primary\/30 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.30); } + html[data-custom-theme="true"] .ring-primary\/40 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.40); } + html[data-custom-theme="true"] .ring-primary\/50 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.50); } + html[data-custom-theme="true"] .ring-primary\/60 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.60); } +} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index bd87e867..db63fe95 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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: diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 15c11d38..0ca4dcc0 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -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 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 2b1f27f5..7fa5e877 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py +++ b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py @@ -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) 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 9e715095..ec634f32 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 @@ -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)