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:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
+80
View File
@@ -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;
}
+93
View File
@@ -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;
}
+159
View File
@@ -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;
}
+163
View File
@@ -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;
}
+167
View File
@@ -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" },
];