mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-05 02:31:47 +00:00
fix: add nous-research/ui package
This commit is contained in:
parent
957ca79e8e
commit
923539a46b
26 changed files with 798 additions and 637 deletions
|
|
@ -3,167 +3,122 @@ import {
|
|||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { DashboardTheme, ThemeColors, ThemeOverlay } from "./types";
|
||||
import { BUILTIN_THEMES, defaultTheme } from "./presets";
|
||||
import type { DashboardTheme, ThemeLayer, ThemePalette } from "./types";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
/** LocalStorage key — pre-applied before the React tree mounts to avoid
|
||||
* a visible flash of the default palette on theme-overridden installs. */
|
||||
const STORAGE_KEY = "hermes-dashboard-theme";
|
||||
|
||||
/** Apply a theme's color overrides to `document.documentElement`. */
|
||||
function applyColors(colors: ThemeColors) {
|
||||
/** Turn a ThemeLayer into the two CSS expressions the DS consumes:
|
||||
* `--<name>` (color-mix'd with alpha) and `--<name>-base` (opaque hex). */
|
||||
function layerVars(name: "background" | "midground" | "foreground", layer: ThemeLayer) {
|
||||
const pct = Math.round(layer.alpha * 100);
|
||||
return {
|
||||
[`--${name}`]: `color-mix(in srgb, ${layer.hex} ${pct}%, transparent)`,
|
||||
[`--${name}-base`]: layer.hex,
|
||||
[`--${name}-alpha`]: String(layer.alpha),
|
||||
};
|
||||
}
|
||||
|
||||
/** Write a theme's palette to `document.documentElement` as inline styles.
|
||||
* Inline styles beat the `:root { }` rule in index.css, so this cascades
|
||||
* into every shadcn-compat token defined over the DS triplet. */
|
||||
function applyPalette(palette: ThemePalette) {
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(colors)) {
|
||||
root.style.setProperty(`--color-${key}`, value);
|
||||
const vars = {
|
||||
...layerVars("background", palette.background),
|
||||
...layerVars("midground", palette.midground),
|
||||
...layerVars("foreground", palette.foreground),
|
||||
"--warm-glow": palette.warmGlow,
|
||||
"--noise-opacity-mul": String(palette.noiseOpacity),
|
||||
};
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
root.style.setProperty(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply overlay overrides (noise + warm-glow). */
|
||||
function applyOverlay(overlay: ThemeOverlay | undefined) {
|
||||
const noiseEl = document.querySelector<HTMLElement>(".noise-overlay");
|
||||
const glowEl = document.querySelector<HTMLElement>(".warm-glow");
|
||||
|
||||
if (noiseEl) {
|
||||
noiseEl.style.opacity = String(overlay?.noiseOpacity ?? 0.10);
|
||||
noiseEl.style.mixBlendMode = overlay?.noiseBlendMode ?? "color-dodge";
|
||||
}
|
||||
if (glowEl) {
|
||||
glowEl.style.opacity = String(overlay?.warmGlowOpacity ?? 0.22);
|
||||
if (overlay?.warmGlowColor) {
|
||||
glowEl.style.background = `radial-gradient(ellipse at 0% 0%, ${overlay.warmGlowColor} 0%, rgba(0,0,0,0) 60%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove all inline overrides — reverts to stylesheet defaults. */
|
||||
function clearOverrides() {
|
||||
const root = document.documentElement;
|
||||
// Clear color overrides
|
||||
for (const key of Object.keys(defaultTheme.colors)) {
|
||||
root.style.removeProperty(`--color-${key}`);
|
||||
}
|
||||
// Clear overlay overrides
|
||||
const noiseEl = document.querySelector<HTMLElement>(".noise-overlay");
|
||||
const glowEl = document.querySelector<HTMLElement>(".warm-glow");
|
||||
if (noiseEl) {
|
||||
noiseEl.style.opacity = "";
|
||||
noiseEl.style.mixBlendMode = "";
|
||||
}
|
||||
if (glowEl) {
|
||||
glowEl.style.opacity = "";
|
||||
glowEl.style.background = "";
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme: DashboardTheme) {
|
||||
if (theme.name === "default") {
|
||||
clearOverrides();
|
||||
} else {
|
||||
applyColors(theme.colors);
|
||||
applyOverlay(theme.overlay);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ThemeContextValue {
|
||||
/** Currently active theme name. */
|
||||
themeName: string;
|
||||
/** Currently active theme object. */
|
||||
theme: DashboardTheme;
|
||||
/** Available theme names (built-in + any server-provided custom themes). */
|
||||
availableThemes: Array<{ name: string; label: string; description: string }>;
|
||||
/** Switch theme — applies CSS immediately and persists to config.yaml. */
|
||||
setTheme: (name: string) => void;
|
||||
/** True while initial theme is loading from server. */
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
themeName: "default",
|
||||
theme: defaultTheme,
|
||||
availableThemes: Object.values(BUILTIN_THEMES).map((t) => ({
|
||||
name: t.name,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
})),
|
||||
setTheme: () => {},
|
||||
loading: true,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [themeName, setThemeName] = useState("default");
|
||||
const [availableThemes, setAvailableThemes] = useState(
|
||||
const [themeName, setThemeName] = useState<string>(() => {
|
||||
if (typeof window === "undefined") return "default";
|
||||
return window.localStorage.getItem(STORAGE_KEY) ?? "default";
|
||||
});
|
||||
const [availableThemes, setAvailableThemes] = useState<
|
||||
Array<{ description: string; label: string; name: string }>
|
||||
>(() =>
|
||||
Object.values(BUILTIN_THEMES).map((t) => ({
|
||||
name: t.name,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
})),
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch active theme + available list from server on mount.
|
||||
useEffect(() => {
|
||||
const t = BUILTIN_THEMES[themeName] ?? defaultTheme;
|
||||
applyPalette(t.palette);
|
||||
}, [themeName]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getThemes()
|
||||
.then((resp) => {
|
||||
if (resp.themes?.length) {
|
||||
setAvailableThemes(resp.themes);
|
||||
}
|
||||
if (resp.active && resp.active !== "default") {
|
||||
if (cancelled) return;
|
||||
if (resp.themes?.length) setAvailableThemes(resp.themes);
|
||||
if (resp.active && resp.active !== themeName) {
|
||||
setThemeName(resp.active);
|
||||
const t = BUILTIN_THEMES[resp.active];
|
||||
if (t) applyTheme(t);
|
||||
window.localStorage.setItem(STORAGE_KEY, resp.active);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Server might not support theme API yet — stay on default.
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolvedTheme = BUILTIN_THEMES[themeName] ?? defaultTheme;
|
||||
const setTheme = useCallback((name: string) => {
|
||||
const next = BUILTIN_THEMES[name] ? name : "default";
|
||||
setThemeName(next);
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
api.setTheme(next).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback(
|
||||
(name: string) => {
|
||||
const t = BUILTIN_THEMES[name] ?? defaultTheme;
|
||||
setThemeName(t.name);
|
||||
applyTheme(t);
|
||||
// Persist to config.yaml — fire and forget.
|
||||
api.setTheme(t.name).catch(() => {});
|
||||
},
|
||||
[],
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme: BUILTIN_THEMES[themeName] ?? defaultTheme,
|
||||
themeName,
|
||||
availableThemes,
|
||||
setTheme,
|
||||
}),
|
||||
[themeName, availableThemes, setTheme],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
themeName,
|
||||
theme: resolvedTheme,
|
||||
availableThemes,
|
||||
setTheme,
|
||||
loading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useTheme() {
|
||||
export function useTheme(): ThemeContextValue {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: defaultTheme,
|
||||
themeName: "default",
|
||||
availableThemes: Object.values(BUILTIN_THEMES).map((t) => ({
|
||||
name: t.name,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
})),
|
||||
setTheme: () => {},
|
||||
});
|
||||
|
||||
interface ThemeContextValue {
|
||||
availableThemes: Array<{ description: string; label: string; name: string }>;
|
||||
setTheme: (name: string) => void;
|
||||
theme: DashboardTheme;
|
||||
themeName: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue