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.
No files matched your search
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: "Partnering with the AMD Developer Program: $50 in free Fireworks credits, wired into PenguinHarness"
|
||||
date: 2026-07-20
|
||||
category: news
|
||||
excerpt: We are delighted to partner with the AMD AI Developer Program to bring you free Fireworks coupon codes — apply for the $50 credits with this guide, then set them up in PenguinHarness in three steps.
|
||||
---
|
||||
|
||||
We are delighted to partner with the **AMD AI Developer Program** to bring you free Fireworks redemption codes: join the program, pass the review, and you receive a Coupon Code redeemable for **$50 in Fireworks AI Credits**. PenguinHarness ships a built-in Fireworks AI gateway group — OpenAI protocol, preset base URL, five preset models — so the credits are usable the moment they land.
|
||||
|
||||
> Page content, credit amounts, and expiration dates are subject to change. Please refer to the application page and approval email for the latest information.
|
||||
|
||||
## Step 1: Join the AMD AI Developer Program
|
||||
|
||||
Visit the official page: <https://developer.amd.com/ai-developer-program/>. In the **Join the AMD AI Developer Program** section:
|
||||
|
||||
- No AMD ADP account yet: click **Create Account** and enter your personal information;
|
||||
- Already have one: click **Log In** and follow the on-screen instructions.
|
||||
|
||||

|
||||
|
||||
## Step 2: Request Cloud Credits under Member Perks
|
||||
|
||||
After registering and signing in:
|
||||
|
||||
1. Click **Member Perks** in the top navigation bar;
|
||||
2. Find **Cloud Credit Options**;
|
||||
3. Click **Request Cloud Credits** at the bottom of the page.
|
||||
|
||||

|
||||
|
||||
## Step 3: Complete the application form
|
||||
|
||||
Fill in the requested personal information:
|
||||
|
||||
- Under **Product Needed**, select **Fireworks AI**;
|
||||
- In the **Profile** section, provide at least one public profile for account verification: LinkedIn, GitHub, a portfolio, a company or school profile, WeChat, WeCom, and similar all work.
|
||||
|
||||

|
||||
|
||||
Complete the other required fields marked with `*`, double-check your email, identity, product selection, and profile link, then submit.
|
||||
|
||||
## Step 4: Wait for the review
|
||||
|
||||
AMD verifies your account and application, usually within **2–3 business days** — actual time varies with application volume, information completeness, and holidays.
|
||||
|
||||
## Step 5: Receive and redeem the coupon code
|
||||
|
||||
Once approved, AMD emails a unique **Coupon Code** redeemable for **$50 in Fireworks AI Credits** to your application address. Keep it secure — do not share it publicly, forward it, or commit it to a repository.
|
||||
|
||||

|
||||
|
||||
Redeem it and create an API key:
|
||||
|
||||
1. Open <https://fireworks.ai/> and sign in;
|
||||
2. Click **Redeem Promo** and enter the Coupon Code from the email to redeem the $50 credits;
|
||||
3. Click **Create API Key** to generate your Fireworks API key.
|
||||
|
||||

