Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Current user context:
|
||||
* initialized via GET /api/me on mount; when unauthenticated, the route guard (RequireAuth)
|
||||
* redirects to /login; a successful login/registration holds a session cookie (HttpOnly,
|
||||
* issued by the server).
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { UserInfo } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../api/endpoints";
|
||||
import { ApiError, setUnauthorizedHandler } from "../api/client";
|
||||
|
||||
interface AuthContextValue {
|
||||
/** undefined = initializing; null = not logged in. */
|
||||
user: UserInfo | null | undefined;
|
||||
login: (userId: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
/** Refetch /api/me (e.g. to refresh the passwordIsInitial flag after a password change). */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<UserInfo | null | undefined>(undefined);
|
||||
|
||||
// Any API returning 401 (session expired / database rebuilt) clears the current user, and
|
||||
// RequireAuth redirects back to the login page.
|
||||
// Must be registered before the GET /api/me effect below (effects in the same component
|
||||
// run in declaration order).
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => setUser(null));
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getMe()
|
||||
.then((res) => {
|
||||
if (!cancelled) setUser(res.user);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
if (err instanceof ApiError && err.status === 401) setUser(null);
|
||||
else setUser(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (userId: string, password: string) => {
|
||||
const res = await api.login({ userId, password });
|
||||
setUser(res.user);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api.logout();
|
||||
} finally {
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const res = await api.getMe();
|
||||
setUser(res.user);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, refresh }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth 必须在 AuthProvider 内使用");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Language context: zh / en / system (tracks navigator.language, listens for languagechange).
|
||||
* On switch, first synchronously calls setActiveStrings (assigned during render, idempotent),
|
||||
* then remounts the whole tree keyed on locale so every `S.x` read immediately reflects the
|
||||
* new language; the preference persists to localStorage.
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { setActiveStrings, zh } from "../lib/strings";
|
||||
import { en } from "../lib/strings-en";
|
||||
|
||||
export type LangPref = "zh" | "en" | "system";
|
||||
export type Locale = "zh" | "en";
|
||||
|
||||
const STORAGE_KEY = "penguin.lang";
|
||||
|
||||
interface LocaleContextValue {
|
||||
lang: LangPref;
|
||||
locale: Locale;
|
||||
setLang: (lang: LangPref) => void;
|
||||
}
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Device language → UI language (default when no stored preference exists; also applies on the
|
||||
* login page): a language tag starting with zh (zh-CN/zh-TW…) → zh; anything else or
|
||||
* unavailable → falls back to en. Exported as a pure function for unit tests (test/locale.test.ts).
|
||||
*/
|
||||
export function resolveSystemLocale(language: string | undefined): Locale {
|
||||
return language?.toLowerCase().startsWith("zh") ? "zh" : "en";
|
||||
}
|
||||
|
||||
function systemLocale(): Locale {
|
||||
return resolveSystemLocale(navigator.language);
|
||||
}
|
||||
|
||||
function resolve(lang: LangPref): Locale {
|
||||
return lang === "system" ? systemLocale() : lang;
|
||||
}
|
||||
|
||||
function initialLang(): LangPref {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "zh" || stored === "en" || stored === "system") return stored;
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function LocaleProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLangState] = useState<LangPref>(initialLang);
|
||||
// Re-resolution signal for browser language changes while in system mode.
|
||||
const [, setSysTick] = useState(0);
|
||||
|
||||
const locale = resolve(lang);
|
||||
// Switch the active dictionary during render (idempotent assignment): children are keyed on
|
||||
// locale and render after this component, so they always read the post-switch dictionary.
|
||||
setActiveStrings(locale === "en" ? en : zh);
|
||||
|
||||
useEffect(() => {
|
||||
if (lang !== "system") return;
|
||||
const onChange = () => setSysTick((t) => t + 1);
|
||||
window.addEventListener("languagechange", onChange);
|
||||
return () => window.removeEventListener("languagechange", onChange);
|
||||
}, [lang]);
|
||||
|
||||
const setLang = useCallback((next: LangPref) => {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
setLangState(next);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={{ lang, locale, setLang }}>{children}</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Language scope: a remount boundary keyed on locale. Placed **inside** AuthProvider —
|
||||
* switching language only rebuilds the UI tree, not the auth state (otherwise user=undefined
|
||||
* would cause a full-screen flash).
|
||||
*/
|
||||
export function LocaleScope({ children }: { children: ReactNode }) {
|
||||
const { locale } = useLocale();
|
||||
return (
|
||||
<div key={locale} className="contents">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocale(): LocaleContextValue {
|
||||
const ctx = useContext(LocaleContext);
|
||||
if (!ctx) throw new Error("useLocale 必须在 LocaleProvider 内使用");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Current Project / Agent context:
|
||||
* - Project list (owned + authorized); the current selection is remembered in localStorage and
|
||||
* synced to server-side prefs (lastProjectId, best-effort);
|
||||
* - the current Project's Agent list and current Agent (switched via the top-bar breadcrumb
|
||||
* dropdown; remembered per Project).
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { AgentSummary, ProjectSummary } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../api/endpoints";
|
||||
|
||||
const PROJECT_KEY = "penguin.lastProjectId";
|
||||
const agentKey = (projectId: string) => `penguin.lastAgentId.${projectId}`;
|
||||
|
||||
interface ProjectContextValue {
|
||||
projects: ProjectSummary[];
|
||||
projectsLoading: boolean;
|
||||
currentProject: ProjectSummary | null;
|
||||
setCurrentProjectId: (projectId: string) => void;
|
||||
reloadProjects: () => Promise<void>;
|
||||
|
||||
agents: AgentSummary[];
|
||||
agentsLoading: boolean;
|
||||
currentAgent: AgentSummary | null;
|
||||
setCurrentAgentId: (agentId: string) => void;
|
||||
reloadAgents: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ProjectContext = createContext<ProjectContextValue | null>(null);
|
||||
|
||||
/** Project display name fallback: falls back to projectId when name is absent. */
|
||||
export function projectDisplayName(p: ProjectSummary): string {
|
||||
return p.name ?? p.projectId;
|
||||
}
|
||||
|
||||
/** Agent display name fallback: falls back to agentId when name is absent. */
|
||||
export function agentDisplayName(a: AgentSummary): string {
|
||||
return a.name ?? a.agentId;
|
||||
}
|
||||
|
||||
export function ProjectProvider({ children }: { children: ReactNode }) {
|
||||
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
||||
const [projectsLoading, setProjectsLoading] = useState(true);
|
||||
const [currentProjectId, setCurrentProjectIdState] = useState<string | null>(null);
|
||||
|
||||
const [agents, setAgents] = useState<AgentSummary[]>([]);
|
||||
const [agentsLoading, setAgentsLoading] = useState(true);
|
||||
const [currentAgentId, setCurrentAgentIdState] = useState<string | null>(null);
|
||||
|
||||
const reloadProjects = useCallback(async () => {
|
||||
setProjectsLoading(true);
|
||||
try {
|
||||
const res = await api.listProjects();
|
||||
setProjects(res.projects);
|
||||
setCurrentProjectIdState((prev) => {
|
||||
const wanted = prev ?? localStorage.getItem(PROJECT_KEY);
|
||||
const found = res.projects.find((p) => p.projectId === wanted);
|
||||
return (found ?? res.projects[0])?.projectId ?? null;
|
||||
});
|
||||
} finally {
|
||||
setProjectsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadProjects();
|
||||
}, [reloadProjects]);
|
||||
|
||||
const setCurrentProjectId = useCallback(
|
||||
(projectId: string) => {
|
||||
// If the selection is already the current Project, return immediately. Otherwise the
|
||||
// code below would clear agents and set loading back to true while currentProjectId
|
||||
// stays unchanged — reloadAgents' effect depends on it and wouldn't rerun, so the Agent
|
||||
// list (and the Session list mounted under it) would disappear for good (reproducible
|
||||
// by clicking the already-current Project in the dropdown).
|
||||
if (projectId === currentProjectId) return;
|
||||
localStorage.setItem(PROJECT_KEY, projectId);
|
||||
setCurrentProjectIdState(projectId);
|
||||
setCurrentAgentIdState(null);
|
||||
// Clear the Agent list in sync: avoids a transient render with "new projectId + old
|
||||
// Project's agents" that would make downstream consumers (Sessions) fetch with the
|
||||
// wrong Agent set (which could create spurious Sessions under the new Project).
|
||||
setAgents([]);
|
||||
setAgentsLoading(true);
|
||||
// Sync server-side prefs (best-effort; failure doesn't affect the local experience).
|
||||
void api.putPrefs({ lastProjectId: projectId }).catch(() => undefined);
|
||||
},
|
||||
[currentProjectId],
|
||||
);
|
||||
|
||||
const reloadAgents = useCallback(async () => {
|
||||
if (!currentProjectId) return;
|
||||
setAgentsLoading(true);
|
||||
try {
|
||||
const res = await api.listAgents(currentProjectId);
|
||||
setAgents(res.agents);
|
||||
setCurrentAgentIdState((prev) => {
|
||||
const wanted = prev ?? localStorage.getItem(agentKey(currentProjectId));
|
||||
const found = res.agents.find((a) => a.agentId === wanted);
|
||||
// Default to conversing with default_agent.
|
||||
const fallback =
|
||||
res.agents.find((a) => a.agentId === "default_agent") ?? res.agents[0] ?? null;
|
||||
return (found ?? fallback)?.agentId ?? null;
|
||||
});
|
||||
} finally {
|
||||
setAgentsLoading(false);
|
||||
}
|
||||
}, [currentProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
setAgents([]);
|
||||
void reloadAgents();
|
||||
}, [reloadAgents]);
|
||||
|
||||
const setCurrentAgentId = useCallback(
|
||||
(agentId: string) => {
|
||||
if (currentProjectId) localStorage.setItem(agentKey(currentProjectId), agentId);
|
||||
setCurrentAgentIdState(agentId);
|
||||
},
|
||||
[currentProjectId],
|
||||
);
|
||||
|
||||
const value = useMemo<ProjectContextValue>(() => {
|
||||
const currentProject = projects.find((p) => p.projectId === currentProjectId) ?? null;
|
||||
const currentAgent = agents.find((a) => a.agentId === currentAgentId) ?? null;
|
||||
return {
|
||||
projects,
|
||||
projectsLoading,
|
||||
currentProject,
|
||||
setCurrentProjectId,
|
||||
reloadProjects,
|
||||
agents,
|
||||
agentsLoading,
|
||||
currentAgent,
|
||||
setCurrentAgentId,
|
||||
reloadAgents,
|
||||
};
|
||||
}, [
|
||||
projects,
|
||||
projectsLoading,
|
||||
currentProjectId,
|
||||
setCurrentProjectId,
|
||||
reloadProjects,
|
||||
agents,
|
||||
agentsLoading,
|
||||
currentAgentId,
|
||||
setCurrentAgentId,
|
||||
reloadAgents,
|
||||
]);
|
||||
|
||||
return <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>;
|
||||
}
|
||||
|
||||
export function useProject(): ProjectContextValue {
|
||||
const ctx = useContext(ProjectContext);
|
||||
if (!ctx) throw new Error("useProject 必须在 ProjectProvider 内使用");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Session list context for all Agents in the current Project:
|
||||
* the sidebar groups by Agent, so all Agents' Sessions are loaded at once (fetched in parallel);
|
||||
* the chat page shares this same data for status sync / title events / self-healing reload.
|
||||
*
|
||||
* **Sessions are not auto-created here**: a new conversation starts as a draft (chat page `/chat/new`),
|
||||
* and the Session is only actually created when the first message is sent — after landing, the user
|
||||
* may still switch models or configure an API key first, so persisting the Session early would both
|
||||
* lock in the model and fail outright when no credential is configured yet.
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { SessionInfo, SessionStatus } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../api/endpoints";
|
||||
import { openUserEvents } from "../api/sse";
|
||||
import { useProject } from "./project";
|
||||
|
||||
interface SessionsContextValue {
|
||||
/** Full list (each Agent's entries keep server-side reverse chronological order). */
|
||||
sessions: SessionInfo[];
|
||||
/** agentId → that Agent's Session list (empty array if none). */
|
||||
byAgent: ReadonlyMap<string, SessionInfo[]>;
|
||||
loading: boolean;
|
||||
reload: () => Promise<void>;
|
||||
/** Prepend to the list on success (draft materialized by the first message, or explicit creation via dialog). */
|
||||
add: (session: SessionInfo) => void;
|
||||
/** Remove from the list in place after deletion. */
|
||||
remove: (sessionId: string) => void;
|
||||
/** Replace the whole entry with the PATCH result. */
|
||||
replace: (session: SessionInfo) => void;
|
||||
/** Live stream task_state → list badge. */
|
||||
setStatus: (sessionId: string, status: SessionStatus) => void;
|
||||
/** session_title server event → update the title in place. */
|
||||
setTitle: (sessionId: string, title: string) => void;
|
||||
}
|
||||
|
||||
const SessionsContext = createContext<SessionsContextValue | null>(null);
|
||||
|
||||
export function SessionsProvider({ children }: { children: ReactNode }) {
|
||||
const { currentProject, agents } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
// Stable key for the Agent set: the list object is a new reference on every reload,
|
||||
// so join the ids to avoid unnecessary reloads.
|
||||
const agentIdsKey = agents.map((a) => a.agentId).join(",");
|
||||
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Generation counter: invalidates any in-flight response once the Project/Agent set
|
||||
// changes or a reload happens.
|
||||
const gen = useRef(0);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const agentIds = agentIdsKey === "" ? [] : agentIdsKey.split(",");
|
||||
if (!projectId || agentIds.length === 0) return;
|
||||
const g = ++gen.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
agentIds.map(async (agentId) => {
|
||||
try {
|
||||
return (await api.listSessions(projectId, agentId)).sessions;
|
||||
} catch {
|
||||
return []; // A single Agent's fetch failure shouldn't bring down the whole batch (e.g. its directory was deleted externally).
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (g !== gen.current) return;
|
||||
setSessions(results.flat());
|
||||
} finally {
|
||||
if (g === gen.current) setLoading(false);
|
||||
}
|
||||
}, [projectId, agentIdsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setSessions([]);
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
// User-level event stream (/api/events): a scheduled task firing may have created a new
|
||||
// Session (new-session mode); reload the list so it appears immediately. schedule_queued
|
||||
// doesn't change the list (the target Session already exists), so it's ignored.
|
||||
// refs hold the current values: the connection stays a single one for the whole login
|
||||
// session and doesn't reconnect on Project switches.
|
||||
const reloadRef = useRef(reload);
|
||||
reloadRef.current = reload;
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
useEffect(() => {
|
||||
const conn = openUserEvents({
|
||||
onOmniMessage: () => undefined,
|
||||
onServerEvent: (ev) => {
|
||||
if (ev.type !== "schedule_fired") return;
|
||||
// The event carries projectId: a trigger from another Project is unrelated to the current list.
|
||||
if (ev.projectId === projectIdRef.current) void reloadRef.current();
|
||||
},
|
||||
});
|
||||
return () => conn.close();
|
||||
}, []);
|
||||
|
||||
const add = useCallback((session: SessionInfo) => {
|
||||
// Invalidate any in-flight reload: the newly created entry mustn't be wiped by a stale snapshot.
|
||||
gen.current += 1;
|
||||
setSessions((prev) => [session, ...prev.filter((s) => s.sessionId !== session.sessionId)]);
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((sessionId: string) => {
|
||||
// Invalidate any in-flight reload: the deletion mustn't be undone by a stale snapshot.
|
||||
gen.current += 1;
|
||||
setSessions((prev) => prev.filter((s) => s.sessionId !== sessionId));
|
||||
}, []);
|
||||
|
||||
const replace = useCallback((session: SessionInfo) => {
|
||||
setSessions((prev) => prev.map((s) => (s.sessionId === session.sessionId ? session : s)));
|
||||
}, []);
|
||||
|
||||
const setStatus = useCallback((sessionId: string, status: SessionStatus) => {
|
||||
setSessions((prev) => {
|
||||
const target = prev.find((s) => s.sessionId === sessionId);
|
||||
if (!target || target.status === status) return prev;
|
||||
return prev.map((s) => (s.sessionId === sessionId ? { ...s, status } : s));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setTitle = useCallback((sessionId: string, title: string) => {
|
||||
setSessions((prev) => prev.map((s) => (s.sessionId === sessionId ? { ...s, title } : s)));
|
||||
}, []);
|
||||
|
||||
const value = useMemo<SessionsContextValue>(() => {
|
||||
const byAgent = new Map<string, SessionInfo[]>();
|
||||
for (const s of sessions) {
|
||||
const list = byAgent.get(s.agentId);
|
||||
if (list) list.push(s);
|
||||
else byAgent.set(s.agentId, [s]);
|
||||
}
|
||||
return {
|
||||
sessions,
|
||||
byAgent,
|
||||
loading,
|
||||
reload,
|
||||
add,
|
||||
remove,
|
||||
replace,
|
||||
setStatus,
|
||||
setTitle,
|
||||
};
|
||||
}, [sessions, loading, reload, add, remove, replace, setStatus, setTitle]);
|
||||
|
||||
return <SessionsContext.Provider value={value}>{children}</SessionsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSessions(): SessionsContextValue {
|
||||
const ctx = useContext(SessionsContext);
|
||||
if (!ctx) throw new Error("useSessions 必须在 SessionsProvider 内使用");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Appearance context: light/dark theme (mode = light / dark / system) + font
|
||||
* size + theme color (accent).
|
||||
* - Theme: html.dark class + Tailwind dark: variant; system mode tracks prefers-color-scheme
|
||||
* live. Dark mode defaults to pure black (styles.css overrides the neutral gray scale).
|
||||
* - Font size: scales the root font-size (rem-based text-* utilities scale along with it).
|
||||
* - Theme color: html[data-accent] overrides --accent-bg/--accent-fg; defaults to neutral
|
||||
* (gray/white, follows light/dark).
|
||||
* All preferences persist to localStorage.
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
export type FontScale = "sm" | "md" | "lg";
|
||||
export type Accent = "neutral" | "blue" | "green" | "violet" | "rose" | "amber";
|
||||
/** Display currency (prices are always stored as USD/million Tokens; conversion happens only for display and input). */
|
||||
export type Currency = "USD" | "CNY";
|
||||
/** 1 USD ≈ 7 CNY (fixed conversion rate). */
|
||||
export const USD_TO_CNY = 7;
|
||||
|
||||
const MODE_KEY = "penguin.theme";
|
||||
const FONT_KEY = "penguin.fontScale";
|
||||
const ACCENT_KEY = "penguin.accent";
|
||||
const CURRENCY_KEY = "penguin.currency";
|
||||
|
||||
/** Font size tier → root font-size (px): overall slightly larger than the system default for readability. */
|
||||
const FONT_PX: Record<FontScale, string> = { sm: "16px", md: "18px", lg: "20px" };
|
||||
|
||||
interface ThemeContextValue {
|
||||
mode: ThemeMode;
|
||||
/** Resolved effective theme (system mode already resolved against the system preference). */
|
||||
dark: boolean;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
fontScale: FontScale;
|
||||
setFontScale: (scale: FontScale) => void;
|
||||
accent: Accent;
|
||||
setAccent: (accent: Accent) => void;
|
||||
/** Display currency for prices (shared by Cost Center and Model Library; always stored as USD). */
|
||||
currency: Currency;
|
||||
setCurrency: (currency: Currency) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function initialMode(): ThemeMode {
|
||||
const stored = localStorage.getItem(MODE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system") return stored;
|
||||
return "system";
|
||||
}
|
||||
|
||||
function initialFontScale(): FontScale {
|
||||
const stored = localStorage.getItem(FONT_KEY);
|
||||
if (stored === "sm" || stored === "md" || stored === "lg") return stored;
|
||||
return "md";
|
||||
}
|
||||
|
||||
function initialAccent(): Accent {
|
||||
const stored = localStorage.getItem(ACCENT_KEY);
|
||||
if (
|
||||
stored === "neutral" ||
|
||||
stored === "blue" ||
|
||||
stored === "green" ||
|
||||
stored === "violet" ||
|
||||
stored === "rose" ||
|
||||
stored === "amber"
|
||||
) {
|
||||
return stored;
|
||||
}
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function initialCurrency(): Currency {
|
||||
return localStorage.getItem(CURRENCY_KEY) === "CNY" ? "CNY" : "USD";
|
||||
}
|
||||
|
||||
function systemDark(): boolean {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [mode, setModeState] = useState<ThemeMode>(initialMode);
|
||||
const [sysDark, setSysDark] = useState(systemDark);
|
||||
const [fontScale, setFontScaleState] = useState<FontScale>(initialFontScale);
|
||||
const [accent, setAccentState] = useState<Accent>(initialAccent);
|
||||
const [currency, setCurrencyState] = useState<Currency>(initialCurrency);
|
||||
|
||||
const dark = mode === "system" ? sysDark : mode === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
}, [dark]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.fontSize = FONT_PX[fontScale];
|
||||
}, [fontScale]);
|
||||
|
||||
useEffect(() => {
|
||||
// neutral relies on the CSS default (follows light/dark) and sets no data-accent.
|
||||
if (accent === "neutral") delete document.documentElement.dataset.accent;
|
||||
else document.documentElement.dataset.accent = accent;
|
||||
}, [accent]);
|
||||
|
||||
// system mode: track system preference changes.
|
||||
useEffect(() => {
|
||||
if (mode !== "system") return;
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = (e: MediaQueryListEvent) => setSysDark(e.matches);
|
||||
setSysDark(mq.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, [mode]);
|
||||
|
||||
const setMode = useCallback((next: ThemeMode) => {
|
||||
localStorage.setItem(MODE_KEY, next);
|
||||
setModeState(next);
|
||||
}, []);
|
||||
|
||||
const setFontScale = useCallback((next: FontScale) => {
|
||||
localStorage.setItem(FONT_KEY, next);
|
||||
setFontScaleState(next);
|
||||
}, []);
|
||||
|
||||
const setAccent = useCallback((next: Accent) => {
|
||||
localStorage.setItem(ACCENT_KEY, next);
|
||||
setAccentState(next);
|
||||
}, []);
|
||||
|
||||
const setCurrency = useCallback((next: Currency) => {
|
||||
localStorage.setItem(CURRENCY_KEY, next);
|
||||
setCurrencyState(next);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
mode,
|
||||
dark,
|
||||
setMode,
|
||||
fontScale,
|
||||
setFontScale,
|
||||
accent,
|
||||
setAccent,
|
||||
currency,
|
||||
setCurrency,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme 必须在 ThemeProvider 内使用");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Display swatches for theme color presets (neutral uses a neutral gray). */
|
||||
export const ACCENT_SWATCHES: ReadonlyArray<{ value: Accent; color: string }> = [
|
||||
{ value: "neutral", color: "#6b7280" },
|
||||
{ value: "blue", color: "#2563eb" },
|
||||
{ value: "green", color: "#15803d" },
|
||||
{ value: "violet", color: "#7c3aed" },
|
||||
{ value: "rose", color: "#be123c" },
|
||||
{ value: "amber", color: "#b45309" },
|
||||
];
|
||||
Reference in New Issue
Block a user