Changelog, dev startup, README, AgentHub 0.4.0, model catalog, and landing site (#7)

Branch-length batch covering tooling, the model layer, the Web App and the public
surfaces. Highlights:

- Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each
  change touches, with a root CHANGELOG.md holding one line per release.
- Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a
  lock and keeps `pnpm install` current; `pnpm dev` runs server+web together.
- AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity`
  object in place of item-level `signature`/`phase`, threaded verbatim through Trace,
  replay and resume; malformed classification adapted to the new error types.
- Model layer: a model is always referenced by an explicit `(provider, model_id)` pair.
  The provider is never inferred, guessed or defaulted -- both the catalog inference and
  the unique-match config resolution are gone, and CLI, SDK, server routes and
  run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen
  Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group.
- Web App: catalog preset sync and per-group speed test on the Models page, positional
  slash commands, a markdown renderer, skill-library update reminders, and a vertically
  centred draft page whose upward menus size themselves to the room available.
- Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed
  benchmark results for both suites, and the demo videos playing on the landing page.

Includes the fixes from a full review of the branch: 23 confirmed findings, among them a
provider-inference bug that could send one vendor's API key to another vendor's endpoint,
and an Escape handler that destroyed the composer's contents unrecoverably.

Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and
pnpm format:check clean, Playwright e2e 14/14.
This commit is contained in:
Yaowei Zheng authored and GitHub committed 2026-07-21 17:43:31 +08:00
1 parent abf0a2f248
commit d4faee3a1e
214 files changed
+9406 -1688

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 84 KiB

@@ -0,0 +1,122 @@
/**
* Rotating announcement bar above the sticky nav (it scrolls away with the page).
* Light brand-tinted background; announcements auto-advance by SLIDING in one
* direction only (a clone of the first slide follows the last one, and the track
* snaps back without animation once the clone is fully in view). No manual
* switch controls; hovering or focusing the bar pauses the rotation. Every
* announcement is a link into the blog, with a small arrow marking it as
* click-through.
*/
import { useEffect, useState } from "react";
import { Link } from "react-router";
import { S } from "../lib/strings";
import { ArrowRightIcon } from "./icons";
const ROTATE_MS = 6000;
/** Both announcements point at blog posts (content/blog/<slug>.*.md). */
const ITEMS = [
{ key: "models", to: "/blog/introducing-penguinharness" },
{ key: "fireworks", to: "/blog/fireworks-credits-amd" },
] as const;
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
function prefersReducedMotion(): boolean {
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
}
export function AnnouncementBar() {
const texts: Record<(typeof ITEMS)[number]["key"], string> = {
models: S.announcement.models,
fireworks: S.announcement.fireworks,
};
// pos runs 0..ITEMS.length where ITEMS.length is the clone of slide 0.
const [pos, setPos] = useState(0);
const [animate, setAnimate] = useState(true);
// Focus pauses like hover does: tabIndex={-1} never blurs an already focused
// link, so advancing past it would strand document focus inside an aria-hidden
// slide translated out of the viewport, and WCAG 2.2.2 wants the rotation
// stoppable without a pointer. The two are tracked apart so the pointer
// leaving the bar cannot restart it while focus is still inside.
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const paused = hovered || focused;
const [reduced, setReduced] = useState(prefersReducedMotion);
// Tracked live rather than read once, because the preference can be toggled
// while the page is open and it decides how the rotation wraps around.
useEffect(() => {
const mq = window.matchMedia(REDUCED_MOTION_QUERY);
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
setReduced(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
useEffect(() => {
if (paused) return;
// styles.css kills every transition under reduced motion, so the clone's
// transitionend never arrives and the snap-back below never runs: those
// users wrap around the real slides instead and never land on the clone.
const advance = (p: number) => {
if (reduced) return (p + 1) % ITEMS.length;
return p >= ITEMS.length ? p : p + 1;
};
const timer = setInterval(() => setPos(advance), ROTATE_MS);
return () => clearInterval(timer);
}, [paused, reduced]);
// After snapping back to the real first slide, re-enable the transition on the
// next frame pair so the jump itself never animates.
useEffect(() => {
if (animate) return;
const raf = requestAnimationFrame(() => requestAnimationFrame(() => setAnimate(true)));
return () => cancelAnimationFrame(raf);
}, [animate]);
const slides = [...ITEMS, ITEMS[0]!];
return (
<div
role="region"
aria-label={S.announcement.label}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocusCapture={() => setFocused(true)}
onBlurCapture={() => setFocused(false)}
className="border-b border-brand-100 bg-brand-50 dark:border-brand-950 dark:bg-brand-950/50"
>
<div className="mx-auto h-9 max-w-6xl overflow-hidden px-4 sm:px-6">
<div
className={`flex h-full ${animate ? "transition-transform duration-500 ease-out" : ""}`}
style={{ transform: `translateX(-${pos * 100}%)` }}
onTransitionEnd={(e) => {
if (e.target !== e.currentTarget || e.propertyName !== "transform") return;
if (pos === ITEMS.length) {
setAnimate(false);
setPos(0);
}
}}
>
{slides.map((item, i) => (
<div
key={`${item.key}-${i}`}
aria-hidden={i !== pos}
className="flex w-full shrink-0 items-center justify-center"
>
<Link
to={item.to}
tabIndex={i === pos ? 0 : -1}
className="inline-flex min-w-0 items-center gap-1.5 text-[13px] font-medium text-brand-800 underline-offset-2 hover:underline dark:text-brand-200"
>
<span className="truncate">{texts[item.key]}</span>
<ArrowRightIcon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
</Link>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
/**
* Demo video embed. The file itself is hosted in the sibling community repo (see
* `demoVideoUrl` in lib/links.ts for why), so the important part here is that nothing
* is fetched until the visitor asks: `preload="none"` plus a poster means a page view
* costs one ~37 KB still, not a ~9 MB download, and the section still looks finished
* before anyone presses play.
*
* Deliberately NOT wrapped in BrowserFrame: that chrome (with its localhost address bar)
* says "this is the product's UI", which is true of a screenshot and of the case demos,
* but not of a narrated slide deck. A plain framed surface instead.
*/
export function DemoVideo({
src,
poster,
label,
caption,
}: {
src: string;
poster: string;
/** Accessible name — the surrounding copy is decorative to a screen reader. */
label: string;
caption?: string;
}) {
return (
<figure className="mx-auto w-full max-w-4xl">
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-950 shadow-sm dark:border-gray-800">
<video
src={src}
poster={poster}
controls
preload="none"
playsInline
aria-label={label}
className="block h-auto w-full"
/>
</div>
{caption && (
<figcaption className="mt-3 text-center text-xs text-gray-500 dark:text-gray-400">
{caption}
</figcaption>
)}
</figure>
);
}
+16
View File
@@ -315,6 +315,22 @@ export function ChevronDownIcon(props: IconProps) {
);
}
export function ChevronLeftIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="m15 18-6-6 6-6" />
</Icon>
);
}
export function ChevronRightIcon(props: IconProps) {
return (
<Icon {...props}>
<path d="m9 18 6-6-6-6" />
</Icon>
);
}
export function ExternalLinkIcon(props: IconProps) {
return (
<Icon {...props}>
+43 -18
View File
@@ -1,30 +1,34 @@
/**
* Sticky top navigation: logo + section anchors + blog link + language/theme toggles +
* GitHub. Desktop links share a sliding hover pill, while the selected section or route
* keeps its own active background; section links route through "/#id" so the Router
* and URL hash stay in sync while native smooth scrolling remains enabled. A disclosure
* menu covers small screens.
* keeps its own active background. On the home page the active section tracks the
* LIVE scroll position (scroll-spy), so the highlight follows as you scroll; on other
* routes it falls back to route state (e.g. Blog). Section links route through "/#id"
* so the URL hash stays in sync on click. A disclosure menu covers small screens.
*/
import { useState } from "react";
import { useRef, useState } from "react";
import type { MouseEvent } from "react";
import { Link, useLocation } from "react-router";
import { S } from "../lib/strings";
import { DOCS_URL, REPO_URL } from "../lib/links";
import { getActiveNavItem, SECTION_IDS } from "../lib/nav-state";
import type { ActiveNavItem, SectionId } from "../lib/nav-state";
import { useScrollSpy } from "../lib/use-scroll-spy";
import { GitHubIcon, MenuIcon, XIcon } from "./icons";
import { ThemeToggle } from "./theme-toggle";
import { LangToggle } from "./lang-toggle";
interface Indicator {
left: number;
width: number;
}
/** Stable empty list: keeps the spy idle away from the home page. */
const NO_IDS: readonly string[] = [];
export function Nav() {
const { pathname, hash } = useLocation();
const activeItem = getActiveNavItem(pathname, hash);
const onHome = pathname === "/";
const spied = useScrollSpy(onHome ? SECTION_IDS : NO_IDS) as SectionId | null;
const activeItem: ActiveNavItem = onHome ? spied : getActiveNavItem(pathname, hash);
const [open, setOpen] = useState(false);
const [indicator, setIndicator] = useState<Indicator | null>(null);
const pillRef = useRef<HTMLSpanElement | null>(null);
const pillVisible = useRef(false);
const sectionLabel: Record<(typeof SECTION_IDS)[number], string> = {
highlights: S.nav.highlights,
@@ -45,9 +49,33 @@ export function Nav() {
const deskLinkCls = (active: boolean) =>
`relative z-10 rounded-md px-2.5 py-1.5 text-sm transition-colors ${active ? activeLinkCls : deskInactiveLinkCls}`;
/**
* The hover pill appears IN PLACE under the first link it lands on (position
* jumps with only the fade animating), slides while moving between links, and
* fades out where it is on leave — never sweeping in from the nav's edge.
*/
const slideTo = (e: MouseEvent<HTMLElement>) => {
const el = e.currentTarget;
setIndicator({ left: el.offsetLeft, width: el.offsetWidth });
const pill = pillRef.current;
if (!pill) return;
if (!pillVisible.current) {
pill.style.transitionProperty = "opacity";
pill.style.left = `${el.offsetLeft}px`;
pill.style.width = `${el.offsetWidth}px`;
void pill.offsetWidth; // flush the jump before restoring the full transition
pill.style.transitionProperty = "";
pillVisible.current = true;
} else {
pill.style.left = `${el.offsetLeft}px`;
pill.style.width = `${el.offsetWidth}px`;
}
pill.style.opacity = "1";
};
const hidePill = () => {
const pill = pillRef.current;
if (pill) pill.style.opacity = "0";
pillVisible.current = false;
};
const desktopLinks = (
@@ -122,17 +150,14 @@ export function Nav() {
<nav
className="relative ml-4 hidden items-center gap-0.5 md:flex"
aria-label="Primary"
onMouseLeave={() => setIndicator(null)}
onMouseLeave={hidePill}
>
{/* Sliding hover pill: moves between links; active links retain their own background. */}
{/* Sliding hover pill: appears in place, slides between links, fades out in place; active links retain their own background. */}
<span
ref={pillRef}
aria-hidden="true"
className="absolute top-1/2 h-8 -translate-y-1/2 rounded-md bg-gray-100 transition-[left,width,opacity] duration-200 ease-out dark:bg-gray-800"
style={{
left: indicator?.left ?? 0,
width: indicator?.width ?? 0,
opacity: indicator ? 1 : 0,
}}
style={{ left: 0, width: 0, opacity: 0 }}
/>
{desktopLinks}
</nav>
+50 -36
View File
@@ -1,11 +1,14 @@
/**
* Benchmark data, two suites driven by the same DeepSeek V4 Pro model, published in
* one unified shape: framework / model / accuracy (%) / Tokens (M) / cost ($), all
* as per-run means. Suite specifics (case count, runs, thinking level, timeout,
* pricing source) live in the footnote strings, not in the table.
* - Data analysis: 15 tasks, single run, USD pricing.
* - Coding: 40 tasks x 2 runs averaged; official CNY pricing converted at $1 = ¥7
* (0.289 / 0.338 / 0.299 CNY per run).
* Benchmark data, two suites published in one unified shape: framework / model /
* accuracy (%) / Tokens (M) / cost ($).
*
* Each harness runs the model it is normally paired with rather than a shared one —
* the comparison is between products as people actually use them, so the model column
* is part of the result and not a constant. Token and cost figures are **suite totals**
* across every task and run, not per-run means. Suite specifics (case count, runs,
* thinking level, pricing source) live in the footnote strings, not in the table.
* - Data analysis: 15 tasks, single run.
* - Coding: 40 tasks x 2 runs (accuracy is over all 80 outcomes).
*/
import type { HarnessKind } from "../components/harness-logo";
@@ -20,14 +23,16 @@ export interface BenchResult {
emphasized?: boolean;
}
const MODEL = "DeepSeek V4 Pro";
const DEEPSEEK = "DeepSeek V4 Pro";
const OPUS = "Claude Opus 4.8";
const GPT = "GPT-5.5";
export const DATA_BENCH: BenchResult[] = [
{
kind: "penguin",
framework: "PenguinHarness",
model: MODEL,
accuracyPct: 66.7,
model: DEEPSEEK,
accuracyPct: 66.67,
tokensM: 18.037757,
costUsd: 0.552406,
emphasized: true,
@@ -35,18 +40,18 @@ export const DATA_BENCH: BenchResult[] = [
{
kind: "claude",
framework: "Claude Code",
model: MODEL,
accuracyPct: 66.7,
tokensM: 21.166305,
costUsd: 0.640706,
model: OPUS,
accuracyPct: 53.33,
tokensM: 22.197759,
costUsd: 38.479975,
},
{
kind: "codex",
framework: "OpenAI Codex",
model: MODEL,
accuracyPct: 46.7,
tokensM: 13.362259,
costUsd: 0.427011,
model: GPT,
accuracyPct: 53.33,
tokensM: 13.72473,
costUsd: 19.413715,
},
];
@@ -54,46 +59,55 @@ export const CODE_BENCH: BenchResult[] = [
{
kind: "penguin",
framework: "PenguinHarness",
model: MODEL,
accuracyPct: 50.0,
tokensM: 2.1,
costUsd: 0.0413,
model: DEEPSEEK,
accuracyPct: 71.25,
tokensM: 200.0,
costUsd: 3.812,
emphasized: true,
},
{
kind: "claude",
framework: "Claude Code",
model: MODEL,
accuracyPct: 48.75,
tokensM: 2.0,
costUsd: 0.0483,
model: OPUS,
accuracyPct: 86.25,
tokensM: 151.61,
costUsd: 146.97,
},
{
kind: "codex",
framework: "OpenAI Codex",
model: MODEL,
accuracyPct: 42.5,
tokensM: 2.65,
costUsd: 0.0427,
model: GPT,
accuracyPct: 71.25,
tokensM: 251.2,
costUsd: 220.08,
},
];
/** 66.7 -> "66.7%" (chart caps). */
/** 66.67 -> "66.67%" (chart caps). */
export function formatPct(pct: number): string {
return `${pct.toFixed(1)}%`;
return `${pct.toFixed(2)}%`;
}
/** Table accuracy at suite precision: 66.7 (1dp) vs 48.75 (2dp). */
/** Table accuracy; both suites publish at 2dp (10/15 -> 66.67, 57/80 -> 71.25). */
export function formatAccuracy(pct: number, dp: number): string {
return pct.toFixed(dp);
}
/** 18.037757 -> "18.0M" / 2.65 -> "2.65M" (chart caps at suite precision). */
export function formatTokensM(tokens: number, dp = 1): string {
/** 18.037757 -> "18.04M" / 200 -> "200.00M". */
export function formatTokensM(tokens: number, dp = 2): string {
return `${tokens.toFixed(dp)}M`;
}
/** Chart cost caps at suite precision: $0.55 vs $0.041. */
/** Chart cost caps: $0.55 vs $220.08 — one format across a three-order-of-magnitude spread. */
export function formatUsd(cost: number, dp = 2): string {
return `$${cost.toFixed(dp)}`;
}
/**
* How many times more a rival spent for the same suite ($38.48 vs $0.55 -> 70). The
* headline claim is built from the data rather than typed into the copy, so it cannot
* drift when the numbers are refreshed.
*/
export function costMultiple(rival: BenchResult, ours: BenchResult): number {
return Math.round(rival.costUsd / ours.costUsd);
}
+20 -2
View File
@@ -11,8 +11,26 @@ export const LICENSE_URL = `${REPO_URL}/blob/main/LICENSE`;
*/
export const DOCS_URL = `${import.meta.env.BASE_URL}docs/`;
/** One-line installer (Linux / macOS, x64 / arm64, bundled Node runtime). */
export const INSTALL_CMD = `curl -fsSL ${REPO_URL}/releases/latest/download/install.sh | sh`;
/**
* One-line installer (Linux / macOS, x64 / arm64, bundled Node runtime).
* penguin.ooo/install.sh is this site's own public/install.sh — a thin forwarder
* to the latest GitHub release installer (Pages cannot serve real redirects).
*/
export const INSTALL_CMD = "curl -fsSL https://penguin.ooo/install.sh | sh";
/**
* Demo videos live in the sibling `penguin-harness-community` repo rather than in this
* one: they are ~9 MB each, and this repo's whole history is ~17 MB, so committing them
* here would triple what every contributor clones for assets only the marketing site
* shows. Served by raw.githubusercontent with `accept-ranges: bytes` (seeking works),
* `access-control-allow-origin: *` and a 5-minute cache. The `application/octet-stream`
* content type does not block playback — `nosniff` is only enforced for scripts and
* styles, and `<video>` sniffs the container itself (verified in Chromium).
* Pair every embed with a poster and `preload="none"`: nothing is fetched until play.
*/
const COMMUNITY_RAW =
"https://github.com/Prism-Shadow/penguin-harness-community/raw/refs/heads/main";
export const demoVideoUrl = (name: string): string => `${COMMUNITY_RAW}/videos/${name}.mp4`;
/** API key consoles (same URLs the in-app Models page links to). */
export const DEEPSEEK_KEYS_URL = "https://platform.deepseek.com/api_keys";
+76 -20
View File
@@ -8,6 +8,14 @@ import type { Strings } from "./strings";
export const en: Strings = {
siteName: "PenguinHarness",
announcement: {
label: "Announcements",
prev: "Previous announcement",
next: "Next announcement",
models: "Kimi K3 and Qwen 3.8 Max are now available in PenguinHarness",
fireworks: "Claim $50 in Fireworks API credits with the AMD Developer Program",
},
nav: {
highlights: "Highlights",
quickstart: "Quick start",
@@ -87,11 +95,33 @@ export const en: Strings = {
],
},
compare: {
eyebrow: "vs. LangChain",
title: "The gap between 1× and 100×",
subtitle:
"With LangChain, you build agents by hand — at 1× speed. With PenguinHarness, agents build agents — at 100×.",
langchain: {
name: "LangChain",
speed: "1×",
mode: "Agents built by hand",
note: "Chains, tools and prompts written line by line — every app starts from zero.",
},
penguin: {
name: "PenguinHarness",
speed: "100×",
mode: "Agents built by agents",
note: "One sentence in — an Agent delivers scaffold, code and run instructions end to end.",
},
},
selfImprove: {
eyebrow: "The self-improvement loop",
title: "Multi-agent collaboration makes evolution automatic",
subtitle:
"The Optimizer orchestrates multiple Evaluators to score the Target Agent in parallel, uses the scores and run traces to find where points were lost, and upgrades the Agent from version N to N+1 — with a snapshot before every round.",
videoLabel: "Self-improvement demo video",
videoCaption:
"The self-improvement loop end to end: run the benchmark, find the lost points, ship the next version.",
nodeOptimizer: "Optimizer",
nodeEvaluator: "Evaluator × N",
nodeTarget: "Target Agent",
@@ -122,7 +152,7 @@ export const en: Strings = {
tabCli: "CLI",
webStep2: "Open the web interface",
webStep2Desc:
"penguin web starts the local service and opens your browser; sign in with the built-in admin account admin / admin123 (change the password right after).",
"penguin web starts the local service and opens your browser; sign in with the built-in admin account admin / penguin-2026 (change the password right after).",
webCmd: "penguin web # opens http://127.0.0.1:7364",
webStep3: "Configure a model in the UI and start chatting",
webStep3Desc:
@@ -136,11 +166,12 @@ export const en: Strings = {
tabDeepseek: "DeepSeek",
tabOpenrouter: "OpenRouter",
deepseekCmd: `penguin config model add \\
--provider deepseek \\
--model-id deepseek-v4-pro \\
--api-key sk-your-deepseek-key \\
--set-default`,
deepseekNote:
"The provider is inferred as deepseek automatically; omit --api-key to fall back to the DEEPSEEK_API_KEY environment variable.",
"A model is always referenced by the (provider, model_id) pair, so --provider is required; omit --api-key to fall back to the DEEPSEEK_API_KEY environment variable.",
openrouterCmd: `penguin config model add \\
--provider openrouter \\
--model-id deepseek/deepseek-v4-pro \\
@@ -155,19 +186,29 @@ export const en: Strings = {
--message "Analyze data.csv and summarize quarterly sales"`,
},
showcase: {
eyebrow: "Use cases",
title: "Daily tasks + zero-code AI development",
cases: {
eyebrow: "Cases",
title: "From one sentence to a running app",
subtitle:
"Hand recurring chores to an Agent that runs around the clock — and go from a one-sentence request to a runnable Agent app without writing a line of code.",
tagChat: "Zero-code AI development",
captionChat:
"Development by conversation: the Agent builds an Agent app from scratch, tools really execute",
tagTraces: "Daily tasks",
captionTraces:
"Autonomous runs, fully replayable: every request and tool call with usage and timing",
tagBenchmark: "Continuous evolution",
captionBenchmark: "Built-in Benchmark scoreboards — scores climb with every round",
"Hand the requirement to an Agent and get a runnable result end to end — more cases are on the way.",
tabs: [
{
label: "RAG app",
prompt:
"Collect the docs from https://github.com/ericbuess/claude-code-docs and build a RAG app that answers Claude Code questions as a configuration expert, citing its sources.",
caption:
"The generated RAG app: a Claude Code docs expert answering with cited, clickable sources and example questions",
cost: "And generating this entire RAG app burned just $0.02 (¥0.2) of tokens — on DeepSeek V4 Pro.",
},
{
label: "2D penguin sled game",
prompt:
"Build a cute Antarctic penguin sledding game: Space to jump the rocks, with speed and difficulty ramping up over time.",
caption:
"The generated mini game: an Antarctic penguin sleds and jumps rocks, with live scoring and rising difficulty",
cost: "",
},
],
},
contract: {
@@ -224,9 +265,9 @@ export const en: Strings = {
benchmark: {
eyebrow: "Benchmark",
title: "Same model, equal or better quality, lower cost",
title: "Each with its flagship model — comparable quality, 1-2 orders of magnitude cheaper",
subtitle:
"All runs use the same DeepSeek V4 Pro model — head-to-head against Claude Code and OpenAI Codex on two suites.",
"Every product runs the model it is normally paired with, head-to-head against Claude Code and OpenAI Codex on two suites: comparable accuracy, tens of times the difference in spend.",
higherBetter: "higher is better",
lowerBetter: "lower is better",
dimScore: "Accuracy",
@@ -234,12 +275,14 @@ export const en: Strings = {
dimCost: "Cost",
dataTitle: "Complex data analysis",
dataDesc:
"Ties Claude Code on accuracy and clearly beats OpenAI Codex — with fewer Tokens at lower cost.",
"Best accuracy of the three (66.67%, against 53.33% for both rivals), at 1/35 of OpenAI Codex's cost and 1/70 of Claude Code's.",
dataFootnote:
"15 complex data-analysis tasks · averaged over 1 run · cost estimated at official pricing.",
"15 complex data-analysis tasks · single run · Tokens and cost are suite totals · estimated at official pricing.",
codeTitle: "Coding tasks",
codeDesc: "Highest accuracy of the three at the lowest per-run cost.",
codeFootnote: "40 coding tasks · averaged over 2 runs · cost estimated at official pricing.",
codeDesc:
"Ties OpenAI Codex on accuracy (71.25%) and trails Claude Code (86.25%) — at 1/58 and 1/39 of their cost.",
codeFootnote:
"40 coding tasks × 2 runs (accuracy over all 80 outcomes) · Tokens and cost are suite totals · estimated at official pricing.",
colFramework: "Framework",
colModel: "Model",
colAccuracy: "Accuracy (%)",
@@ -251,6 +294,7 @@ export const en: Strings = {
eyebrow: "Features",
title: "The full capability set, one desktop-grade UI",
subtitle: "One-to-one with the web interface's menu — installed means ready.",
more: "and more…",
items: [
{
title: "Multi-session chat",
@@ -315,6 +359,18 @@ export const en: Strings = {
],
},
community: {
eyebrow: "Community",
title: "Join the community and build with us",
subtitle: "Discuss, ask, contribute — your first Issue is the best way to start.",
items: {
discord: { name: "Discord", desc: "Chat with us and other developers in real time." },
x: { name: "X (Twitter)", desc: "Follow the latest product and team updates." },
wechat: { name: "WeChat group", desc: "Chinese community discussions and support." },
github: { name: "GitHub", desc: "Stars, Issues, and PRs all welcome." },
},
},
cta: {
title: "Complex AI development, made ever simpler",
subtitle:
+75 -19
View File
@@ -9,6 +9,14 @@
export const zh = {
siteName: "PenguinHarness",
announcement: {
label: "公告",
prev: "上一条公告",
next: "下一条公告",
models: "Kimi K3 与 Qwen 3.8 Max 模型现已在 PenguinHarness 可用",
fireworks: "携手 AMD 开发者计划:$50 Fireworks API 额度免费领取中",
},
nav: {
highlights: "特色",
quickstart: "快速开始",
@@ -91,11 +99,32 @@ export const zh = {
],
},
compare: {
eyebrow: "对比 LangChain",
title: "1× 与 100× 的差距",
subtitle:
"使用 LangChain,以 1 倍速度人工构建 Agent;使用 PenguinHarness,以 100 倍速度用 Agent 构建 Agent。",
langchain: {
name: "LangChain",
speed: "1×",
mode: "人工构建 Agent",
note: "逐行编写链路、工具与提示词,每个应用都从零开始。",
},
penguin: {
name: "PenguinHarness",
speed: "100×",
mode: "Agent 构建 Agent",
note: "一句话需求,Agent 端到端交付脚手架、代码与运行说明。",
},
},
selfImprove: {
eyebrow: "自我提升循环",
title: "多 Agent 协作,进化自动发生",
subtitle:
"Optimizer 组织多个 Evaluator 为 Target Agent 并行打分,依据分数与运行轨迹定位失分原因,把 Agent 从版本 N 优化到版本 N+1——每一轮都有快照,随时可回退。",
videoLabel: "自我进化演示视频",
videoCaption: "自我进化演示:Agent 跑评测、定位失分点、发布下一版——完整一轮。",
nodeOptimizer: "Optimizer",
nodeEvaluator: "Evaluator × N",
nodeTarget: "Target Agent",
@@ -126,7 +155,7 @@ export const zh = {
tabCli: "命令行",
webStep2: "启动 Web 界面",
webStep2Desc:
"penguin web 启动本地服务并打开浏览器,用内置管理员 admin / admin123 登录(登录后请尽快修改密码)。",
"penguin web 启动本地服务并打开浏览器,用内置管理员 admin / penguin-2026 登录(登录后请尽快修改密码)。",
webCmd: "penguin web # 打开 http://127.0.0.1:7364",
webStep3: "在界面里配置模型,开始对话",
webStep3Desc:
@@ -139,10 +168,12 @@ export const zh = {
tabDeepseek: "DeepSeek",
tabOpenrouter: "OpenRouter",
deepseekCmd: `penguin config model add \\
--provider deepseek \\
--model-id deepseek-v4-pro \\
--api-key sk-your-deepseek-key \\
--set-default`,
deepseekNote: "provider 自动推断为 deepseek;省略 --api-key 时回退环境变量 DEEPSEEK_API_KEY。",
deepseekNote:
"模型引用始终是 (provider, model_id) 二元组,--provider 必填;省略 --api-key 时回退环境变量 DEEPSEEK_API_KEY。",
openrouterCmd: `penguin config model add \\
--provider openrouter \\
--model-id deepseek/deepseek-v4-pro \\
@@ -155,17 +186,25 @@ export const zh = {
--message "分析 data.csv,输出各季度销售额汇总"`,
},
showcase: {
eyebrow: "使用场景",
title: "日常任务 + 零代码 AI 研发",
subtitle:
"把重复琐事交给 Agent 全天候自主执行;不写一行代码,从一句需求到可运行的 Agent 应用。",
tagChat: "零代码 AI 研发",
captionChat: "对话即开发:Agent 从零构建一个 Agent 应用,工具真实执行",
tagTraces: "日常任务",
captionTraces: "自主执行全程可回放:每次请求与工具调用、用量与耗时全量可查",
tagBenchmark: "持续进化",
captionBenchmark: "内建 Benchmark 记分板,分数随进化不断上升",
cases: {
eyebrow: "案例",
title: "从一句话到可运行的应用",
subtitle: "把需求交给 Agent,端到端拿到可运行的结果;更多案例陆续加入。",
tabs: [
{
label: "RAG 应用",
prompt:
"收集 https://github.com/ericbuess/claude-code-docs 的文档,做一个化身 Claude Code 配置专家、回答带来源引用的 RAG 问答应用。",
caption: "生成的 RAG 应用成品:Claude Code 配置专家,回答带可点击的来源引用与示例问题",
cost: "而生成整个 RAG 应用,仅消耗了 0.2 元($0.02)的 token——使用 DeepSeek V4 Pro 模型。",
},
{
label: "2D 企鹅雪橇小游戏",
prompt: "做一个可爱的南极企鹅滑雪橇越野小游戏:空格起跳跃过石头,速度与难度随时间上升。",
caption: "生成的小游戏成品:南极企鹅滑雪橇跳石头越野,实时计分,难度渐进",
cost: "",
},
],
},
contract: {
@@ -220,20 +259,24 @@ export const zh = {
benchmark: {
eyebrow: "Benchmark",
title: "同一模型,效果同级或更好,消耗更低",
title: "各用各的旗舰模型,效果同级,成本低一到两个数量级",
subtitle:
"全部使用同一 DeepSeek V4 Pro 模型,与 Claude Code、OpenAI Codex 在两套题库上正面对比。",
"每个产品搭配它常用的模型,与 Claude Code、OpenAI Codex 在两套题库上正面对比:准确率同级,花的钱差出几十倍。",
higherBetter: "越高越好",
lowerBetter: "越低越好",
dimScore: "准确率",
dimTokens: "Token 用量",
dimCost: "成本",
dataTitle: "复杂数据分析",
dataDesc: "与 Claude Code 准确率持平、显著超过 OpenAI Codex,同时 Token 与成本更低。",
dataFootnote: "15 道复杂数据分析任务 · 1 次运行取均值 · 成本按官方计价估算。",
dataDesc:
"三者中准确率最高(66.67%,另两者均为 53.33%),成本只有 OpenAI Codex 的 1/35、Claude Code 的 1/70。",
dataFootnote:
"15 道复杂数据分析任务 · 单次运行 · Token 与成本为全套题目合计 · 按官方计价估算。",
codeTitle: "代码任务",
codeDesc: "三者中准确率最高、单次成本最低。",
codeFootnote: "40 道代码任务 · 2 次运行取均值 · 成本按官方计价估算。",
codeDesc:
"准确率与 OpenAI Codex 持平(71.25%)、低于 Claude Code(86.25%),但成本只有前者的 1/58、后者的 1/39。",
codeFootnote:
"40 道代码任务 × 2 次运行(准确率取全部 80 次结果)· Token 与成本为全套题目合计 · 按官方计价估算。",
colFramework: "实验框架",
colModel: "模型名称",
colAccuracy: "准确率(%)",
@@ -245,6 +288,7 @@ export const zh = {
eyebrow: "主要功能",
title: "桌面级界面里的完整能力",
subtitle: "与 Web 界面的菜单一一对应,装好即用。",
more: "以及更多……",
items: [
{
title: "多 Session 会话",
@@ -309,6 +353,18 @@ export const zh = {
],
},
community: {
eyebrow: "社区",
title: "加入社区,一起共建",
subtitle: "讨论、提问、贡献——你的第一个 Issue 就是最好的开始。",
items: {
discord: { name: "Discord", desc: "与我们和其他开发者实时交流。" },
x: { name: "X(Twitter)", desc: "关注产品与团队的最新动态。" },
wechat: { name: "微信群", desc: "中文社区讨论与互助。" },
github: { name: "GitHub", desc: "Star、Issue 与 PR 都欢迎。" },
},
},
cta: {
title: "让复杂的 AI 开发越来越简单",
subtitle:
@@ -0,0 +1,48 @@
/**
* Live scroll-spy for the landing nav: returns the id of the LAST section whose
* anchor top has crossed the activation line under the sticky header, or null
* while the viewport is still above the first section (the hero). Scroll events
* are captured at the document level so any scrolling container works, and
* positions are measured with viewport rects (rAF-throttled).
*/
import { useEffect, useState } from "react";
/** Just below the sticky header plus the .section-anchor scroll margin (5.5rem). */
const ACTIVATION_LINE_PX = 96;
export function useScrollSpy(ids: readonly string[]): string | null {
const [active, setActive] = useState<string | null>(null);
const key = ids.join("|");
useEffect(() => {
if (ids.length === 0) {
setActive(null);
return;
}
let raf = 0;
const measure = () => {
raf = 0;
let current: string | null = null;
for (const id of ids) {
const el = document.getElementById(id);
if (el && el.getBoundingClientRect().top <= ACTIVATION_LINE_PX) current = id;
}
setActive(current);
};
const schedule = () => {
if (raf === 0) raf = requestAnimationFrame(measure);
};
measure();
document.addEventListener("scroll", schedule, { capture: true, passive: true });
window.addEventListener("resize", schedule);
return () => {
document.removeEventListener("scroll", schedule, { capture: true });
window.removeEventListener("resize", schedule);
if (raf) cancelAnimationFrame(raf);
};
// The id list is captured via its joined key.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return active;
}
+6 -2
View File
@@ -1,28 +1,32 @@
/** Home: section composition, ordered to match the nav anchors. */
import { Hero } from "../sections/hero";
import { Pillars } from "../sections/pillars";
import { Compare } from "../sections/compare";
import { SelfImprove } from "../sections/self-improve";
import { Quickstart } from "../sections/quickstart";
import { Showcase } from "../sections/showcase";
import { Cases } from "../sections/cases";
import { Benchmark } from "../sections/benchmark";
import { Contract } from "../sections/contract";
import { Features } from "../sections/features";
import { Security } from "../sections/security";
import { Cta } from "../sections/cta";
import { Community } from "../sections/community";
export function HomePage() {
return (
<>
<Hero />
<Pillars />
<Compare />
<SelfImprove />
<Quickstart />
<Showcase />
<Cases />
<Benchmark />
<Contract />
<Features />
<Security />
<Cta />
<Community />
</>
);
}
+2
View File
@@ -6,6 +6,7 @@
import { useEffect } from "react";
import { BrowserRouter, Navigate, Outlet, Route, Routes, useLocation } from "react-router";
import { DOCS_URL } from "./lib/links";
import { AnnouncementBar } from "./components/announcement-bar";
import { Nav } from "./components/nav";
import { Footer } from "./components/footer";
import { NeonBackground } from "./components/neon-bg";
@@ -56,6 +57,7 @@ function Layout() {
return (
<div className="relative flex min-h-full flex-col">
<NeonBackground />
<AnnouncementBar />
<Nav />
<main className="flex-1">
<Outlet />
+16 -9
View File
@@ -1,9 +1,9 @@
/**
* Benchmark section: two suites (complex data analysis + coding tasks), same model
* everywhere, rendered in one identical format — three small multiples (accuracy /
* Tokens / cost, one measure per axis) plus a five-column table of per-run means
* (framework / model / accuracy % / Tokens M / cost $). Suite specifics (case count,
* runs, thinking level, timeout, pricing) sit in a small footnote under each table.
* Benchmark section: two suites (complex data analysis + coding tasks), each harness on
* the model it is normally paired with, rendered in one identical format — three small
* multiples (accuracy / Tokens / cost, one measure per axis) plus a five-column table of
* suite totals (framework / model / accuracy % / Tokens M / cost $). Suite specifics
* (case count, runs, thinking level, timeout, pricing) sit in a footnote under each table.
* Emphasis form: PenguinHarness wears the brand hue, competitors the de-emphasis
* gray; per-bar identity comes from logo+name labels, every cap is value-labeled,
* and the exact table relieves the sub-3:1 gray fills.
@@ -40,6 +40,13 @@ const VIEW_H = 210;
const PLOT_TOP = 28;
const BASELINE = 168;
const BAR_W = 24;
/**
* Shortest a bar may render. At true scale the cost panels put PenguinHarness at ~1.6px
* against a 140px plot (the spread is ~70x), which reads as an empty slot rather than a
* winning series. The floor keeps it visible while still plainly the smallest bar, and
* every cap carries its exact value.
*/
const MIN_BAR_H = 12;
function BarPanel({
title,
@@ -77,7 +84,7 @@ function BarPanel({
>
{rows.map((row, i) => {
const v = values[i] ?? 0;
const h = ((v - lo) / (hi - lo)) * (BASELINE - PLOT_TOP);
const h = Math.max(MIN_BAR_H, ((v - lo) / (hi - lo)) * (BASELINE - PLOT_TOP));
const yTop = BASELINE - h;
const cx = slot * i + slot / 2;
return (
@@ -248,9 +255,9 @@ export function Benchmark() {
title={S.benchmark.dataTitle}
desc={S.benchmark.dataDesc}
rows={DATA_BENCH}
accDp={1}
accDp={2}
tokenDp={2}
costDp={3}
costDp={2}
footnote={S.benchmark.dataFootnote}
/>
<SuiteBlock
@@ -259,7 +266,7 @@ export function Benchmark() {
rows={CODE_BENCH}
accDp={2}
tokenDp={2}
costDp={3}
costDp={2}
footnote={S.benchmark.codeFootnote}
/>
</div>
+142
View File
@@ -0,0 +1,142 @@
/**
* Use-case gallery as switchable tabs — the RAG docs expert and the penguin sled
* game, each shown as its FINISHED PRODUCT (mockup shots matched to the visitor's
* locale and theme) under its condensed one-sentence prompt. Tab order follows
* S.cases.tabs; CASE_SHOTS is index-aligned with it. A tab may carry a `cost`
* line (the RAG one does) — an emphasized token-cost hook under the caption.
*/
import { useState } from "react";
import { S } from "../lib/strings";
import { useLocale } from "../state/locale";
import type { Locale } from "../state/locale";
import { Section } from "../components/section";
import { BrowserFrame } from "../components/browser-frame";
import { demoVideoUrl } from "../lib/links";
import ragAppZhLight from "../assets/rag-app-zh-light.webp";
import ragAppZhDark from "../assets/rag-app-zh-dark.webp";
import ragAppEnLight from "../assets/rag-app-en-light.webp";
import ragAppEnDark from "../assets/rag-app-en-dark.webp";
import gameZhLight from "../assets/game-zh-light.webp";
import gameZhDark from "../assets/game-zh-dark.webp";
import gameEnLight from "../assets/game-en-light.webp";
import gameEnDark from "../assets/game-en-dark.webp";
type ShotSet = Record<Locale, { light: string; dark: string }>;
/**
* Demo recording for a case, index-aligned with S.cases.tabs; null where the finished
* product is still shown as a still. The community-hosted file only downloads on play
* (preload="none"), and the matching screenshot doubles as the poster — so a video tab
* costs a visitor exactly what an image tab already did until they press play.
*/
const CASE_VIDEOS: Array<Record<Locale, string> | null> = [
{ zh: demoVideoUrl("rag_zh"), en: demoVideoUrl("rag_en") },
null,
];
/** Finished-product shots, index-aligned with S.cases.tabs. */
const CASE_SHOTS: ShotSet[] = [
{
zh: { light: ragAppZhLight, dark: ragAppZhDark },
en: { light: ragAppEnLight, dark: ragAppEnDark },
},
{
zh: { light: gameZhLight, dark: gameZhDark },
en: { light: gameEnLight, dark: gameEnDark },
},
];
export function Cases() {
const { locale } = useLocale();
const tabs = S.cases.tabs;
const [active, setActive] = useState(0);
const tab = tabs[active] ?? tabs[0]!;
const shots = (CASE_SHOTS[active] ?? CASE_SHOTS[0]!)[locale];
const video = (CASE_VIDEOS[active] ?? null)?.[locale];
return (
<Section id="cases" eyebrow={S.cases.eyebrow} title={S.cases.title} subtitle={S.cases.subtitle}>
<div
role="tablist"
aria-label={S.cases.eyebrow}
className="flex flex-wrap justify-center gap-2"
>
{tabs.map((t, i) => (
<button
key={t.label}
type="button"
role="tab"
aria-selected={i === active}
onClick={() => setActive(i)}
className={`rounded-full border px-3.5 py-1.5 text-sm transition-colors ${
i === active
? "border-gray-900 bg-gray-900 text-white dark:border-gray-100 dark:bg-gray-100 dark:text-gray-900"
: "border-gray-200 text-gray-600 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-gray-900"
}`}
>
{t.label}
</button>
))}
</div>
<figure role="tabpanel" className="anim-fade mx-auto mt-8 max-w-5xl" key={tab.label}>
<div className="mx-auto mb-5 w-fit max-w-full overflow-x-auto rounded-xl border border-gray-200 bg-gray-50 px-4 py-2.5 dark:border-gray-800 dark:bg-gray-900">
<code className="font-mono text-[13px] whitespace-nowrap text-gray-800 dark:text-gray-200">
<span className="mr-2 text-gray-400 select-none dark:text-gray-500">&gt;</span>
{tab.prompt}
</code>
</div>
<BrowserFrame>
{/* Light/dark are two elements rather than one with a swapped source: a <video>
takes a single poster, and the theme-matched still is what stands in for the
recording before play. Only the visible one's poster is ever fetched. */}
{video ? (
<>
<video
src={video}
poster={shots.light}
controls
preload="none"
playsInline
aria-label={tab.caption}
className="block h-auto w-full bg-gray-950 dark:hidden"
/>
<video
src={video}
poster={shots.dark}
controls
preload="none"
playsInline
aria-label={tab.caption}
className="hidden h-auto w-full bg-gray-950 dark:block"
/>
</>
) : (
<>
<img
src={shots.light}
alt={tab.caption}
loading="lazy"
className="block h-auto w-full dark:hidden"
/>
<img
src={shots.dark}
alt={tab.caption}
loading="lazy"
className="hidden h-auto w-full dark:block"
/>
</>
)}
</BrowserFrame>
<figcaption className="mt-3 text-center text-xs text-gray-500 dark:text-gray-400">
{tab.caption}
</figcaption>
{tab.cost && (
<p className="mt-2 text-center text-[13px] font-semibold text-brand-700 dark:text-brand-300">
{tab.cost}
</p>
)}
</figure>
</Section>
);
}
@@ -0,0 +1,49 @@
/**
* Community links, closing the page: Discord / X / WeChat group / GitHub as
* outbound cards — discuss, follow, and build with us.
*/
import { S } from "../lib/strings";
import { REPO_URL } from "../lib/links";
import { Section } from "../components/section";
import { ArrowRightIcon } from "../components/icons";
const HREFS = {
discord: "https://discord.gg/eFHKqqcU3D",
x: "https://x.com/code_hiyouga",
wechat: "https://github.com/Prism-Shadow/penguin-harness-community/blob/main/wechat/group.jpg",
github: REPO_URL,
} as const;
export function Community() {
const items = (Object.keys(HREFS) as Array<keyof typeof HREFS>).map((key) => ({
key,
href: HREFS[key],
...S.community.items[key],
}));
return (
<Section
id="community"
eyebrow={S.community.eyebrow}
title={S.community.title}
subtitle={S.community.subtitle}
>
<div className="mx-auto grid max-w-4xl gap-4 sm:grid-cols-2 lg:grid-cols-4">
{items.map((item) => (
<a
key={item.key}
href={item.href}
target="_blank"
rel="noreferrer"
className="group rounded-xl border border-gray-200 bg-white p-5 transition-colors hover:border-gray-300 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700"
>
<p className="flex items-center justify-between text-[15px] font-semibold tracking-tight">
{item.name}
<ArrowRightIcon className="h-4 w-4 text-gray-400 transition-transform group-hover:translate-x-0.5 group-hover:text-gray-600 dark:group-hover:text-gray-300" />
</p>
<p className="mt-1.5 text-sm leading-6 text-gray-600 dark:text-gray-400">{item.desc}</p>
</a>
))}
</div>
</Section>
);
}
+126
View File
@@ -0,0 +1,126 @@
/**
* LangChain comparison: the 1x/100x story as two cards with a looping
* "construction" animation — on a shared cycle, the LangChain side lays one
* block at a time (still unfinished when the cycle resets) while the
* PenguinHarness side raises a whole skyline almost instantly and holds it.
* Reduced-motion users see both skylines complete.
*/
import type { CSSProperties, ReactNode } from "react";
import { S } from "../lib/strings";
import { Section } from "../components/section";
/** Shared build cycle (s): both sides reset together so the pace gap is obvious. */
const CYCLE_S = 7.2;
function Block({
delay,
className,
}: {
/** Seconds into the cycle when this block lands (applied as a negative delay). */
delay: number;
className: string;
}) {
return (
<span
className={`anim-block rounded-[3px] ${className}`}
style={{ "--cycle": `${CYCLE_S}s`, animationDelay: `${delay - CYCLE_S}s` } as CSSProperties}
/>
);
}
/** One slow tower: a block every ~1.15s — the cycle ends before it tops out. */
function SlowBuild() {
return (
<div aria-hidden="true" className="flex h-28 items-end justify-center">
<span className="flex flex-col-reverse gap-1">
{Array.from({ length: 6 }, (_, i) => (
<Block key={i} delay={0.4 + i * 1.15} className="h-3 w-10 bg-gray-300 dark:bg-gray-600" />
))}
</span>
</div>
);
}
/** A skyline of towers raised in a burst: every block lands within ~1.6s. */
function FastBuild() {
const towers = [7, 9, 6, 8];
return (
<div aria-hidden="true" className="flex h-28 items-end justify-center gap-1.5">
{towers.map((height, k) => (
<span key={k} className="flex flex-col-reverse gap-1">
{Array.from({ length: height }, (_, j) => (
<Block
key={j}
delay={0.4 + k * 0.1 + j * 0.14}
className="h-2 w-7 bg-brand-500 dark:bg-brand-400"
/>
))}
</span>
))}
</div>
);
}
function CompareCard({
name,
speed,
mode,
note,
emphasized = false,
children,
}: {
name: string;
speed: string;
mode: string;
note: string;
emphasized?: boolean;
children: ReactNode;
}) {
return (
<article
className={`rounded-xl border p-6 text-center sm:p-8 ${
emphasized
? "border-brand-300 bg-brand-25 dark:border-brand-800 dark:bg-brand-950/40"
: "border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
}`}
>
<p
className={`text-sm font-semibold tracking-tight ${
emphasized ? "text-brand-700 dark:text-brand-300" : "text-gray-500 dark:text-gray-400"
}`}
>
{name}
</p>
<p
className={`mt-2 text-4xl font-semibold tracking-tight tabular-nums sm:text-5xl ${
emphasized ? "text-brand-600 dark:text-brand-300" : "text-gray-400 dark:text-gray-600"
}`}
>
{speed}
</p>
<div className="mt-5">{children}</div>
<p className="mt-5 text-[15px] font-medium text-gray-900 dark:text-gray-100">{mode}</p>
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">{note}</p>
</article>
);
}
export function Compare() {
return (
<Section
id="compare"
eyebrow={S.compare.eyebrow}
title={S.compare.title}
subtitle={S.compare.subtitle}
>
<div className="mx-auto grid max-w-4xl gap-5 sm:grid-cols-2">
<CompareCard {...S.compare.langchain}>
<SlowBuild />
</CompareCard>
<CompareCard {...S.compare.penguin} emphasized>
<FastBuild />
</CompareCard>
</div>
</Section>
);
}
+128 -9
View File
@@ -1,10 +1,15 @@
/**
* Feature grid mirroring the Web App's menu: multi-session chat, agent hub, skill
* library, scheduled tasks, subagents, cost center, trace view, agent evaluation,
* multi-user management — icon + title + one-line description.
* Feature section in two layers: the three features with real captures
* (multi-session chat, trace view, agent evaluation) form switchable tabs whose
* active panel embeds the locale/theme-matched screenshot; the remaining
* features sit below as a plain card grid, closed by an "and more…" card.
*/
import { useState } from "react";
import { S } from "../lib/strings";
import { useLocale } from "../state/locale";
import type { Locale } from "../state/locale";
import { Section } from "../components/section";
import { BrowserFrame } from "../components/browser-frame";
import {
ActivityIcon,
BarChartIcon,
@@ -16,6 +21,62 @@ import {
SparklesIcon,
UsersIcon,
} from "../components/icons";
import chatZhLight from "../assets/shots/chat-zh-light.webp";
import chatZhDark from "../assets/shots/chat-zh-dark.webp";
import chatEnLight from "../assets/shots/chat-en-light.webp";
import chatEnDark from "../assets/shots/chat-en-dark.webp";
import tracesZhLight from "../assets/shots/traces-zh-light.webp";
import tracesZhDark from "../assets/shots/traces-zh-dark.webp";
import tracesEnLight from "../assets/shots/traces-en-light.webp";
import tracesEnDark from "../assets/shots/traces-en-dark.webp";
import benchmarkZhLight from "../assets/shots/benchmark-zh-light.webp";
import benchmarkZhDark from "../assets/shots/benchmark-zh-dark.webp";
import benchmarkEnLight from "../assets/shots/benchmark-en-light.webp";
import benchmarkEnDark from "../assets/shots/benchmark-en-dark.webp";
type ShotSet = Record<Locale, { light: string; dark: string }>;
const SHOTS: Record<"chat" | "traces" | "benchmark", ShotSet> = {
chat: {
zh: { light: chatZhLight, dark: chatZhDark },
en: { light: chatEnLight, dark: chatEnDark },
},
traces: {
zh: { light: tracesZhLight, dark: tracesZhDark },
en: { light: tracesEnLight, dark: tracesEnDark },
},
benchmark: {
zh: { light: benchmarkZhLight, dark: benchmarkZhDark },
en: { light: benchmarkEnLight, dark: benchmarkEnDark },
},
};
function ThemedShot({ set, alt }: { set: ShotSet; alt: string }) {
const { locale } = useLocale();
const pair = set[locale];
// Explicit intrinsic size (all shots are 1920x1200): reserves layout before the
// image loads, so switching tabs doesn't shift the page.
return (
<>
<img
src={pair.light}
alt={alt}
width={1920}
height={1200}
loading="lazy"
className="block h-auto w-full dark:hidden"
/>
<img
src={pair.dark}
alt={alt}
width={1920}
height={1200}
loading="lazy"
className="hidden h-auto w-full dark:block"
/>
</>
);
}
/** Icon order matches S.features.items. */
const ICONS = [
@@ -30,7 +91,21 @@ const ICONS = [
UsersIcon,
];
/** Item indexes with a real capture, in tab order. */
const SHOT_TABS: Array<{ index: number; shot: keyof typeof SHOTS }> = [
{ index: 0, shot: "chat" },
{ index: 6, shot: "traces" },
{ index: 7, shot: "benchmark" },
];
/** The rest of the grid, in the original item order. */
const PLAIN_INDEXES = [1, 2, 3, 4, 5, 8];
export function Features() {
const [active, setActive] = useState(0);
const tab = SHOT_TABS[active] ?? SHOT_TABS[0]!;
const item = S.features.items[tab.index]!;
return (
<Section
id="features"
@@ -38,12 +113,53 @@ export function Features() {
title={S.features.title}
subtitle={S.features.subtitle}
>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{S.features.items.map((item, i) => {
const IconCmp = ICONS[i] ?? SparklesIcon;
<div
role="tablist"
aria-label={S.features.eyebrow}
className="flex flex-wrap justify-center gap-2"
>
{SHOT_TABS.map((t, i) => {
const tabItem = S.features.items[t.index]!;
const TabIcon = ICONS[t.index] ?? SparklesIcon;
const activeTab = i === active;
return (
<button
key={t.shot}
type="button"
role="tab"
aria-selected={activeTab}
onClick={() => setActive(i)}
className={`inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-sm transition-colors ${
activeTab
? "border-gray-900 bg-gray-900 text-white dark:border-gray-100 dark:bg-gray-100 dark:text-gray-900"
: "border-gray-200 text-gray-600 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-gray-900"
}`}
>
<TabIcon className="h-3.5 w-3.5 shrink-0" />
{tabItem.title}
</button>
);
})}
</div>
<div role="tabpanel" className="anim-fade mx-auto mt-6 max-w-5xl" key={tab.shot}>
{/* Centered feature text above the shot — the tab chip already names it. */}
<p className="mx-auto mb-5 max-w-2xl text-center text-sm leading-6 text-gray-600 dark:text-gray-400">
{item.desc}
</p>
<BrowserFrame>
<ThemedShot set={SHOTS[tab.shot]} alt={item.title} />
</BrowserFrame>
</div>
{/* Features without a capture: the classic card grid, closed by "and more…". */}
<div className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{PLAIN_INDEXES.map((index) => {
const plain = S.features.items[index]!;
const IconCmp = ICONS[index] ?? SparklesIcon;
return (
<article
key={item.title}
key={plain.title}
className="relative overflow-hidden rounded-xl border border-gray-200 bg-white p-5 transition-colors hover:border-gray-300 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700"
>
{/* Oversized faint icon as the card backdrop (decorative). */}
@@ -51,13 +167,16 @@ export function Features() {
strokeWidth={1.25}
className="pointer-events-none absolute -right-5 -bottom-5 h-26 w-26 text-gray-100 dark:text-gray-800"
/>
<h3 className="relative text-[15px] font-semibold tracking-tight">{item.title}</h3>
<h3 className="relative text-[15px] font-semibold tracking-tight">{plain.title}</h3>
<p className="relative mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
{item.desc}
{plain.desc}
</p>
</article>
);
})}
<article className="flex items-center justify-center rounded-xl border border-dashed border-gray-300 p-5 text-sm text-gray-400 dark:border-gray-700 dark:text-gray-500">
{S.features.more}
</article>
</div>
</Section>
);
+107 -21
View File
@@ -6,8 +6,14 @@
* real axes and ticks, each line growing from zero rightwards on a slow loop.
* Illustrative; all colors are theme-aware Tailwind fill/stroke classes.
*/
import { useEffect, useRef } from "react";
import { S } from "../lib/strings";
import { Section } from "../components/section";
import { DemoVideo } from "../components/demo-video";
import { demoVideoUrl } from "../lib/links";
import { useLocale } from "../state/locale";
import evoPosterZh from "../assets/evo-poster-zh.webp";
import evoPosterEn from "../assets/evo-poster-en.webp";
/** Rounded node with a centered label and an optional version pill. */
function Node({
@@ -146,9 +152,17 @@ const EDGE_BENCH_OLD = "M295,208 L163,294";
const EDGE_BENCH_NEW = "M425,208 L557,294";
const EDGE_EVOLVE = "M216,324 L504,324";
/** Draw-loop timing, mirroring the old CSS keyframes: grow, hold, fade, repeat. */
const CYCLE_S = 4.6;
const DRAW_END = 0.55;
const HOLD_END = 0.84;
const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t));
/**
* Outcome trend chart with proper axes and ticks; the line grows from zero
* rightwards (pathLength-normalized draw animation looping in styles.css).
* Outcome trend chart with proper axes and ticks; a single rAF clock grows the
* line from zero rightwards and carries a value label on the moving head — the
* number changes as the curve climbs (or falls), so the trend reads at a glance.
* Reduced-motion users see the finished line with its final value.
*/
function TrendChart({
label,
@@ -156,28 +170,75 @@ function TrendChart({
points,
yTicks,
xTicks,
delay,
phase,
format,
}: {
label: string;
hint: string;
points: number[];
yTicks: number[];
xTicks: Array<{ at: number; text: string }>;
delay: string;
/** Seconds this chart runs ahead of the shared cycle (staggers the trio). */
phase: number;
format: (v: number) => string;
}) {
const W = 210;
const H = 112;
const M = { left: 34, right: 10, top: 10, bottom: 20 };
const M = { left: 34, right: 10, top: 16, bottom: 20 };
const yMax = Math.max(...yTicks);
const yMin = Math.min(...yTicks);
const px = (i: number) => M.left + (i / (points.length - 1)) * (W - M.left - M.right);
const py = (v: number) => M.top + ((yMax - v) / (yMax - yMin || 1)) * (H - M.top - M.bottom);
const path = points.map((v, i) => `${i === 0 ? "M" : "L"}${px(i)},${py(v)}`).join(" ");
const lastX = px(points.length - 1);
const lastY = py(points[points.length - 1] ?? 0);
const axis = "stroke-gray-200 dark:stroke-gray-800";
const tickText = "fill-gray-400 text-[8.5px] tabular-nums dark:fill-gray-500";
const fadeRef = useRef<SVGGElement>(null);
const pathRef = useRef<SVGPathElement>(null);
const headRef = useRef<SVGGElement>(null);
const labelRef = useRef<SVGTextElement>(null);
useEffect(() => {
const fade = fadeRef.current;
const line = pathRef.current;
const head = headRef.current;
const value = labelRef.current;
if (!fade || !line || !head || !value) return;
const apply = (p: number, opacity: number) => {
line.style.strokeDashoffset = String(1 - p);
const f = p * (points.length - 1);
const i0 = Math.floor(f);
const i1 = Math.min(points.length - 1, i0 + 1);
const v = (points[i0] ?? 0) + ((points[i1] ?? 0) - (points[i0] ?? 0)) * (f - i0);
const x = px(f);
const y = py(v);
head.setAttribute("transform", `translate(${x}, ${y})`);
value.textContent = format(v);
value.setAttribute("x", String(Math.min(W - M.right - 10, Math.max(M.left + 12, x))));
value.setAttribute("y", String(Math.max(10, y - 9)));
fade.style.opacity = String(opacity);
};
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
apply(1, 1);
return;
}
let raf = 0;
const t0 = performance.now();
const tick = (now: number) => {
const t = (((now - t0) / 1000 + phase) % CYCLE_S) / CYCLE_S;
if (t < DRAW_END) apply(easeInOut(t / DRAW_END), 1);
else if (t < HOLD_END) apply(1, 1);
else apply(1, 1 - (t - HOLD_END) / (1 - HOLD_END));
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
// px/py are pure functions of the props below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [points, phase, format]);
return (
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 dark:border-gray-800 dark:bg-gray-900">
<div className="flex items-baseline justify-between">
@@ -232,19 +293,28 @@ function TrendChart({
</text>
</g>
))}
<path
d={path}
pathLength={1}
fill="none"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="anim-line-draw stroke-brand-600 dark:stroke-brand-400"
style={{ animationDelay: delay }}
/>
<g className="anim-line-dot" style={{ animationDelay: delay }}>
<circle cx={lastX} cy={lastY} r={5} className="fill-white dark:fill-gray-900" />
<circle cx={lastX} cy={lastY} r={3.5} className="fill-brand-600 dark:fill-brand-400" />
<g ref={fadeRef}>
<path
ref={pathRef}
d={path}
pathLength={1}
fill="none"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="stroke-brand-600 dark:stroke-brand-400"
style={{ strokeDasharray: 1, strokeDashoffset: 1 }}
/>
<g ref={headRef}>
<circle r={5} className="fill-white dark:fill-gray-900" />
<circle r={3.5} className="fill-brand-600 dark:fill-brand-400" />
</g>
{/* The moving value label riding the curve head. */}
<text
ref={labelRef}
textAnchor="middle"
className="fill-brand-700 text-[10px] font-semibold tabular-nums dark:fill-brand-300"
/>
</g>
</svg>
</div>
@@ -255,6 +325,7 @@ const TRENDS: Array<{
points: number[];
yTicks: number[];
xTicks: Array<{ at: number; text: string }>;
format: (v: number) => string;
}> = [
{
points: [52, 58, 56, 64, 70, 75, 79],
@@ -264,6 +335,7 @@ const TRENDS: Array<{
{ at: 3, text: "v4" },
{ at: 6, text: "v7" },
],
format: (v) => `${v.toFixed(1)}%`,
},
{
points: [0.42, 0.38, 0.39, 0.33, 0.3, 0.27, 0.25],
@@ -273,6 +345,7 @@ const TRENDS: Array<{
{ at: 3, text: "v4" },
{ at: 6, text: "v7" },
],
format: (v) => `$${v.toFixed(2)}`,
},
{
points: [115, 107, 109, 98, 92, 87, 83],
@@ -282,10 +355,12 @@ const TRENDS: Array<{
{ at: 3, text: "v4" },
{ at: 6, text: "v7" },
],
format: (v) => `${v.toFixed(1)}s`,
},
];
export function SelfImprove() {
const { locale } = useLocale();
return (
<Section
id="self-improvement"
@@ -416,11 +491,22 @@ export function SelfImprove() {
points={TRENDS[i]?.points ?? []}
yTicks={TRENDS[i]?.yTicks ?? [0, 1]}
xTicks={TRENDS[i]?.xTicks ?? []}
delay={`${-i * 1.4}s`}
phase={i * 1.4}
format={TRENDS[i]?.format ?? ((v) => v.toFixed(0))}
/>
))}
</div>
</div>
{/* The loop above is a diagram; this is the same loop actually running. Replaces the
"demo video coming soon" pill that stood here while the recording was in progress. */}
<div className="mt-10">
<DemoVideo
src={demoVideoUrl(locale === "zh" ? "evo_zh" : "evo_en")}
poster={locale === "zh" ? evoPosterZh : evoPosterEn}
label={S.selfImprove.videoLabel}
caption={S.selfImprove.videoCaption}
/>
</div>
</Section>
);
}
-112
View File
@@ -1,112 +0,0 @@
/**
* Use cases: daily tasks + zero-code AI development, illustrated with real
* screenshots captured from the Web App with Playwright. Twelve variants — three
* views (chat building an Agent app / trace view / evaluation center) x two UI
* languages x two themes (WebP) — and the one shown always matches the visitor's
* active locale and theme. Each figure carries a scenario tag.
*/
import { S } from "../lib/strings";
import { useLocale } from "../state/locale";
import type { Locale } from "../state/locale";
import { Section } from "../components/section";
import { BrowserFrame } from "../components/browser-frame";
import chatZhLight from "../assets/shots/chat-zh-light.webp";
import chatZhDark from "../assets/shots/chat-zh-dark.webp";
import chatEnLight from "../assets/shots/chat-en-light.webp";
import chatEnDark from "../assets/shots/chat-en-dark.webp";
import tracesZhLight from "../assets/shots/traces-zh-light.webp";
import tracesZhDark from "../assets/shots/traces-zh-dark.webp";
import tracesEnLight from "../assets/shots/traces-en-light.webp";
import tracesEnDark from "../assets/shots/traces-en-dark.webp";
import benchmarkZhLight from "../assets/shots/benchmark-zh-light.webp";
import benchmarkZhDark from "../assets/shots/benchmark-zh-dark.webp";
import benchmarkEnLight from "../assets/shots/benchmark-en-light.webp";
import benchmarkEnDark from "../assets/shots/benchmark-en-dark.webp";
type ShotSet = Record<Locale, { light: string; dark: string }>;
const SHOTS: Record<"chat" | "traces" | "benchmark", ShotSet> = {
chat: {
zh: { light: chatZhLight, dark: chatZhDark },
en: { light: chatEnLight, dark: chatEnDark },
},
traces: {
zh: { light: tracesZhLight, dark: tracesZhDark },
en: { light: tracesEnLight, dark: tracesEnDark },
},
benchmark: {
zh: { light: benchmarkZhLight, dark: benchmarkZhDark },
en: { light: benchmarkEnLight, dark: benchmarkEnDark },
},
};
function ThemedShot({ set, alt }: { set: ShotSet; alt: string }) {
const { locale } = useLocale();
const pair = set[locale];
// Explicit intrinsic size (all shots are 1920x1200): reserves layout before the
// image loads, so in-page anchor jumps don't drift when the showcase pops in.
return (
<>
<img
src={pair.light}
alt={alt}
width={1920}
height={1200}
loading="lazy"
className="block h-auto w-full dark:hidden"
/>
<img
src={pair.dark}
alt={alt}
width={1920}
height={1200}
loading="lazy"
className="hidden h-auto w-full dark:block"
/>
</>
);
}
function Caption({ tag, text }: { tag: string; text: string }) {
return (
<figcaption className="mt-3 flex flex-wrap items-center justify-center gap-2 text-center text-xs text-gray-500 dark:text-gray-400">
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-[11px] font-medium text-brand-700 dark:border-brand-800 dark:bg-brand-950 dark:text-brand-300">
{tag}
</span>
{text}
</figcaption>
);
}
export function Showcase() {
return (
<Section
id="showcase"
eyebrow={S.showcase.eyebrow}
title={S.showcase.title}
subtitle={S.showcase.subtitle}
>
<figure className="mx-auto max-w-5xl">
<BrowserFrame>
<ThemedShot set={SHOTS.chat} alt={S.showcase.captionChat} />
</BrowserFrame>
<Caption tag={S.showcase.tagChat} text={S.showcase.captionChat} />
</figure>
<div className="mx-auto mt-10 grid max-w-5xl gap-8 sm:grid-cols-2">
<figure>
<BrowserFrame>
<ThemedShot set={SHOTS.traces} alt={S.showcase.captionTraces} />
</BrowserFrame>
<Caption tag={S.showcase.tagTraces} text={S.showcase.captionTraces} />
</figure>
<figure>
<BrowserFrame>
<ThemedShot set={SHOTS.benchmark} alt={S.showcase.captionBenchmark} />
</BrowserFrame>
<Caption tag={S.showcase.tagBenchmark} text={S.showcase.captionBenchmark} />
</figure>
</div>
</Section>
);
}
+25
View File
@@ -331,6 +331,31 @@
}
}
/* ---------- Building-blocks loop (LangChain comparison): each block pops in at its
negative delay within the shared cycle, holds while the rest of the tower lands,
then the whole skyline fades and rebuilds. Reduced-motion shows towers complete. */
.anim-block {
animation: block-build var(--cycle, 7.2s) linear infinite;
}
@keyframes block-build {
0% {
opacity: 0;
transform: translateY(5px) scale(0.92);
}
3% {
opacity: 1;
transform: none;
}
93% {
opacity: 1;
transform: none;
}
100% {
opacity: 0;
transform: none;
}
}
/* Printing never scrolls, so scroll-reveal must not hide content on paper/PDF. */
@media print {
.reveal {