|
||||
|
||||
## Set it up in PenguinHarness
|
||||
|
||||
With the API key in hand, three steps:
|
||||
|
||||
**1. Install and launch**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://penguin.ooo/install.sh | sh
|
||||
penguin web # opens http://127.0.0.1:7364 (first login: admin / penguin-2026)
|
||||
```
|
||||
|
||||
**2. Configure a Fireworks model**
|
||||
|
||||
Open the Models page and find the **Fireworks AI** group, then use its bulk key button to paste the API key you just created. The group presets five models — GLM 5.2, Kimi K2.7 Code, DeepSeek V4 Pro, MiniMax M3, and DeepSeek V4 Flash — with base URLs and pricing pre-filled; set any of them as the default. You can also hit the group's speed-test button to measure real TTFT and TPS before choosing.
|
||||
|
||||
**3. Start working**
|
||||
|
||||
Head back to Chat and hand the Agent its first task — e.g. "Analyze data.csv and summarize quarterly sales".
|
||||
|
||||
## References
|
||||
|
||||
- [AMD AI Developer Program](https://developer.amd.com/ai-developer-program/)
|
||||
- [Official AMD Cloud Credits application video tutorial](https://www.youtube.com/watch?v=masSW53JkTY)
|
||||
- Application steps and screenshots are adapted from WhatGhost's guides ([中文](https://github.com/WhatGhost/whatghost_Notebooks/blob/main/other/AMD_AI_Developer_Program_Credits_%E7%94%B3%E8%AF%B7%E6%8C%87%E5%8D%97.md) / [English](https://github.com/WhatGhost/whatghost_Notebooks/blob/main/other/AMD_AI_Developer_Program_Credits_Application_Guide_EN.md)) — thanks to the original author
|
||||
- [PenguinHarness model configuration docs](https://penguin.ooo/docs/models)
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: 携手 AMD 开发者计划:免费领取 $50 Fireworks 额度,直连 PenguinHarness
|
||||
date: 2026-07-20
|
||||
category: news
|
||||
excerpt: 我们很高兴与 AMD AI Developer Program 合作,为大家带来 Fireworks 的免费兑换码——按本文申请 $50 Credits,再在 PenguinHarness 里三步用起来。
|
||||
---
|
||||
|
||||
我们很高兴与 **AMD AI Developer Program**(AMD 开发者计划)合作,为大家带来 Fireworks 的免费兑换码:加入计划并通过审核,即可获得可兑换 **$50 Fireworks AI Credits** 的 Coupon Code。PenguinHarness 内置 Fireworks AI 网关分组——OpenAI 协议、预置 base URL 与五个模型,额度到手即刻可用。
|
||||
|
||||
> 页面内容、Credits 金额与有效期可能调整,请以申请时的页面及审批邮件为准。
|
||||
|
||||
## 第一步:加入 AMD AI Developer Program
|
||||
|
||||
访问官方入口:<https://developer.amd.com/ai-developer-program/>,在 **Join the AMD AI Developer Program** 区域:
|
||||
|
||||
- 没有 AMD ADP 账户:点击 **Create Account**,填写个人信息创建账户;
|
||||
- 已有账户:点击 **Log In**,按页面提示登录。
|
||||
|
||||

|
||||
|
||||
## 第二步:在 Member Perks 申请 Cloud Credits
|
||||
|
||||
注册并登录后:
|
||||
|
||||
1. 点击顶部导航栏中的 **Member Perks**;
|
||||
2. 找到 **Cloud Credit Options**;
|
||||
3. 点击底部的 **Request Cloud Credits**。
|
||||
|
||||

|
||||
|
||||
## 第三步:填写申请表
|
||||
|
||||
进入申请表后填写个人信息:
|
||||
|
||||
- **Product Needed** 处选择 **Fireworks AI**;
|
||||
- **Profile** 处提供至少一个公开资料用于账户验证:LinkedIn、GitHub、Portfolio、公司 / 学校主页、WeChat、WeCom 等均可。
|
||||
|
||||

|
||||
|
||||
填完页面中其他带 `*` 的必填项,检查邮箱、身份、产品选项与公开资料链接无误后提交。
|
||||
|
||||
## 第四步:等待审核
|
||||
|
||||
AMD 会验证账户与申请资料,通常需要 **2–3 个工作日**;实际时间可能因申请量、资料完整度或节假日而变化。
|
||||
|
||||
## 第五步:收到兑换码并兑换
|
||||
|
||||
审核通过后,AMD 会向申请邮箱发送包含唯一 **Coupon Code** 的邮件,可兑换 **$50 Fireworks AI Credits**。请妥善保存,不要公开、转发或提交到代码仓库。
|
||||
|
||||

|
||||
|
||||
兑换与创建 API key:
|
||||
|
||||
1. 打开 <https://fireworks.ai/> 并登录;
|
||||
2. 点击 **Redeem Promo**,输入邮件中的 Coupon Code,兑换 $50 Credits;
|
||||
3. 点击 **Create API Key**,生成 Fireworks API key。
|
||||
|
||||

|
||||
|
||||
## 在 PenguinHarness 中用起来
|
||||
|
||||
拿到 API key 后,三步接入:
|
||||
|
||||
**1. 安装并启动**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://penguin.ooo/install.sh | sh
|
||||
penguin web # 打开 http://127.0.0.1:7364(首次登录:admin / penguin-2026)
|
||||
```
|
||||
|
||||
**2. 配置 Fireworks 模型**
|
||||
|
||||
进入「模型仓库」页,找到 **Fireworks AI** 分组,点击「统一配置 key」粘贴刚创建的 API key。分组预置了五个模型——GLM 5.2、Kimi K2.7 Code、DeepSeek V4 Pro、MiniMax M3、DeepSeek V4 Flash——base URL 与价格已填好,任选一个设为默认即可;也可以点组头的「测速」,实测各模型的 TTFT 与 TPS 再决定。
|
||||
|
||||
**3. 开始使用**
|
||||
|
||||
回到对话页,把第一个任务交给 Agent——例如「分析 data.csv,输出各季度销售额汇总」。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [AMD AI Developer Program](https://developer.amd.com/ai-developer-program/)
|
||||
- [AMD 官方 Cloud Credits 申请视频演示](https://www.youtube.com/watch?v=masSW53JkTY)
|
||||
- 申请步骤与截图整理自 WhatGhost 的申请指南([中文](https://github.com/WhatGhost/whatghost_Notebooks/blob/main/other/AMD_AI_Developer_Program_Credits_%E7%94%B3%E8%AF%B7%E6%8C%87%E5%8D%97.md) / [English](https://github.com/WhatGhost/whatghost_Notebooks/blob/main/other/AMD_AI_Developer_Program_Credits_Application_Guide_EN.md)),感谢原作者
|
||||
- [PenguinHarness 模型配置文档](https://penguin.ooo/docs/models)
|
||||
@@ -2,70 +2,114 @@
|
||||
title: "Introducing PenguinHarness: agents that build agents"
|
||||
date: 2026-07-17
|
||||
category: news
|
||||
excerpt: The first open-source harness with recursive self-improvement is here — lightweight, efficient and secure infrastructure covering everything from automatic agent construction to continuous self-evolution.
|
||||
excerpt: We proved agents can self-evolve in our GDPevo Benchmark — now we are bringing that capability to everyone. The first open-source harness with recursive self-improvement covers everything from one-sentence agent construction to continuous self-evolution.
|
||||
---
|
||||
|
||||
Today we are releasing **PenguinHarness** — an open-source harness built for constructing and evolving agents. Its purpose fits in one line:
|
||||
Today we are releasing **PenguinHarness** — an open-source harness built for constructing and evolving agents: a zero-code Harness CLI and Web UI, connected to 1000+ models. The story it tells fits in one line:
|
||||
|
||||
> Efficient Self-Improving Harness for Everyone.
|
||||
> With LangChain, you build agents by hand — at 1× speed. With PenguinHarness, agents build agents — at 100×.
|
||||
|
||||
## From GDPevo to PenguinHarness: why we built this
|
||||
|
||||
Before PenguinHarness, our team published the [GDPevo Benchmark](https://prism-shadow.github.io/GDPevo/). In GDPevo we systematically verified one thing: **agents can self-evolve** — an Agent can score its own performance, find where the points were lost, rewrite its own prompts and Skills, and climb version after version.
|
||||
|
||||
With the capability proven, the question became: how does everyone get to use it? Self-evolution should not stay a curve in a paper — it should be infrastructure that works out of the box on every developer's desk. **Bringing an efficient self-improving harness to everyone is why we built PenguinHarness** — and it is right there in the name: Efficient Self-Improving Harness for Everyone.
|
||||
|
||||
## Why PenguinHarness
|
||||
|
||||
Over the past year the way agent applications are built has been converging fast: what really decides quality is not a heavyweight framework but a simple, reliable, observable harness. PenguinHarness is rebuilt from the ground up — no dependency on any agent framework, a fully open-source self-developed kernel — and it brings three things to the open-source world first:
|
||||
Three reasons, in deliberate order — from task quality, to how agents get built, to how they keep improving.
|
||||
|
||||
- **Simplest Is the Best**: a deliberately minimal toolset over clean low-level interfaces — fewer tool calls, fewer Tokens, complex tasks done efficiently.
|
||||
- **Harness for Building Agents**: with the PenguinHarness SDK, an Agent builds complete Agent applications for you, autonomously, from scratch.
|
||||
- **Harness for Recursive Self-Improvement**: with PenguinHarness Skills, an Agent evaluates and optimizes itself, improving recursively over time.
|
||||
### 1. Better on complex tasks, at lower cost
|
||||
|
||||
For the latter two, PenguinHarness is the first open-source implementation in the industry.
|
||||
A deliberately minimal toolset over clean low-level interfaces: fewer tool calls, fewer Tokens, deeply tuned for open models like DeepSeek. Each harness runs the model it is normally paired with — the comparison is between the products as people actually use them — head-to-head on two suites:
|
||||
|
||||
## Same model, equal or better quality, lower cost
|
||||

|
||||
|
||||
All runs use the same DeepSeek V4 Pro model, head-to-head against Claude Code and OpenAI Codex on two suites (per-run means below).
|
||||
Complex data analysis (15 tasks, single run; PenguinHarness and Codex at thinking xhigh, Claude Code at max):
|
||||
|
||||
Complex data analysis (15 tasks, single run):
|
||||
| Framework | Model | Accuracy (%) | Tokens (M) | Cost ($) |
|
||||
| -------------- | --------------- | -----------: | ---------: | -------: |
|
||||
| PenguinHarness | DeepSeek V4 Pro | 66.67 | 18.04 | 0.55 |
|
||||
| Claude Code | Claude Opus 4.8 | 53.33 | 22.20 | 38.48 |
|
||||
| OpenAI Codex | GPT-5.5 | 53.33 | 13.72 | 19.41 |
|
||||
|
||||
| Framework | Model | Accuracy (%) | Tokens (M) | Cost ($) |
|
||||
| --- | --- | ---: | ---: | ---: |
|
||||
| PenguinHarness | DeepSeek V4 Pro | 66.7 | 18.04 | 0.552 |
|
||||
| Claude Code | DeepSeek V4 Pro | 66.7 | 21.17 | 0.641 |
|
||||
| OpenAI Codex | DeepSeek V4 Pro | 46.7 | 13.36 | 0.427 |
|
||||
Coding tasks (40 tasks × 2 runs; accuracy is over all 80 outcomes):
|
||||
|
||||
Coding tasks (40 tasks × 2 runs averaged, thinking high, 30 min per-case timeout, CNY pricing converted at $1 = ¥7):
|
||||
| Framework | Model | Accuracy (%) | Tokens (M) | Cost ($) |
|
||||
| -------------- | --------------- | -----------: | ---------: | -------: |
|
||||
| PenguinHarness | DeepSeek V4 Pro | 71.25 | 200.00 | 3.81 |
|
||||
| Claude Code | Claude Opus 4.8 | 86.25 | 151.61 | 146.97 |
|
||||
| OpenAI Codex | GPT-5.5 | 71.25 | 251.20 | 220.08 |
|
||||
|
||||
| Framework | Model | Accuracy (%) | Tokens (M) | Cost ($) |
|
||||
| --- | --- | ---: | ---: | ---: |
|
||||
| PenguinHarness | DeepSeek V4 Pro | 50.00 | 2.10 | 0.041 |
|
||||
| Claude Code | DeepSeek V4 Pro | 48.75 | 2.00 | 0.048 |
|
||||
| OpenAI Codex | DeepSeek V4 Pro | 42.50 | 2.65 | 0.043 |
|
||||
Tokens and cost are suite totals, not per-run means. On data analysis we take the highest accuracy of the three — 66.67% against 53.33% for both — while spending 1/35 of what Codex spent and 1/70 of Claude Code's. On coding we tie Codex at 71.25% and trail Claude Code's 86.25%, but the whole suite cost us $3.81 against their $220.08 and $146.97: comparable work, one to two orders of magnitude apart on the bill.
|
||||
|
||||
On the data-analysis suite PenguinHarness ties Claude Code on accuracy and clearly beats OpenAI Codex while using 14.8% fewer Tokens at 13.8% lower cost; on the coding suite it scores highest of the three at the lowest per-run cost.
|
||||
### 2. One sentence, and an Agent builds your Agent app
|
||||
|
||||
Type one sentence, and an Agent builds the complete Agent application for you — scaffold, code, and run instructions, end to end:
|
||||
|
||||
```text
|
||||
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.
|
||||
```
|
||||
|
||||
And this is the finished product — a docs expert with retrieval, cited sources that link to the original files, and example questions built in:
|
||||
|
||||

|
||||
|
||||
**And generating this entire RAG app burned just $0.02 (¥0.2) of tokens — on DeepSeek V4 Pro.**
|
||||
|
||||
### 3. Self-evolution: it gets stronger with use
|
||||
|
||||
With PenguinHarness Skills, an Agent evaluates and optimizes itself: the Optimizer orchestrates multiple Evaluators to score 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, and every request replayable in the Trace view. A self-evolution demo video is coming soon.
|
||||
|
||||
## Evolution within bounds, security first
|
||||
|
||||
The biggest worry about self-improvement is losing control. PenguinHarness answers with a contract — CONTRACT.md:
|
||||
The biggest worry about self-evolution is losing control. PenguinHarness answers with a contract (CONTRACT.md):
|
||||
|
||||
- Evolution is strictly confined to Workspace and Skills; the harness core security boundary is never modified.
|
||||
- Tool calls run only after approval, and every approval is audited.
|
||||
- Risky changes snapshot first — every step of evolution can be rolled back.
|
||||
- Fully open source and locally deployable: data never leaves your machine, meeting enterprise security requirements.
|
||||
- Evolution is strictly confined to Workspace and Skills — the harness core security boundary is never modified;
|
||||
- Tool calls require approval first, and every approval leaves an audit record;
|
||||
- Risky changes are preceded by version snapshots, so any round of evolution can be rolled back;
|
||||
- Fully open source and locally deployed — data never leaves your machine, meeting enterprise data-security requirements.
|
||||
|
||||
## Get started now
|
||||
## Supported models
|
||||
|
||||
Install with one command (Linux / macOS, x64 / arm64, bundled Node runtime):
|
||||
| Model | Providers |
|
||||
| ---------------- | -------------------------------------------------------------------------------- |
|
||||
| DeepSeek V4 | DeepSeek, OpenRouter, Fireworks AI, SiliconFlow, Qwen Token Plan |
|
||||
| Kimi K3 | Moonshot AI, OpenRouter, Qwen Pay-As-You-Go |
|
||||
| GLM 5.2 | Z.AI, OpenRouter, Fireworks AI, SiliconFlow, Qwen Token Plan, Qwen Pay-As-You-Go |
|
||||
| Hunyuan 3 | OpenRouter |
|
||||
| Qwen 3.8 Max | Qwen Token Plan (preview) |
|
||||
| GPT 5.5 | OpenAI, OpenRouter |
|
||||
| Gemini 3.5 Flash | Google Gemini, OpenRouter |
|
||||
| Claude Opus 4.8 | Anthropic, OpenRouter |
|
||||
|
||||
Any OpenAI-protocol endpoint is supported: pick a preset above, or point a custom endpoint at any of the 1000+ online and local models.
|
||||
|
||||
## How to use it
|
||||
|
||||
Install with one command (Linux / macOS, x64 / arm64, bundled Node runtime), then launch the Web UI:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://github.com/Prism-Shadow/penguin-harness/releases/latest/download/install.sh | sh
|
||||
curl -fsSL https://penguin.ooo/install.sh | sh
|
||||
penguin web # opens http://127.0.0.1:7364 (first login: admin / penguin-2026)
|
||||
```
|
||||
|
||||
Configure a model (DeepSeek as an example), run your first task, or open the desktop-grade web interface with `penguin web`:
|
||||
Open the Models page, paste an API key under the DeepSeek or OpenRouter group and set it as default; then head back to Chat and hand the Agent its first task — e.g. "Analyze data.csv and summarize quarterly sales".
|
||||
|
||||
```bash
|
||||
penguin config model add --model-id deepseek-v4-pro --api-key sk-your-key --set-default
|
||||
penguin run --approve allow-all --message "Analyze data.csv and summarize quarterly sales"
|
||||
penguin web
|
||||
```
|
||||
## What's next
|
||||
|
||||
PenguinHarness supports 1000+ online and local models and multi-agent collaborative evolution, and runs on as little as a single CPU. Through continuous evolution it makes complex AI development ever simpler — a more efficient, more reliable, lower-hallucination and lower-cost Agent productivity engine.
|
||||
- Public release of the benchmark suite;
|
||||
- A desktop app;
|
||||
- Windows support;
|
||||
- More to come.
|
||||
|
||||
Follow us on [GitHub](https://github.com/Prism-Shadow/penguin-harness) and open your first issue.
|
||||
## Join the community and build with us
|
||||
|
||||
A self-improving harness needs a community that improves with it. Come discuss, request, and contribute — your first Issue is the best way to start:
|
||||
|
||||
- [Discord](https://discord.gg/eFHKqqcU3D): chat with us and other developers in real time;
|
||||
- [X (Twitter)](https://x.com/code_hiyouga): follow the latest updates;
|
||||
- [WeChat group](https://github.com/Prism-Shadow/penguin-harness-community/blob/main/wechat/group.jpg): Chinese community discussions;
|
||||
- [GitHub](https://github.com/Prism-Shadow/penguin-harness): stars, Issues, and PRs all welcome.
|
||||
|
||||
Self-evolving agent infrastructure, for everyone — starting today.
|
||||
@@ -2,44 +2,64 @@
|
||||
title: PenguinHarness 正式发布:让 Agent 为你构建 Agent
|
||||
date: 2026-07-17
|
||||
category: news
|
||||
excerpt: 首个支持递归自我进化的开源 Harness 正式发布——以轻量、高效、安全的方式,提供从 Agent 自动构建到持续自我进化的完整基础设施。
|
||||
excerpt: 我们在 GDPevo Benchmark 中验证了 Agent 自我进化的能力,现在把它带给所有人——首个支持递归自我进化的开源 Harness 正式发布,从一句话构建 Agent 到持续自我进化,一套基础设施全部覆盖。
|
||||
---
|
||||
|
||||
今天,我们正式发布 **PenguinHarness**——一个为构建与进化 Agent 而生的开源 Harness。它的主旨只有一句话:
|
||||
今天,我们正式发布 **PenguinHarness**——一个为构建与进化 Agent 而生的开源 Harness:零代码的 Harness CLI 与 Web UI,连接 1000+ 模型。它要讲的故事只有一句话:
|
||||
|
||||
> Efficient Self-Improving Harness for Everyone.
|
||||
> 使用 LangChain,以 1 倍速度人工构建 Agent;使用 PenguinHarness,以 100 倍速度用 Agent 构建 Agent。
|
||||
|
||||
## 从 GDPevo 到 PenguinHarness:我们的初心
|
||||
|
||||
在 PenguinHarness 之前,我们团队发布了 [GDPevo Benchmark](https://prism-shadow.github.io/GDPevo/)。在 GDPevo 中,我们系统性地验证了一件事:**Agent 可以自我进化**——让 Agent 评估自己的表现、定位失分原因、改写自己的提示词与技能,分数随版本一路上升。
|
||||
|
||||
能力验证了,问题就变成了:怎么让每个人都用上它?自我进化不该只是论文里的曲线,而应该是每个开发者桌面上开箱即用的基础设施。**让每个人都能使用 Efficient Self-Improving Harness,这就是我们构建 PenguinHarness 的初心**——它也因此得名:Efficient Self-Improving Harness for Everyone.
|
||||
|
||||
## 为什么是 PenguinHarness
|
||||
|
||||
过去一年里,Agent 应用的开发范式在快速收敛:真正决定效果的不是庞大的框架,而是一个简洁、可靠、可观测的 Harness。PenguinHarness 从底层重构,不依赖任何 Agent 框架,开源自研 Harness 内核,并率先把三件事带入开源世界:
|
||||
三个递进的理由——从任务效果,到构建方式,再到进化能力。
|
||||
|
||||
- **Simplest Is the Best**:坚持最小化工具集与简洁的底层接口,以更少的工具调用与 Token 消耗,高效完成复杂任务。
|
||||
- **Harness for Building Agents**:通过 PenguinHarness SDK,让 Agent 从零自主完成 Agent 应用的构建。
|
||||
- **Harness for Recursive Self-Improvement**:通过 PenguinHarness Skills,Agent 以自我评估与自我优化实现递归式自我提升。
|
||||
### 1. 复杂任务表现更好,成本更低
|
||||
|
||||
后两项能力,PenguinHarness 是业内首个开源实现。
|
||||
刻意精简的工具集配合干净的底层接口:更少的工具调用、更少的 Token,对 DeepSeek 等开放模型深度适配。每个产品搭配它常用的模型——比的是大家实际会怎么用——在两套题库上正面对比:
|
||||
|
||||
## 同一模型,同级效果,更低消耗
|
||||

|
||||
|
||||
全部使用同一 DeepSeek V4 Pro 模型,与 Claude Code、OpenAI Codex 在两套题库上正面对比(表中为单次运行均值)。
|
||||
复杂数据分析(15 题,单次运行;PenguinHarness 与 Codex 为 thinking xhigh,Claude Code 为 max):
|
||||
|
||||
复杂数据分析(15 题,单次运行):
|
||||
| 实验框架 | 模型名称 | 准确率(%) | Token 用量(M) | 成本($) |
|
||||
| -------------- | --------------- | ----------: | --------------: | --------: |
|
||||
| PenguinHarness | DeepSeek V4 Pro | 66.67 | 18.04 | 0.55 |
|
||||
| Claude Code | Claude Opus 4.8 | 53.33 | 22.20 | 38.48 |
|
||||
| OpenAI Codex | GPT-5.5 | 53.33 | 13.72 | 19.41 |
|
||||
|
||||
| 实验框架 | 模型名称 | 准确率(%) | Token 用量(M) | 成本($) |
|
||||
| --- | --- | ---: | ---: | ---: |
|
||||
| PenguinHarness | DeepSeek V4 Pro | 66.7 | 18.04 | 0.552 |
|
||||
| Claude Code | DeepSeek V4 Pro | 66.7 | 21.17 | 0.641 |
|
||||
| OpenAI Codex | DeepSeek V4 Pro | 46.7 | 13.36 | 0.427 |
|
||||
代码任务(40 题 × 2 次运行,准确率取全部 80 次结果):
|
||||
|
||||
代码任务(40 题 × 2 runs 取均值,thinking high、单题 30 分钟超时,人民币计价按 $1 = ¥7 折算):
|
||||
| 实验框架 | 模型名称 | 准确率(%) | Token 用量(M) | 成本($) |
|
||||
| -------------- | --------------- | ----------: | --------------: | --------: |
|
||||
| PenguinHarness | DeepSeek V4 Pro | 71.25 | 200.00 | 3.81 |
|
||||
| Claude Code | Claude Opus 4.8 | 86.25 | 151.61 | 146.97 |
|
||||
| OpenAI Codex | GPT-5.5 | 71.25 | 251.20 | 220.08 |
|
||||
|
||||
| 实验框架 | 模型名称 | 准确率(%) | Token 用量(M) | 成本($) |
|
||||
| --- | --- | ---: | ---: | ---: |
|
||||
| PenguinHarness | DeepSeek V4 Pro | 50.00 | 2.10 | 0.041 |
|
||||
| Claude Code | DeepSeek V4 Pro | 48.75 | 2.00 | 0.048 |
|
||||
| OpenAI Codex | DeepSeek V4 Pro | 42.50 | 2.65 | 0.043 |
|
||||
Token 与成本均为全套题目合计,不是单次均值。数据分析套件我们准确率最高——66.67% 对另两者的 53.33%——花的钱是 Codex 的 1/35、Claude Code 的 1/70;代码套件与 Codex 同为 71.25%、低于 Claude Code 的 86.25%,但整套题目只花了 $3.81,对方分别是 $220.08 与 $146.97:活干得差不多,账单差出一到两个数量级。
|
||||
|
||||
数据分析套件与 Claude Code 准确率持平、显著超过 OpenAI Codex,同时 Token 消耗少 14.8%、成本低 13.8%;代码套件三者中准确率最高、单次成本最低。
|
||||
### 2. 一句话,让 Agent 构建 Agent 应用
|
||||
|
||||
输入一句话,Agent 为你构建完整的 Agent 应用——脚手架、代码、运行说明,一步到位:
|
||||
|
||||
```text
|
||||
收集 https://github.com/ericbuess/claude-code-docs 的文档,做一个化身 Claude Code 配置专家、回答带来源引用的 RAG 问答应用。
|
||||
```
|
||||
|
||||
这是做出来的成品——一个文档专家:检索增强、引用可点击直达原文、内置示例问题:
|
||||
|
||||

|
||||
|
||||
**而生成整个 RAG 应用,仅消耗了 0.2 元($0.02)的 token——使用 DeepSeek V4 Pro 模型。**
|
||||
|
||||
### 3. 自进化,越用越强
|
||||
|
||||
借助 PenguinHarness 技能库,Agent 自己评估、自己优化:Optimizer 组织多个 Evaluator 并行打分,依据分数与运行轨迹定位失分原因,把 Agent 从版本 N 优化到版本 N+1——每轮之前自动快照,每个请求都可在轨迹观测中回放。自进化演示视频即将上线。
|
||||
|
||||
## 进化有界,安全先行
|
||||
|
||||
@@ -50,22 +70,46 @@ excerpt: 首个支持递归自我进化的开源 Harness 正式发布——以
|
||||
- 风险修改之前先留版本快照,任何一次进化都可回退;
|
||||
- 完全开源、本地部署,数据不出域,满足企业级数据安全。
|
||||
|
||||
## 现在就可以开始
|
||||
## 支持的模型
|
||||
|
||||
一行命令安装(Linux / macOS,x64 / arm64,内嵌 Node 运行时):
|
||||
| 模型 | 可用供应商 |
|
||||
| ---------------- | -------------------------------------------------------------------------------- |
|
||||
| DeepSeek V4 | DeepSeek, OpenRouter, Fireworks AI, SiliconFlow, Qwen Token Plan |
|
||||
| Kimi K3 | Moonshot AI, OpenRouter, Qwen Pay-As-You-Go |
|
||||
| GLM 5.2 | Z.AI, OpenRouter, Fireworks AI, SiliconFlow, Qwen Token Plan, Qwen Pay-As-You-Go |
|
||||
| Hunyuan 3 | OpenRouter |
|
||||
| Qwen 3.8 Max | Qwen Token Plan(预览) |
|
||||
| GPT 5.5 | OpenAI, OpenRouter |
|
||||
| Gemini 3.5 Flash | Google Gemini, OpenRouter |
|
||||
| Claude Opus 4.8 | Anthropic, OpenRouter |
|
||||
|
||||
只要是 OpenAI 协议的端点都可以接入:从上表选择预置,或用自定义端点连接 1000+ 在线与本地模型。
|
||||
|
||||
## 如何使用
|
||||
|
||||
一行命令安装(Linux / macOS,x64 / arm64,内嵌 Node 运行时),然后启动 Web 界面:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://github.com/Prism-Shadow/penguin-harness/releases/latest/download/install.sh | sh
|
||||
curl -fsSL https://penguin.ooo/install.sh | sh
|
||||
penguin web # 打开 http://127.0.0.1:7364(首次登录:admin / penguin-2026)
|
||||
```
|
||||
|
||||
配置模型(以 DeepSeek 为例)后即可运行第一个任务,或用 `penguin web` 打开桌面级 Web 界面:
|
||||
进入「模型仓库」页,在 DeepSeek 或 OpenRouter 分组里粘贴 API key 并设为默认;回到对话页,把第一个任务交给 Agent——例如「分析 data.csv,输出各季度销售额汇总」。
|
||||
|
||||
```bash
|
||||
penguin config model add --model-id deepseek-v4-pro --api-key sk-your-key --set-default
|
||||
penguin run --approve allow-all --message "分析 data.csv,输出各季度销售额汇总"
|
||||
penguin web
|
||||
```
|
||||
## 发展计划
|
||||
|
||||
PenguinHarness 支持 1000 多种在线与本地模型、多智能体协作进化,最低单 CPU 即可运行。通过不断进化,它会让复杂的 AI 开发越来越简单——为你提供更高效、更可靠、更低幻觉、更低成本的 Agent 生产力引擎。
|
||||
- Benchmark 套件正式发布;
|
||||
- 推出桌面端(Desktop)应用;
|
||||
- 支持 Windows 系统;
|
||||
- 更多规划,敬请期待。
|
||||
|
||||
欢迎在 [GitHub](https://github.com/Prism-Shadow/penguin-harness) 上关注我们,提出你的第一个 Issue。
|
||||
## 加入社区,一起共建
|
||||
|
||||
自我进化的 Harness,也需要一个不断进化的社区。欢迎加入讨论、提出需求、贡献代码——你的第一个 Issue 就是最好的开始:
|
||||
|
||||
- [Discord](https://discord.gg/eFHKqqcU3D):与我们和其他开发者实时交流;
|
||||
- [X(Twitter)](https://x.com/code_hiyouga):关注最新动态;
|
||||
- [微信群](https://github.com/Prism-Shadow/penguin-harness-community/blob/main/wechat/group.jpg):中文社区讨论;
|
||||
- [GitHub](https://github.com/Prism-Shadow/penguin-harness):Star、Issue 与 PR 都欢迎。
|
||||
|
||||
让每个人都用上会自我进化的 Agent 基础设施——从今天开始。
|
||||
@@ -0,0 +1,48 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 920 368" width="920" height="368" role="img" aria-label="Benchmark: PenguinHarness vs Claude Code vs OpenAI Codex on two suites — comparable accuracy at a small fraction of the cost">
|
||||
<text x="148" y="36" font-size="12" font-weight="400" fill="#898781" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">Accuracy · suite total · higher is better</text>
|
||||
<text x="596" y="36" font-size="12" font-weight="400" fill="#898781" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">Total cost (USD) · lower is better</text>
|
||||
<text x="24" y="68" font-size="13" font-weight="600" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">Data analysis — 15 tasks, single run</text>
|
||||
<rect x="147" y="78" width="1" height="92" fill="#c3c2b7"/>
|
||||
<text x="136" y="94.5" font-size="12" font-weight="600" fill="#1f2328" text-anchor="end" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">PenguinHarness</text>
|
||||
<path d="M149 82 h172.0 a4 4 0 0 1 4 4 v8 a4 4 0 0 1 -4 4 h-172.0 z" fill="#2a78d6"/>
|
||||
<text x="337" y="94.5" font-size="12" font-weight="600" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">66.67%</text>
|
||||
<text x="136" y="124.5" font-size="12" font-weight="400" fill="#52514e" text-anchor="end" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">Claude Code</text>
|
||||
<path d="M149 112 h136.8 a4 4 0 0 1 4 4 v8 a4 4 0 0 1 -4 4 h-136.8 z" fill="#898781"/>
|
||||
<text x="301.7841607919604" y="124.5" font-size="12" font-weight="400" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">53.33%</text>
|
||||
<text x="136" y="154.5" font-size="12" font-weight="400" fill="#52514e" text-anchor="end" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">OpenAI Codex</text>
|
||||
<path d="M149 142 h136.8 a4 4 0 0 1 4 4 v8 a4 4 0 0 1 -4 4 h-136.8 z" fill="#898781"/>
|
||||
<text x="301.7841607919604" y="154.5" font-size="12" font-weight="400" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">53.33%</text>
|
||||
<rect x="595" y="78" width="1" height="92" fill="#c3c2b7"/>
|
||||
<text x="584" y="94.5" font-size="12" font-weight="600" fill="#1f2328" text-anchor="end" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">PenguinHarness</text>
|
||||
<path d="M597 82 h10.0 a4 4 0 0 1 4 4 v8 a4 4 0 0 1 -4 4 h-10.0 z" fill="#2a78d6"/>
|
||||
<text x="623" y="94.5" font-size="12" font-weight="600" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">$0.55</text>
|
||||
<text x="584" y="124.5" font-size="12" font-weight="400" fill="#52514e" text-anchor="end" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">Claude Code</text>
|
||||
<path d="M597 112 h172.0 a4 4 0 0 1 4 4 v8 a4 4 0 0 1 -4 4 h-172.0 z" fill="#898781"/>
|
||||
<text x="785" y="124.5" font-size="12" font-weight="400" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">$38.48</text>
|
||||
<text x="584" y="154.5" font-size="12" font-weight="400" fill="#52514e" text-anchor="end" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">OpenAI Codex</text>
|
||||
<path d="M597 142 h84.8 a4 4 0 0 1 4 4 v8 a4 4 0 0 1 -4 4 h-84.8 z" fill="#898781"/>
|
||||
<text x="697.7945961503353" y="154.5" font-size="12" font-weight="400" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">$19.41</text>
|
||||
<text x="24" y="210" font-size="13" font-weight="600" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">Coding — 40 tasks × 2 runs</text>
|
||||
<rect x="147" y="220" width="1" height="92" fill="#c3c2b7"/>
|
||||
<text x="136" y="236.5" font-size="12" font-weight="600" fill="#1f2328" text-anchor="end" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">PenguinHarness</text>
|
||||
<path d="M149 224 h141.4 a4 4 0 0 1 4 4 v8 a4 4 0 0 1 -4 4 h-141.4 z" fill="#2a78d6"/>
|
||||
<text x="306.3913043478261" y="236.5" font-size="12" font-weight="600" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">71.25%</text>
|
||||
<text x="136" y="266.5" font-size="12" font-weight="400" fill="#52514e" text-anchor="end" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">Claude Code</text>
|
||||
<path d="M149 254 h172.0 a4 4 0 0 1 4 4 v8 a4 4 0 0 1 -4 4 h-172.0 z" fill="#898781"/>
|
||||
<text x="337" y="266.5" font-size="12" font-weight="400" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">86.25%</text>
|
||||
<text x="136" y="296.5" font-size="12" font-weight="400" fill="#52514e" text-anchor="end" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">OpenAI Codex</text>
|
||||
<path d="M149 284 h141.4 a4 4 0 0 1 4 4 v8 a4 4 0 0 1 -4 4 h-141.4 z" fill="#898781"/>
|
||||
<text x="306.3913043478261" y="296.5" font-size="12" font-weight="400" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">71.25%</text>
|
||||
<rect x="595" y="220" width="1" height="92" fill="#c3c2b7"/>
|
||||
<text x="584" y="236.5" font-size="12" font-weight="600" fill="#1f2328" text-anchor="end" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">PenguinHarness</text>
|
||||
<path d="M597 224 h10.0 a4 4 0 0 1 4 4 v8 a4 4 0 0 1 -4 4 h-10.0 z" fill="#2a78d6"/>
|
||||
<text x="623" y="236.5" font-size="12" font-weight="600" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">$3.81</text>
|
||||
<text x="584" y="266.5" font-size="12" font-weight="400" fill="#52514e" text-anchor="end" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">Claude Code</text>
|
||||
<path d="M597 254 h113.5 a4 4 0 0 1 4 4 v8 a4 4 0 0 1 -4 4 h-113.5 z" fill="#898781"/>
|
||||
<text x="726.5332606324973" y="266.5" font-size="12" font-weight="400" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">$146.97</text>
|
||||
<text x="584" y="296.5" font-size="12" font-weight="400" fill="#52514e" text-anchor="end" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">OpenAI Codex</text>
|
||||
<path d="M597 284 h172.0 a4 4 0 0 1 4 4 v8 a4 4 0 0 1 -4 4 h-172.0 z" fill="#898781"/>
|
||||
<text x="785" y="296.5" font-size="12" font-weight="400" fill="#1f2328" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">$220.08</text>
|
||||
<text x="24" y="338" font-size="12" font-weight="400" fill="#898781" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">Each harness runs the model it is normally paired with: PenguinHarness on DeepSeek V4 Pro, Claude Code on Claude Opus 4.8,</text>
|
||||
<text x="24" y="356" font-size="12" font-weight="400" fill="#898781" text-anchor="start" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">OpenAI Codex on GPT-5.5. Accuracy, Tokens and cost are suite totals at official pricing.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
# https://penguin.ooo/install.sh - PenguinHarness installer entry point.
|
||||
#
|
||||
# GitHub Pages cannot serve HTTP redirects, so this thin forwarder IS the
|
||||
# stable install URL: it fetches the real installer attached to the latest
|
||||
# GitHub release and runs it, forwarding every argument it was given. Usage:
|
||||
#
|
||||
# curl -fsSL https://penguin.ooo/install.sh | sh
|
||||
# curl -fsSL https://penguin.ooo/install.sh | sh -s -- --universal
|
||||
#
|
||||
set -eu
|
||||
# Download to a file first, then run it: piping straight into `sh` would execute
|
||||
# a truncated download line by line, and the real installer removes the old
|
||||
# bin/lib/web/node before moving the new ones in — a cut connection mid-way
|
||||
# would leave no install at all.
|
||||
TMP="$(mktemp)"
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
curl -fsSL "https://github.com/Prism-Shadow/penguin-harness/releases/latest/download/install.sh" -o "$TMP"
|
||||
rc=0
|
||||
sh "$TMP" "$@" || rc=$?
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Render the landing Cases tab's "penguin sled game" finished-product shot.
|
||||
*
|
||||
* Same pattern as capture-readme-demo.mjs: penguin-game-mockup.html is a static,
|
||||
* dependency-free mockup of the example game's play screen, captured per language
|
||||
* (zh / en) and theme (light = polar day / dark = polar night) into
|
||||
* packages/landing/src/assets as game-<lang>-<theme>.webp.
|
||||
*
|
||||
* Prereqs: Playwright's chromium only (no server, no build). Run:
|
||||
* `node scripts/capture-game-mockup.mjs [--png-dir <dir>]`.
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR = path.resolve(HERE, "../src/assets");
|
||||
const PAGE = pathToFileURL(path.join(HERE, "penguin-game-mockup.html")).href;
|
||||
|
||||
const pngDirArg = process.argv.indexOf("--png-dir");
|
||||
const PNG_DIR = pngDirArg >= 0 ? process.argv[pngDirArg + 1] : null;
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
if (PNG_DIR) mkdirSync(PNG_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const encoderPage = await browser.newPage();
|
||||
async function saveWebp(pngBuffer, fileName) {
|
||||
const dataUrl = await encoderPage.evaluate(async (b64) => {
|
||||
const img = new Image();
|
||||
img.src = `data:image/png;base64,${b64}`;
|
||||
await img.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0);
|
||||
return canvas.toDataURL("image/webp", 0.9);
|
||||
}, pngBuffer.toString("base64"));
|
||||
writeFileSync(path.join(OUT_DIR, fileName), Buffer.from(dataUrl.split(",")[1], "base64"));
|
||||
console.log(`[game] ${fileName}`);
|
||||
}
|
||||
|
||||
for (const lang of ["en", "zh"]) {
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 760 },
|
||||
deviceScaleFactor: 1.5,
|
||||
locale: lang === "zh" ? "zh-CN" : "en-US",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${PAGE}?lang=${lang}&theme=${theme}`);
|
||||
await page.waitForTimeout(300);
|
||||
const png = await page.screenshot();
|
||||
if (PNG_DIR) writeFileSync(path.join(PNG_DIR, `game-${lang}-${theme}.png`), png);
|
||||
await saveWebp(png, `game-${lang}-${theme}.webp`);
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log("[game] done");
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Render the README "build an Agent app in one sentence" finished-product shot.
|
||||
*
|
||||
* The image shows the RESULT of the example — a Claude Code docs-expert RAG app —
|
||||
* rather than the build conversation: rag-app-mockup.html is a static, dependency-free
|
||||
* mockup of the generated app's UI (per the web-design skill's Penguin visual language),
|
||||
* captured per language (zh / en) and theme (light / dark) into assets/readme/ at the
|
||||
* repo root as rag-app-<lang>-<theme>.webp (README.md uses en, README.zh.md uses zh).
|
||||
*
|
||||
* Prereqs: Playwright's chromium only (no server, no build). Run:
|
||||
* `node scripts/capture-readme-demo.mjs [--png-dir <dir>]` (--png-dir also saves
|
||||
* lossless PNG copies, handy for reviewing the shots).
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(HERE, "../../..");
|
||||
const OUT_DIR = path.resolve(ROOT, "assets/readme");
|
||||
const PAGE = pathToFileURL(path.join(HERE, "rag-app-mockup.html")).href;
|
||||
|
||||
const pngDirArg = process.argv.indexOf("--png-dir");
|
||||
const PNG_DIR = pngDirArg >= 0 ? process.argv[pngDirArg + 1] : null;
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
if (PNG_DIR) mkdirSync(PNG_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const encoderPage = await browser.newPage();
|
||||
async function saveWebp(pngBuffer, fileName) {
|
||||
const dataUrl = await encoderPage.evaluate(async (b64) => {
|
||||
const img = new Image();
|
||||
img.src = `data:image/png;base64,${b64}`;
|
||||
await img.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0);
|
||||
return canvas.toDataURL("image/webp", 0.9);
|
||||
}, pngBuffer.toString("base64"));
|
||||
writeFileSync(path.join(OUT_DIR, fileName), Buffer.from(dataUrl.split(",")[1], "base64"));
|
||||
console.log(`[demo] ${fileName}`);
|
||||
}
|
||||
|
||||
for (const lang of ["en", "zh"]) {
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 760 },
|
||||
deviceScaleFactor: 1.5,
|
||||
locale: lang === "zh" ? "zh-CN" : "en-US",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${PAGE}?lang=${lang}&theme=${theme}`);
|
||||
await page.waitForTimeout(300);
|
||||
const png = await page.screenshot();
|
||||
if (PNG_DIR) writeFileSync(path.join(PNG_DIR, `rag-app-${lang}-${theme}.png`), png);
|
||||
await saveWebp(png, `rag-app-${lang}-${theme}.webp`);
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log("[demo] done");
|
||||
@@ -34,96 +34,172 @@ const MOCK = `http://127.0.0.1:${MOCK_PORT}`;
|
||||
// Commands are shared across languages (code is code) and really execute.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CMD_SCAFFOLD = `mkdir -p csv-analyst/src && cat > csv-analyst/package.json <<'EOF'
|
||||
const CMD_COLLECT = `git clone --depth 1 https://github.com/ericbuess/claude-code-docs \\
|
||||
claude-code-expert/corpus/claude-code-docs
|
||||
find claude-code-expert/corpus -type f ! -name '*.md' -delete
|
||||
rm -rf claude-code-expert/corpus/claude-code-docs/.git
|
||||
ls claude-code-expert/corpus/claude-code-docs | head -6`;
|
||||
|
||||
const CMD_APP = `mkdir -p claude-code-expert/src claude-code-expert/public
|
||||
cat > claude-code-expert/package.json <<'EOF'
|
||||
{
|
||||
"name": "csv-analyst",
|
||||
"name": "claude-code-expert",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "start": "tsx src/agent.ts" },
|
||||
"dependencies": { "@prismshadow/penguin-core": "^0.1.0" }
|
||||
"scripts": { "start": "tsx src/rag.ts" },
|
||||
"dependencies": { "@prismshadow/penguin-core": "^0.1.0", "tsx": "^4" }
|
||||
}
|
||||
EOF
|
||||
ls -R csv-analyst`;
|
||||
cat > claude-code-expert/src/rag.ts <<'EOF'
|
||||
// BM25 retrieval over corpus/ + a Session that answers with clickable [n] citations.
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { createAgent, isModelMessage, userText } from "@prismshadow/penguin-core";
|
||||
|
||||
const CMD_ENTRY = `cat > csv-analyst/src/agent.ts <<'EOF'
|
||||
import { createAgent, isCompleteModelMessage, userText } from "@prismshadow/penguin-core";
|
||||
const walk = (d) =>
|
||||
fs.readdirSync(d, { withFileTypes: true }).flatMap((e) =>
|
||||
e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]);
|
||||
const chunks = walk("corpus").flatMap((f) =>
|
||||
fs.readFileSync(f, "utf8").split(/\\n(?=#{1,3} )/).map((text) => ({ source: f, text })));
|
||||
|
||||
const agent = await createAgent({ agentId: "csv_analyst" });
|
||||
const session = await agent.createSession({ workspaceDir: process.cwd() });
|
||||
|
||||
for await (const out of session.run([userText("Analyze data.csv and write summary.md")], {
|
||||
approve: async () => "allow",
|
||||
})) {
|
||||
if (isCompleteModelMessage(out) && out.payload.type === "text") {
|
||||
console.log(out.payload.text);
|
||||
const tok = (s) => s.toLowerCase().match(/[a-z0-9]+|[一-鿿]/g) ?? [];
|
||||
const docs = chunks.map((c) => tok(c.text));
|
||||
const avg = docs.reduce((n, d) => n + d.length, 0) / Math.max(docs.length, 1);
|
||||
const df = new Map();
|
||||
for (const d of docs) for (const w of new Set(d)) df.set(w, (df.get(w) ?? 0) + 1);
|
||||
// BM25 (k1=1.2, b=0.75): idf weights rare terms, term frequency saturates, long chunks are penalized.
|
||||
const score = (q, i) => {
|
||||
const d = docs[i];
|
||||
let s = 0;
|
||||
for (const w of new Set(tok(q))) {
|
||||
const f = d.filter((x) => x === w).length;
|
||||
if (!f) continue;
|
||||
const n = df.get(w) ?? 0;
|
||||
s += Math.log(1 + (docs.length - n + 0.5) / (n + 0.5)) * (f * 2.2) / (f + 1.2 * (0.25 + 0.75 * d.length / avg));
|
||||
}
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const agent = await createAgent({ root: "penguin_data" });
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
if (req.method !== "POST") { res.end(fs.readFileSync("public/index.html")); return; }
|
||||
let body = "";
|
||||
for await (const p of req) body += p;
|
||||
const { question } = JSON.parse(body);
|
||||
const hits = chunks.map((c, i) => [score(question, i), c]).filter(([s]) => s > 0)
|
||||
.sort((a, b) => b[0] - a[0]).slice(0, 6).map(([, c]) => c);
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
const ctx = hits.map((c, i) => "[" + (i + 1) + "] " + c.source + "\\n" + c.text).join("\\n\\n");
|
||||
const session = await agent.createSession({ workspaceDir: process.cwd() });
|
||||
for await (const m of session.run([userText(ctx + "\\n\\nQ: " + question)], {
|
||||
approve: async () => "deny",
|
||||
})) {
|
||||
if (isModelMessage(m) && m.payload.type === "partial_text" && m.payload.event_type === "delta")
|
||||
res.write("data: " + JSON.stringify({ delta: m.payload.text }) + "\\n\\n");
|
||||
}
|
||||
res.write("data: " + JSON.stringify({ sources: hits.map((c) => c.source) }) + "\\n\\n");
|
||||
session.dispose();
|
||||
res.end();
|
||||
}).listen(4630);
|
||||
EOF
|
||||
wc -l csv-analyst/src/agent.ts`;
|
||||
cat > claude-code-expert/public/index.html <<'EOF'
|
||||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>Claude Code docs expert</title>
|
||||
<body style="max-width:640px;margin:3rem auto;font-family:system-ui;line-height:1.5">
|
||||
<h3>Claude Code docs expert</h3>
|
||||
<div id="log"></div>
|
||||
<input id="q" style="width:100%;padding:.5rem" placeholder="Ask about Claude Code…" autofocus />
|
||||
<script>
|
||||
q.addEventListener("keydown", async (e) => {
|
||||
if (e.key !== "Enter" || !q.value.trim()) return;
|
||||
const question = q.value; q.value = "";
|
||||
const p = document.createElement("p"); log.append(p);
|
||||
const res = await fetch("/api/ask", { method: "POST", body: JSON.stringify({ question }) });
|
||||
const reader = res.body.getReader(), dec = new TextDecoder();
|
||||
for (let r; !(r = await reader.read()).done; )
|
||||
for (const chunk of dec.decode(r.value).split("\\n\\n")) {
|
||||
if (!chunk.startsWith("data:")) continue;
|
||||
const d = JSON.parse(chunk.slice(5));
|
||||
if (d.delta) p.textContent += d.delta;
|
||||
if (d.sources) p.textContent += " Sources: " + d.sources.join(", ");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
EOF
|
||||
wc -l claude-code-expert/src/rag.ts`;
|
||||
|
||||
const TREE = `\`\`\`text
|
||||
csv-analyst/
|
||||
claude-code-expert/
|
||||
├── package.json
|
||||
└── src/
|
||||
└── agent.ts
|
||||
├── corpus/claude-code-docs/ # the collected docs
|
||||
├── src/rag.ts
|
||||
└── public/index.html
|
||||
\`\`\``;
|
||||
|
||||
/** Per-language script: user prompt marker -> turns + session title. */
|
||||
const SCRIPTS = {
|
||||
zh: {
|
||||
marker: "数据分析 Agent 应用",
|
||||
prompt: "用 PenguinHarness SDK 创建一个数据分析 Agent 应用:读取 CSV 并输出汇总报告",
|
||||
title: "构建数据分析 Agent 应用",
|
||||
marker: "配置专家",
|
||||
prompt:
|
||||
"收集 https://github.com/ericbuess/claude-code-docs 的文档,做一个化身 Claude Code 配置专家、回答带来源引用的 RAG 问答应用。",
|
||||
title: "构建 Claude Code 文档专家",
|
||||
turns: [
|
||||
{
|
||||
thinking:
|
||||
"需求是基于 penguin-core 的数据分析 Agent 应用。先创建项目骨架:package.json 与源码目录。",
|
||||
text: "我来创建应用骨架:",
|
||||
cmd: CMD_SCAFFOLD,
|
||||
"一句话需求,penguin-sdk 技能给出了完整配方:先采集语料,再建 BM25 索引,回答经 Session 流式生成并带 [n] 引用直达原文。",
|
||||
text: "先把文档采集进语料库:",
|
||||
cmd: CMD_COLLECT,
|
||||
},
|
||||
{
|
||||
thinking:
|
||||
"骨架已建好。入口代码用 createAgent + createSession,把 CSV 分析任务交给 session.run。",
|
||||
text: "骨架就绪,写入 Agent 入口代码:创建 Session,把 CSV 分析任务交给 session.run 并流式输出。",
|
||||
cmd: CMD_ENTRY,
|
||||
"语料就绪。写应用:本地 BM25 检索(零 embedding 依赖)+ 引用回答 + Penguin 风格聊天界面,引用链接到 /corpus 原文。",
|
||||
text: "语料就绪,写检索与引用回答的应用入口:",
|
||||
cmd: CMD_APP,
|
||||
},
|
||||
{
|
||||
text: `数据分析 Agent 应用已创建完成:
|
||||
text: `Claude Code 文档专家已就绪:
|
||||
|
||||
${TREE}
|
||||
|
||||
- 入口 \`csv-analyst/src/agent.ts\`:创建 Agent 与 Session,任务经 \`session.run\` 流式执行,工具调用逐个审批;
|
||||
- 运行方式:\`cd csv-analyst && npm install && npm start\`;
|
||||
- 建议下一步:在评估中心为它配一组 CSV 任务 Benchmark,交给 Optimizer 持续优化。`,
|
||||
- 检索:本地 BM25 索引全部文档片段,中文提问同样支持;
|
||||
- 回答:每次提问经 Session 流式生成,引用 [1][2] 可点击直达 \`corpus/\` 原文;
|
||||
- 界面:Penguin 风格聊天页,空态内置示例问题;
|
||||
- 运行:\`cd claude-code-expert && npm install && npm start\`,浏览器打开 http://localhost:4630。`,
|
||||
},
|
||||
],
|
||||
},
|
||||
en: {
|
||||
marker: "data-analysis Agent app",
|
||||
marker: "configuration expert",
|
||||
prompt:
|
||||
"Use the PenguinHarness SDK to create a data-analysis Agent app that reads CSV files and writes a summary report",
|
||||
title: "Build a data-analysis Agent app",
|
||||
"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.",
|
||||
// Stay under core's TITLE_MAX_CHARS (30): a longer title gets hard-clipped
|
||||
// mid-word in the shots (e.g. "…docs exper").
|
||||
title: "Build Claude Code docs expert",
|
||||
turns: [
|
||||
{
|
||||
thinking:
|
||||
"They want a data-analysis Agent app on penguin-core. Start with the project skeleton: package.json plus the source directory.",
|
||||
text: "Let me scaffold the app first:",
|
||||
cmd: CMD_SCAFFOLD,
|
||||
"One sentence is enough — the penguin-sdk skill has the full recipe: collect the corpus, build a BM25 index, answer through a Session with [n] citations linking to the originals.",
|
||||
text: "Collecting the docs into the corpus first:",
|
||||
cmd: CMD_COLLECT,
|
||||
},
|
||||
{
|
||||
thinking:
|
||||
"Skeleton is in place. The entry uses createAgent + createSession and hands the CSV task to session.run.",
|
||||
text: "Skeleton ready — now the Agent entry point: create a Session and hand the CSV analysis task to session.run, streaming the output.",
|
||||
cmd: CMD_ENTRY,
|
||||
"Corpus in place. Now the app: local BM25 retrieval (no embedding credential), cited answers, and a Penguin-style chat UI with citations linking to /corpus originals.",
|
||||
text: "Corpus ready — now the retrieval and cited-answer entry:",
|
||||
cmd: CMD_APP,
|
||||
},
|
||||
{
|
||||
text: `The data-analysis Agent app is ready:
|
||||
text: `The Claude Code docs expert is ready:
|
||||
|
||||
${TREE}
|
||||
|
||||
- Entry \`csv-analyst/src/agent.ts\`: creates the Agent and a Session; the task runs through \`session.run\` with per-tool approval;
|
||||
- Run it with \`cd csv-analyst && npm install && npm start\`;
|
||||
- Suggested next step: give it a CSV Benchmark suite in the evaluation center and let an Optimizer keep improving it.`,
|
||||
- Retrieval: a local BM25 index over every doc chunk, Chinese questions included;
|
||||
- Answers: each question streams through a Session, with [1][2] citations that link straight to the \`corpus/\` originals;
|
||||
- UI: a Penguin-style chat page with example questions in the empty state;
|
||||
- Run: \`cd claude-code-expert && npm install && npm start\`, then open http://localhost:4630.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -466,7 +542,7 @@ try {
|
||||
await waitFor(`${BASE}/`);
|
||||
console.log(`[shots] server ready on ${BASE}`);
|
||||
|
||||
const admin = await login("admin", "admin123");
|
||||
const admin = await login("admin", "penguin-2026");
|
||||
const browser = await chromium.launch();
|
||||
|
||||
// WebP encoder: Chromium re-encodes the PNG screenshot buffer via canvas, which
|
||||
@@ -549,17 +625,15 @@ try {
|
||||
await page.waitForTimeout(2000);
|
||||
await saveWebp(await page.screenshot(), `chat-${lang}-${theme}.webp`);
|
||||
|
||||
// Trace view: select the session in the list (deep-link selection is unreliable
|
||||
// right after a fresh navigation, so click explicitly — sidebar shows the same
|
||||
// title first in DOM order, hence .last()).
|
||||
await page.goto(`${BASE}/traces?sessionId=${sessionId}`);
|
||||
await page.waitForTimeout(1500);
|
||||
await page
|
||||
.getByText(script.title)
|
||||
.last()
|
||||
.click()
|
||||
.catch(() => {});
|
||||
await page.waitForTimeout(2500);
|
||||
// Trace view: the product's canonical deep link carries BOTH agentId and
|
||||
// sessionId (?sessionId= alone is ignored by TracesPage's focus wiring), and
|
||||
// auto-selects the Session once its trace list loads. Waiting for the
|
||||
// execution timeline's exec_command lanes guarantees every language captures
|
||||
// the same opened trace — stats + a timeline with tool calls — never the
|
||||
// empty "select a Session" state.
|
||||
await page.goto(`${BASE}/traces?agentId=default_agent&sessionId=${sessionId}`);
|
||||
await page.getByText("exec_command").first().waitFor({ timeout: 20000 });
|
||||
await page.waitForTimeout(2000);
|
||||
await saveWebp(await page.screenshot(), `traces-${lang}-${theme}.webp`);
|
||||
|
||||
// Evaluation center: open the pre-provisioned example Benchmark scoreboard.
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Static, dependency-free mockup of the "penguin sled" example game's play screen —
|
||||
the FINISHED PRODUCT of the draft-screen game example, captured by
|
||||
capture-game-mockup.mjs per language (?lang=zh|en) and theme (?theme=light|dark;
|
||||
light = polar day, dark = polar night with an aurora). A cute cartoon scene:
|
||||
an Antarctic penguin on a wooden sled mid-jump over snow-capped rocks, with a
|
||||
score/distance/speed HUD and a "Space to jump" hint.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Penguin Sled Run</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
sans-serif;
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 1280px;
|
||||
height: 760px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(#bfdbfe 0%, #e0f2fe 55%, #f8fafc 100%);
|
||||
}
|
||||
body.dark .stage {
|
||||
background: linear-gradient(#0b1220 0%, #16233b 60%, #1e293b 100%);
|
||||
}
|
||||
.hud {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
body.dark .hud {
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
border-color: rgba(148, 163, 184, 0.25);
|
||||
color: #e2e8f0;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.hud .label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0.62;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.hud .value {
|
||||
font-size: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
#hud-score {
|
||||
top: 22px;
|
||||
left: 24px;
|
||||
}
|
||||
#hud-dist {
|
||||
top: 22px;
|
||||
left: 218px;
|
||||
}
|
||||
#hud-speed {
|
||||
top: 22px;
|
||||
right: 24px;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
body.dark #hud-speed {
|
||||
color: #93c5fd;
|
||||
}
|
||||
.hint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 26px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
color: #334155;
|
||||
}
|
||||
body.dark .hint {
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
border-color: rgba(148, 163, 184, 0.25);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.hint kbd {
|
||||
padding: 3px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #94a3b8;
|
||||
border-bottom-width: 3px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
color: #0f172a;
|
||||
}
|
||||
body.dark .hint kbd {
|
||||
background: #1e293b;
|
||||
border-color: #64748b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.title {
|
||||
position: absolute;
|
||||
top: 26px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
body.dark .title {
|
||||
color: rgba(226, 232, 240, 0.65);
|
||||
}
|
||||
.day-only {
|
||||
display: initial;
|
||||
}
|
||||
.night-only {
|
||||
display: none;
|
||||
}
|
||||
body.dark .day-only {
|
||||
display: none;
|
||||
}
|
||||
body.dark .night-only {
|
||||
display: initial;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stage">
|
||||
<svg viewBox="0 0 1280 760" width="1280" height="760" aria-hidden="true">
|
||||
<!-- Polar day: sun; polar night: moon, stars and an aurora ribbon. -->
|
||||
<g class="day-only">
|
||||
<circle cx="1084" cy="150" r="58" fill="#fde68a" />
|
||||
<circle cx="1084" cy="150" r="44" fill="#fcd34d" />
|
||||
</g>
|
||||
<g class="night-only">
|
||||
<path
|
||||
d="M60 60 C 320 190, 680 30, 1020 150 C 1120 185, 1220 140, 1268 100 L 1268 20 L 60 20 Z"
|
||||
fill="#34d399"
|
||||
opacity="0.16"
|
||||
/>
|
||||
<path
|
||||
d="M100 100 C 360 220, 700 80, 1040 190 C 1130 218, 1210 180, 1260 150 L 1260 60 C 1000 160, 420 40, 100 100 Z"
|
||||
fill="#6ee7b7"
|
||||
opacity="0.12"
|
||||
/>
|
||||
<circle cx="1084" cy="140" r="46" fill="#e2e8f0" opacity="0.92" />
|
||||
<circle cx="1066" cy="128" r="12" fill="#cbd5e1" opacity="0.8" />
|
||||
<circle cx="1102" cy="156" r="8" fill="#cbd5e1" opacity="0.8" />
|
||||
<g fill="#e2e8f0">
|
||||
<circle cx="180" cy="120" r="3" />
|
||||
<circle cx="330" cy="70" r="2.4" />
|
||||
<circle cx="520" cy="130" r="2.8" />
|
||||
<circle cx="760" cy="64" r="2.4" />
|
||||
<circle cx="900" cy="120" r="3" />
|
||||
<circle cx="240" cy="220" r="2" />
|
||||
<circle cx="640" cy="200" r="2.2" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Distant snow hills. -->
|
||||
<path
|
||||
d="M0 430 Q 200 330 420 420 T 860 410 T 1280 400 L 1280 760 L 0 760 Z"
|
||||
class="hill-far"
|
||||
fill="#e2e8f0"
|
||||
/>
|
||||
<path
|
||||
d="M0 500 Q 260 400 560 490 T 1280 470 L 1280 760 L 0 760 Z"
|
||||
class="hill-near"
|
||||
fill="#f1f5f9"
|
||||
/>
|
||||
|
||||
<!-- Ice track. -->
|
||||
<rect x="0" y="560" width="1280" height="200" fill="#ffffff" />
|
||||
<rect x="0" y="560" width="1280" height="12" fill="#bae6fd" opacity="0.7" />
|
||||
<path
|
||||
d="M0 640 H 300 M420 640 H 700 M820 640 H 1100"
|
||||
stroke="#dbeafe"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<!-- Rocks ahead on the track (snow-capped). -->
|
||||
<g>
|
||||
<path d="M760 560 q 26 -52 62 -2 q 20 30 -8 30 l -60 0 q -14 -6 6 -28 Z" fill="#94a3b8" />
|
||||
<path d="M770 548 q 18 -26 40 -4 q -20 -10 -40 4 Z" fill="#f8fafc" />
|
||||
<path
|
||||
d="M980 560 q 34 -70 84 -4 q 24 36 -10 36 l -80 0 q -18 -8 6 -32 Z"
|
||||
fill="#64748b"
|
||||
/>
|
||||
<path d="M994 540 q 26 -34 54 -6 q -28 -14 -54 6 Z" fill="#f8fafc" />
|
||||
<path
|
||||
d="M1180 560 q 22 -44 52 -2 q 16 26 -6 26 l -52 0 q -12 -6 6 -24 Z"
|
||||
fill="#94a3b8"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- The star: a cute penguin on a wooden sled, mid-jump over the first rock.
|
||||
Drawn upright and compact so every part stays connected to the body. -->
|
||||
<g transform="translate(430 316) rotate(-8)">
|
||||
<!-- motion lines -->
|
||||
<path
|
||||
d="M-158 118 h 70 M-178 156 h 58 M-150 194 h 66"
|
||||
stroke="#93c5fd"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
opacity="0.7"
|
||||
/>
|
||||
<!-- snow spray behind the sled -->
|
||||
<g fill="#e0f2fe">
|
||||
<circle cx="-92" cy="214" r="9" />
|
||||
<circle cx="-116" cy="230" r="6" />
|
||||
<circle cx="-70" cy="232" r="5" />
|
||||
</g>
|
||||
<!-- sled: two wooden slats + a curled steel runner -->
|
||||
<path
|
||||
d="M-92 208 q -22 0 -20 -22 q 2 -14 20 -14 h 250 q 26 2 26 22 q 0 14 -24 14 Z"
|
||||
fill="none"
|
||||
stroke="#475569"
|
||||
stroke-width="9"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<rect x="-70" y="176" width="242" height="24" rx="12" fill="#b45309" />
|
||||
<rect x="-58" y="158" width="220" height="22" rx="11" fill="#d97706" />
|
||||
|
||||
<!-- penguin, centered on the sled -->
|
||||
<g transform="translate(52 60)">
|
||||
<!-- body (black egg) -->
|
||||
<path
|
||||
d="M0 -96 C 62 -96 82 -34 82 20 C 82 78 44 108 0 108 C -44 108 -82 78 -82 20 C -82 -34 -62 -96 0 -96 Z"
|
||||
fill="#111827"
|
||||
/>
|
||||
<!-- white belly, tucked inside the body outline -->
|
||||
<path
|
||||
d="M0 -52 C 40 -52 56 -10 56 30 C 56 66 30 88 0 88 C -30 88 -56 66 -56 30 C -56 -10 -40 -52 0 -52 Z"
|
||||
fill="#f8fafc"
|
||||
/>
|
||||
<!-- near wing, hugging the body -->
|
||||
<path d="M-66 -20 C -92 4 -92 52 -70 74 C -58 60 -58 16 -60 -8 Z" fill="#0b1220" />
|
||||
<!-- far wing, raised a little (whee!) -->
|
||||
<path d="M62 -30 C 92 -44 104 -16 96 12 C 82 6 70 -8 60 -18 Z" fill="#0b1220" />
|
||||
<!-- cheeks -->
|
||||
<circle cx="-30" cy="-14" r="11" fill="#fda4af" opacity="0.8" />
|
||||
<circle cx="30" cy="-14" r="11" fill="#fda4af" opacity="0.8" />
|
||||
<!-- eyes -->
|
||||
<circle cx="-20" cy="-40" r="9" fill="#f8fafc" />
|
||||
<circle cx="20" cy="-40" r="9" fill="#f8fafc" />
|
||||
<circle cx="-18" cy="-38" r="4.4" fill="#0b1220" />
|
||||
<circle cx="22" cy="-38" r="4.4" fill="#0b1220" />
|
||||
<circle cx="-16" cy="-40" r="1.5" fill="#f8fafc" />
|
||||
<circle cx="24" cy="-40" r="1.5" fill="#f8fafc" />
|
||||
<!-- beak -->
|
||||
<path d="M-11 -22 L 11 -22 L 0 -8 Z" fill="#f59e0b" />
|
||||
<!-- scarf around the neck (two bands, one short tail) -->
|
||||
<path d="M-40 4 Q 0 22 40 4 L 40 20 Q 0 38 -40 20 Z" fill="#ef4444" />
|
||||
<path d="M28 14 q 26 6 30 26 q -18 6 -34 -6 Z" fill="#f87171" />
|
||||
<!-- feet resting on the front slat -->
|
||||
<path d="M-26 104 q -14 12 4 20 q 20 4 26 -10 Z" fill="#fb923c" />
|
||||
<path d="M26 104 q -6 14 12 18 q 20 2 22 -12 Z" fill="#fb923c" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="title" id="title"></div>
|
||||
<div class="hud" id="hud-score">
|
||||
<span class="label" id="score-label"></span><span class="value">1280</span>
|
||||
</div>
|
||||
<div class="hud" id="hud-dist">
|
||||
<span class="label" id="dist-label"></span><span class="value">512 m</span>
|
||||
</div>
|
||||
<div class="hud" id="hud-speed"><span class="value">×2.4</span></div>
|
||||
<div class="hint"><kbd id="key"></kbd><span id="hint"></span></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const lang = params.get("lang") === "zh" ? "zh" : "en";
|
||||
if (params.get("theme") === "dark") document.body.classList.add("dark");
|
||||
const T = {
|
||||
zh: {
|
||||
title: "企 鹅 雪 橇 越 野",
|
||||
score: "得分",
|
||||
dist: "里程",
|
||||
key: "空格",
|
||||
hint: "起跳,跃过石头!",
|
||||
},
|
||||
en: {
|
||||
title: "P E N G U I N · S L E D · R U N",
|
||||
score: "SCORE",
|
||||
dist: "DISTANCE",
|
||||
key: "Space",
|
||||
hint: "jump the rocks!",
|
||||
},
|
||||
}[lang];
|
||||
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
|
||||
document.getElementById("title").textContent = T.title;
|
||||
document.getElementById("score-label").textContent = T.score;
|
||||
document.getElementById("dist-label").textContent = T.dist;
|
||||
document.getElementById("key").textContent = T.key;
|
||||
document.getElementById("hint").textContent = T.hint;
|
||||
const dark = document.body.classList.contains("dark");
|
||||
if (dark) {
|
||||
for (const [sel, fill] of [
|
||||
[".hill-far", "#243b55"],
|
||||
[".hill-near", "#2c476b"],
|
||||
]) {
|
||||
document.querySelector(sel).setAttribute("fill", fill);
|
||||
}
|
||||
for (const rect of document.querySelectorAll("svg rect")) {
|
||||
if (rect.getAttribute("fill") === "#ffffff") rect.setAttribute("fill", "#dbeafe");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,439 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<!--
|
||||
Finished-product mockup of the "one sentence → RAG app" example (a Claude Code docs
|
||||
expert), rendered by capture-readme-demo.mjs into assets/readme/rag-app-<lang>-<theme>.webp.
|
||||
Pure static page — no build, no server; ?lang=zh|en&theme=light|dark picks the variant.
|
||||
Styling follows the web-design skill's Penguin visual language: solid backgrounds,
|
||||
1px hairlines, system fonts, one brand-blue accent, pure-black dark mode.
|
||||
-->
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Claude Code Docs Expert</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--brand-50: #e8f0fe;
|
||||
--brand-100: #d2e3fc;
|
||||
--brand-300: #8ab4f8;
|
||||
--brand-500: #4285f4;
|
||||
--brand-600: #1a73e8;
|
||||
--brand-700: #0b57d0;
|
||||
--bg: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f9fafb;
|
||||
--border: #e5e7eb;
|
||||
--control-border: #d1d5db;
|
||||
--fg: #111827;
|
||||
--fg-muted: #4b5563;
|
||||
--fg-faint: #6b7280;
|
||||
--accent-bg: #111827;
|
||||
--accent-fg: #ffffff;
|
||||
--bubble: #f3f4f6;
|
||||
--code-bg: #f9fafb;
|
||||
--chip-bg: #e8f0fe;
|
||||
--chip-fg: #0b57d0;
|
||||
--chip-border: #d2e3fc;
|
||||
--dots: rgb(26 115 232 / 0.14);
|
||||
}
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--bg: #000000;
|
||||
--surface: #0d0d0d;
|
||||
--surface-2: #0d0d0d;
|
||||
--border: #1f1f1f;
|
||||
--control-border: #303030;
|
||||
--fg: #f3f4f6;
|
||||
--fg-muted: #9ca3af;
|
||||
--fg-faint: #6b7280;
|
||||
--accent-bg: #f3f4f6;
|
||||
--accent-fg: #111827;
|
||||
--bubble: #1f1f1f;
|
||||
--code-bg: #0d0d0d;
|
||||
--chip-bg: #041e49;
|
||||
--chip-fg: #8ab4f8;
|
||||
--chip-border: #062e6f;
|
||||
--dots: rgb(138 180 248 / 0.16);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
code,
|
||||
pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
nav {
|
||||
height: 56px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--bg) 85%, transparent);
|
||||
}
|
||||
.logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent-fg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
}
|
||||
.logo svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.app-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.nav-chip {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--chip-fg);
|
||||
background: var(--chip-bg);
|
||||
border: 1px solid var(--chip-border);
|
||||
border-radius: 9999px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
.nav-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
/* Decorative dot grid fading out below the nav — the one flourish the design language
|
||||
allows. The ::after overlay fades to the page background, so the tail disappears
|
||||
cleanly in dark mode too (light dots on black stay visible far longer under a mask). */
|
||||
.dots-band {
|
||||
position: absolute;
|
||||
inset: 56px 0 auto 0;
|
||||
height: 150px;
|
||||
background-image: radial-gradient(var(--dots) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dots-band::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--bg) 55%, transparent) 55%,
|
||||
var(--bg) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.chat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.msg-user {
|
||||
align-self: flex-end;
|
||||
max-width: 85%;
|
||||
background: var(--bubble);
|
||||
border-radius: 12px;
|
||||
padding: 9px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.msg-assistant {
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: var(--fg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.msg-assistant p {
|
||||
max-width: 65ch;
|
||||
}
|
||||
.cite {
|
||||
color: var(--brand-600);
|
||||
font-size: 0.8em;
|
||||
vertical-align: super;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.dark .cite {
|
||||
color: var(--brand-300);
|
||||
}
|
||||
pre.block {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.85;
|
||||
overflow-x: auto;
|
||||
}
|
||||
pre.block .c {
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.sources {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.sources-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.source-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--chip-fg);
|
||||
background: var(--chip-bg);
|
||||
border: 1px solid var(--chip-border);
|
||||
border-radius: 9999px;
|
||||
padding: 3px 11px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.source-chip .n {
|
||||
font-weight: 600;
|
||||
}
|
||||
.source-chip .path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
flex: none;
|
||||
padding: 14px 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.suggests {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.suggest {
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 9999px;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
.composer {
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 12px 12px 10px 14px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.composer .ph {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--fg-faint);
|
||||
padding: 4px 0 6px;
|
||||
}
|
||||
.send {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent-fg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
}
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--fg-faint);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="dots-band"></div>
|
||||
<nav>
|
||||
<div class="logo" aria-hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M4 19.5V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v13.5" stroke="none" />
|
||||
<path
|
||||
d="M12 3c3.5 0 5.5 2.6 5.5 6.2 0 4.4 1.6 6.3 2.5 7.1-1.6 2.9-4.6 4.7-8 4.7s-6.4-1.8-8-4.7c.9-.8 2.5-2.7 2.5-7.1C6.5 5.6 8.5 3 12 3z"
|
||||
/>
|
||||
<circle cx="9.8" cy="8.6" r="0.6" fill="currentColor" stroke="none" />
|
||||
<circle cx="14.2" cy="8.6" r="0.6" fill="currentColor" stroke="none" />
|
||||
<path d="M10.6 11h2.8l-1.4 1.6z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="app-name" data-t="appName"></span>
|
||||
<span class="nav-chip">RAG · claude-code-docs</span>
|
||||
<div class="nav-right"><span class="dot"></span><span data-t="corpus"></span></div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<div class="chat">
|
||||
<div class="msg-user" data-t="question"></div>
|
||||
<div class="msg-assistant">
|
||||
<p>
|
||||
<span data-t="a1"></span>
|
||||
<a class="cite" href="#">[1]</a><span data-t="a1b"></span>
|
||||
</p>
|
||||
<pre class="block"><span class="c" data-t="codeC1"></span>
|
||||
claude mcp add github -- npx -y @modelcontextprotocol/server-github
|
||||
|
||||
<span class="c" data-t="codeC2"></span>
|
||||
claude mcp list</pre>
|
||||
<p>
|
||||
<span data-t="a2"></span>
|
||||
<a class="cite" href="#">[2]</a><span data-t="a2b"></span>
|
||||
</p>
|
||||
<div class="sources">
|
||||
<span class="sources-label" data-t="sources"></span>
|
||||
<a class="source-chip" href="#"
|
||||
><span class="n">[1]</span><span class="path">docs/mcp.md</span
|
||||
><span data-t="s1"></span
|
||||
></a>
|
||||
<a class="source-chip" href="#"
|
||||
><span class="n">[2]</span><span class="path">docs/settings.md</span
|
||||
><span data-t="s2"></span
|
||||
></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="suggests">
|
||||
<span class="suggest" data-t="q1"></span>
|
||||
<span class="suggest" data-t="q2"></span>
|
||||
<span class="suggest" data-t="q3"></span>
|
||||
</div>
|
||||
<div class="composer">
|
||||
<div class="ph" data-t="placeholder"></div>
|
||||
<button class="send" aria-hidden>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 19V5M6 11l6-6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="hint" data-t="hint"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const T = {
|
||||
en: {
|
||||
appName: "Claude Code Docs Expert",
|
||||
corpus: "674 chunks · BM25",
|
||||
question: "How do I configure an MCP server for Claude Code?",
|
||||
a1: "Add the server with the CLI, then verify it from inside Claude Code ",
|
||||
a1b: ":",
|
||||
codeC1: "# add a server (local scope by default)",
|
||||
codeC2: "# verify",
|
||||
a2: "Project-scoped servers live in .mcp.json at the project root and are shared with your team; user scope makes a server available across all your projects ",
|
||||
a2b: ". Inside a session, /mcp shows each server's connection status.",
|
||||
sources: "Sources",
|
||||
s1: "— Add an MCP server",
|
||||
s2: "— Settings & scopes",
|
||||
q1: "What does CLAUDE.md do?",
|
||||
q2: "How do I create a custom slash command?",
|
||||
q3: "Where do hooks go?",
|
||||
placeholder: "Ask about Claude Code…",
|
||||
hint: "Answers cite the collected docs — click a source to open the original file.",
|
||||
},
|
||||
zh: {
|
||||
appName: "Claude Code 配置专家",
|
||||
corpus: "674 个片段 · BM25",
|
||||
question: "如何为 Claude Code 配置 MCP 服务器?",
|
||||
a1: "用 CLI 添加服务器,然后在 Claude Code 里验证 ",
|
||||
a1b: ":",
|
||||
codeC1: "# 添加服务器(默认 local 作用域)",
|
||||
codeC2: "# 验证",
|
||||
a2: "项目级服务器保存在项目根目录的 .mcp.json,可随仓库共享给团队;user 作用域则让服务器在你的所有项目中可用 ",
|
||||
a2b: "。会话内可用 /mcp 查看各服务器的连接状态。",
|
||||
sources: "来源",
|
||||
s1: "— 添加 MCP 服务器",
|
||||
s2: "— 设置与作用域",
|
||||
q1: "CLAUDE.md 是做什么的?",
|
||||
q2: "怎么创建自定义斜杠命令?",
|
||||
q3: "Hooks 配置放在哪里?",
|
||||
placeholder: "向 Claude Code 专家提问…",
|
||||
hint: "回答引用采集的文档——点击来源即可打开原文。",
|
||||
},
|
||||
};
|
||||
const params = new URLSearchParams(location.search);
|
||||
const lang = params.get("lang") === "zh" ? "zh" : "en";
|
||||
const theme = params.get("theme") === "dark" ? "dark" : "light";
|
||||
document.documentElement.lang = lang === "zh" ? "zh" : "en";
|
||||
if (theme === "dark") document.documentElement.classList.add("dark");
|
||||
for (const el of document.querySelectorAll("[data-t]")) {
|
||||
el.textContent = T[lang][el.dataset.t];
|
||||
}
|
||||
document.title = T[lang].appName;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Renders the README / blog benchmark chart from the same data the landing page uses
|
||||
* (src/lib/benchmark-data.ts), so the static SVGs cannot drift from the site when the
|
||||
* numbers are refreshed. Emits:
|
||||
* assets/readme/benchmark-light.svg
|
||||
* assets/readme/benchmark-dark.svg
|
||||
* packages/landing/public/blog-assets/benchmark-light.svg
|
||||
*
|
||||
* Two panels per suite (accuracy, cost), horizontal bars scaled linearly from zero — the
|
||||
* cost spread is ~70x, so the PenguinHarness bar is by far the shortest. That is the
|
||||
* result, not a defect; a zoomed baseline would flatter everyone else. Bars are floored at
|
||||
* MIN_BAR, though: at true scale the cost bar comes out ~2.5px, which reads as "no bar" and
|
||||
* loses the series entirely. The floor keeps it visible and unmistakably smallest, and the
|
||||
* exact figure is printed beside every bar, so nothing is overstated.
|
||||
*
|
||||
* Run: node packages/landing/scripts/render-benchmark-svg.mjs
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LANDING = path.resolve(HERE, "..");
|
||||
const REPO = path.resolve(LANDING, "..", "..");
|
||||
|
||||
/**
|
||||
* The data module is TypeScript; rather than add a build step, read the two exported
|
||||
* arrays out of the source. Any shape change here fails loudly instead of silently
|
||||
* rendering a stale chart.
|
||||
*/
|
||||
function loadBench() {
|
||||
const src = readFileSync(path.join(LANDING, "src/lib/benchmark-data.ts"), "utf8");
|
||||
const consts = Object.fromEntries(
|
||||
[...src.matchAll(/^const (\w+) = "([^"]+)";$/gm)].map((m) => [m[1], m[2]]),
|
||||
);
|
||||
const pick = (name) => {
|
||||
const block = src.match(
|
||||
new RegExp(`export const ${name}: BenchResult\\[\\] = \\[([\\s\\S]*?)\\n\\];`),
|
||||
);
|
||||
if (!block) throw new Error(`could not find ${name} in benchmark-data.ts`);
|
||||
const rows = [...block[1].matchAll(/\{([\s\S]*?)\n {2}\}/g)].map((m) => {
|
||||
const body = m[1];
|
||||
const field = (k) => body.match(new RegExp(`${k}:\\s*([^,\\n]+)`))?.[1]?.trim();
|
||||
const str = (k) => {
|
||||
const v = field(k);
|
||||
if (!v) throw new Error(`missing ${k}`);
|
||||
return v.startsWith('"') ? v.slice(1, -1) : (consts[v] ?? v);
|
||||
};
|
||||
return {
|
||||
framework: str("framework"),
|
||||
model: str("model"),
|
||||
accuracyPct: Number(field("accuracyPct")),
|
||||
tokensM: Number(field("tokensM")),
|
||||
costUsd: Number(field("costUsd")),
|
||||
emphasized: /emphasized:\s*true/.test(body),
|
||||
};
|
||||
});
|
||||
if (rows.length !== 3) throw new Error(`${name}: expected 3 rows, got ${rows.length}`);
|
||||
for (const r of rows) {
|
||||
if (!Number.isFinite(r.accuracyPct) || !Number.isFinite(r.costUsd)) {
|
||||
throw new Error(`${name}: ${r.framework} has a non-numeric field`);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
return { DATA_BENCH: pick("DATA_BENCH"), CODE_BENCH: pick("CODE_BENCH") };
|
||||
}
|
||||
|
||||
const THEMES = {
|
||||
light: {
|
||||
strong: "#1f2328",
|
||||
mid: "#52514e",
|
||||
muted: "#898781",
|
||||
rule: "#c3c2b7",
|
||||
brand: "#2a78d6",
|
||||
bar: "#898781",
|
||||
},
|
||||
dark: {
|
||||
strong: "#f0f3f6",
|
||||
mid: "#c3c2b7",
|
||||
muted: "#898781",
|
||||
rule: "#383835",
|
||||
brand: "#3987e5",
|
||||
bar: "#6b6a64",
|
||||
},
|
||||
};
|
||||
|
||||
const FONT = "system-ui, -apple-system, 'Segoe UI', sans-serif";
|
||||
const W = 920;
|
||||
const H = 368;
|
||||
const BAR_H = 16;
|
||||
const ROW_H = 30;
|
||||
const MAX_BAR = 176;
|
||||
/** Shortest a bar may render, so a near-zero value still reads as a bar (~8% of full). */
|
||||
const MIN_BAR = 14;
|
||||
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
function text(x, y, s, { size = 12, weight = 400, fill, anchor = "start" }) {
|
||||
return `<text x="${x}" y="${y}" font-size="${size}" font-weight="${weight}" fill="${fill}" text-anchor="${anchor}" font-family="${FONT}">${esc(s)}</text>`;
|
||||
}
|
||||
|
||||
/** Rounded-end horizontal bar starting at the axis. */
|
||||
function bar(x, y, w, fill) {
|
||||
const r = Math.min(4, w / 2);
|
||||
return `<path d="M${x} ${y} h${(w - r).toFixed(1)} a${r} ${r} 0 0 1 ${r} ${r} v${BAR_H - 2 * r} a${r} ${r} 0 0 1 -${r} ${r} h-${(w - r).toFixed(1)} z" fill="${fill}"/>`;
|
||||
}
|
||||
|
||||
/** One measure for one suite: label column, axis rule, three bars, value labels. */
|
||||
function panel(t, x0, yTop, rows, value, format) {
|
||||
const axis = x0 + 124;
|
||||
const max = Math.max(...rows.map(value));
|
||||
const out = [
|
||||
`<rect x="${axis - 1}" y="${yTop}" width="1" height="${ROW_H * rows.length + 2}" fill="${t.rule}"/>`,
|
||||
];
|
||||
rows.forEach((row, i) => {
|
||||
const y = yTop + 4 + i * ROW_H;
|
||||
const w = Math.max(MIN_BAR, (value(row) / max) * MAX_BAR);
|
||||
const weight = row.emphasized ? 600 : 400;
|
||||
out.push(
|
||||
text(axis - 12, y + 12.5, row.framework, {
|
||||
fill: row.emphasized ? t.strong : t.mid,
|
||||
weight,
|
||||
anchor: "end",
|
||||
}),
|
||||
bar(axis + 1, y, w, row.emphasized ? t.brand : t.bar),
|
||||
text(axis + w + 13, y + 12.5, format(value(row)), { fill: t.strong, weight }),
|
||||
);
|
||||
});
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function suite(t, yTop, title, rows) {
|
||||
return [
|
||||
text(24, yTop, title, { size: 13, weight: 600, fill: t.strong }),
|
||||
panel(
|
||||
t,
|
||||
24,
|
||||
yTop + 10,
|
||||
rows,
|
||||
(r) => r.accuracyPct,
|
||||
(v) => `${v.toFixed(2)}%`,
|
||||
),
|
||||
panel(
|
||||
t,
|
||||
472,
|
||||
yTop + 10,
|
||||
rows,
|
||||
(r) => r.costUsd,
|
||||
(v) => `$${v.toFixed(2)}`,
|
||||
),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function render(theme, { DATA_BENCH, CODE_BENCH }) {
|
||||
const t = THEMES[theme];
|
||||
const alt =
|
||||
"Benchmark: PenguinHarness vs Claude Code vs OpenAI Codex on two suites — comparable accuracy at a small fraction of the cost";
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${esc(alt)}">
|
||||
${text(148, 36, "Accuracy · suite total · higher is better", { fill: t.muted })}
|
||||
${text(596, 36, "Total cost (USD) · lower is better", { fill: t.muted })}
|
||||
${suite(t, 68, "Data analysis — 15 tasks, single run", DATA_BENCH)}
|
||||
${suite(t, 210, "Coding — 40 tasks × 2 runs", CODE_BENCH)}
|
||||
${text(24, 338, "Each harness runs the model it is normally paired with: PenguinHarness on DeepSeek V4 Pro, Claude Code on Claude Opus 4.8,", { fill: t.muted })}
|
||||
${text(24, 356, "OpenAI Codex on GPT-5.5. Accuracy, Tokens and cost are suite totals at official pricing.", { fill: t.muted })}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
const bench = loadBench();
|
||||
const targets = [
|
||||
["light", path.join(REPO, "assets/readme/benchmark-light.svg")],
|
||||
["dark", path.join(REPO, "assets/readme/benchmark-dark.svg")],
|
||||
["light", path.join(LANDING, "public/blog-assets/benchmark-light.svg")],
|
||||
];
|
||||
for (const [theme, file] of targets) {
|
||||
writeFileSync(file, render(theme, bench));
|
||||
console.log(`wrote ${path.relative(REPO, file)}`);
|
||||
}
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 85 KiB |
|
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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,31 +2,55 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CODE_BENCH,
|
||||
DATA_BENCH,
|
||||
costMultiple,
|
||||
formatAccuracy,
|
||||
formatPct,
|
||||
formatTokensM,
|
||||
formatUsd,
|
||||
} from "../src/lib/benchmark-data";
|
||||
|
||||
describe("benchmark data (unified per-run means)", () => {
|
||||
describe("benchmark data (unified suite totals)", () => {
|
||||
it("formats the data-analysis suite at its published precision", () => {
|
||||
const penguin = DATA_BENCH[0]!;
|
||||
expect(formatPct(penguin.accuracyPct)).toBe("66.7%");
|
||||
expect(formatAccuracy(penguin.accuracyPct, 1)).toBe("66.7");
|
||||
expect(formatPct(penguin.accuracyPct)).toBe("66.67%");
|
||||
expect(formatAccuracy(penguin.accuracyPct, 2)).toBe("66.67");
|
||||
expect(formatTokensM(penguin.tokensM, 2)).toBe("18.04M");
|
||||
expect(formatUsd(penguin.costUsd, 3)).toBe("$0.552");
|
||||
expect(formatUsd(penguin.costUsd, 2)).toBe("$0.55");
|
||||
});
|
||||
|
||||
it("formats the coding suite at its published precision (CNY converted at 7:1)", () => {
|
||||
it("formats the coding suite at its published precision", () => {
|
||||
const penguin = CODE_BENCH[0]!;
|
||||
expect(formatAccuracy(penguin.accuracyPct, 2)).toBe("50.00");
|
||||
expect(formatTokensM(penguin.tokensM, 2)).toBe("2.10M");
|
||||
expect(formatUsd(penguin.costUsd, 3)).toBe("$0.041");
|
||||
// 0.289 CNY / 7 -> ~0.0413 USD
|
||||
expect(penguin.costUsd).toBeCloseTo(0.289 / 7, 3);
|
||||
expect(formatAccuracy(penguin.accuracyPct, 2)).toBe("71.25");
|
||||
expect(formatTokensM(penguin.tokensM, 2)).toBe("200.00M");
|
||||
expect(formatUsd(penguin.costUsd, 2)).toBe("$3.81");
|
||||
});
|
||||
|
||||
it("uses the unified framework names with PenguinHarness as the only emphasized row", () => {
|
||||
it("keeps every published accuracy on its suite's grid (n=15 and n=80)", () => {
|
||||
// 10/15 -> 66.67, 8/15 -> 53.33; 57/80 -> 71.25, 69/80 -> 86.25. A number off the grid
|
||||
// means a transcription slip, which the charts would render without complaint.
|
||||
for (const row of DATA_BENCH)
|
||||
expect(Math.round((row.accuracyPct / 100) * 15)).toBeCloseTo((row.accuracyPct / 100) * 15, 1);
|
||||
for (const row of CODE_BENCH) expect(((row.accuracyPct / 100) * 80) % 1).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it("backs the copy's cost claims: 35x/70x on data analysis, 58x/39x on coding", () => {
|
||||
const [dPenguin, dClaude, dCodex] = DATA_BENCH as [
|
||||
(typeof DATA_BENCH)[0],
|
||||
(typeof DATA_BENCH)[0],
|
||||
(typeof DATA_BENCH)[0],
|
||||
];
|
||||
expect(costMultiple(dCodex, dPenguin)).toBe(35);
|
||||
expect(costMultiple(dClaude, dPenguin)).toBe(70);
|
||||
const [cPenguin, cClaude, cCodex] = CODE_BENCH as [
|
||||
(typeof CODE_BENCH)[0],
|
||||
(typeof CODE_BENCH)[0],
|
||||
(typeof CODE_BENCH)[0],
|
||||
];
|
||||
expect(costMultiple(cCodex, cPenguin)).toBe(58);
|
||||
expect(costMultiple(cClaude, cPenguin)).toBe(39);
|
||||
});
|
||||
|
||||
it("pairs each harness with its own model and emphasizes only PenguinHarness", () => {
|
||||
for (const suite of [DATA_BENCH, CODE_BENCH]) {
|
||||
expect(suite.map((r) => r.framework)).toEqual([
|
||||
"PenguinHarness",
|
||||
@@ -34,7 +58,7 @@ describe("benchmark data (unified per-run means)", () => {
|
||||
"OpenAI Codex",
|
||||
]);
|
||||
expect(suite.filter((r) => r.emphasized).map((r) => r.framework)).toEqual(["PenguinHarness"]);
|
||||
for (const row of suite) expect(row.model).toBe("DeepSeek V4 Pro");
|
||||
expect(suite.map((r) => r.model)).toEqual(["DeepSeek V4 Pro", "Claude Opus 4.8", "GPT-5.5"]);
|
||||
}
|
||||
});
|
||||
});
|
||||