Changelog, dev startup, README, AgentHub 0.4.0, model catalog, and landing site (#7)
Branch-length batch covering tooling, the model layer, the Web App and the public surfaces. Highlights: - Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each change touches, with a root CHANGELOG.md holding one line per release. - Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a lock and keeps `pnpm install` current; `pnpm dev` runs server+web together. - AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity` object in place of item-level `signature`/`phase`, threaded verbatim through Trace, replay and resume; malformed classification adapted to the new error types. - Model layer: a model is always referenced by an explicit `(provider, model_id)` pair. The provider is never inferred, guessed or defaulted -- both the catalog inference and the unique-match config resolution are gone, and CLI, SDK, server routes and run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group. - Web App: catalog preset sync and per-group speed test on the Models page, positional slash commands, a markdown renderer, skill-library update reminders, and a vertically centred draft page whose upward menus size themselves to the room available. - Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed benchmark results for both suites, and the demo videos playing on the landing page. Includes the fixes from a full review of the branch: 23 confirmed findings, among them a provider-inference bug that could send one vendor's API key to another vendor's endpoint, and an Escape handler that destroyed the composer's contents unrecoverably. Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and pnpm format:check clean, Playwright e2e 14/14.
This commit is contained in:
1 parent
abf0a2f248
commit
d4faee3a1e
214 files changed
+9406
-1688
No files matched your search
@@ -4,17 +4,16 @@ The PenguinHarness built-in skill library. A Skill is a directory with a `SKILL.
|
||||
|
||||
Skills follow the "index first, body on demand" design: only their metadata is injected into an Agent's system prompt; the Agent reads the full `SKILL.md` via shell when it actually needs it.
|
||||
|
||||
Included skills:
|
||||
Included skills, in the order of the `SKILL_GROUPS` manifest in `src/index.ts` (a skill directory missing from the manifest is still loaded, and lands in an "Other" group):
|
||||
|
||||
| Group | Skills |
|
||||
| --- | --- |
|
||||
| Agent Development | `agent-creation`, `benchmark-design`, `agent-evaluation`, `agent-optimization` |
|
||||
| Data Analysis | `data-analysis` |
|
||||
| Penguin Development | `penguin-sdk`, `penguin-cli`, `agenthub-models` |
|
||||
| Web Development | `web-design` |
|
||||
| Software Engineering | `software-engineering` |
|
||||
| Office Productivity | `data-analysis`, `firecrawl` |
|
||||
| Software Development | `web-design`, `software-engineering` |
|
||||
| AI App Development | `penguin-sdk`, `penguin-cli`, `agenthub-models` |
|
||||
| Agent Tuning | `agent-creation`, `benchmark-design`, `agent-evaluation`, `agent-optimization` |
|
||||
|
||||
The first group powers the self-improvement loop: design a Benchmark, evaluate the Target Agent, optimize it to version N+1 with a snapshot before every round.
|
||||
Agent Tuning powers the self-improvement loop: create the Target Agent, design a Benchmark, evaluate it, optimize it to version N+1 with a snapshot before every round.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ name: agent-creation
|
||||
description: Turn a user requirement into a concrete agent — write the target agent's AGENTS.md and install the skills it needs.
|
||||
short_description: Turn a requirement into a working agent.
|
||||
short_description_zh: 把需求变成可用的 Agent。
|
||||
version: 1
|
||||
updated: 2026-07-17T00:00:00Z
|
||||
version: 2
|
||||
updated: 2026-07-20T13:00:00Z
|
||||
---
|
||||
|
||||
# Agent Creation
|
||||
@@ -13,7 +13,7 @@ This skill turns a user requirement into a working agent configuration — plain
|
||||
|
||||
## Before you start
|
||||
|
||||
If the user's message only invokes this skill (e.g. "use agent-creation skill") without a concrete requirement, ask the user what agent they want and what it should do. Do not start until the requirement is clear.
|
||||
If the user's message only invokes this skill (e.g. "use agent-creation skill") without a concrete requirement, ask the user what agent they want and what it should do. But when the requirement is already concrete — even a single sentence like "an expert that answers questions about X" — do **not** ask follow-up questions: derive the role and rules from that sentence, apply the defaults below, and list your assumptions in the final reply.
|
||||
|
||||
## Locate the target agent
|
||||
|
||||
@@ -34,7 +34,7 @@ An agent directory contains `agent_state/` (`system_config.yaml`, `AGENTS.md`, `
|
||||
- Role — what the agent is for, in one or two sentences.
|
||||
- Domain guidance — the concrete rules, steps and constraints derived from the user requirement.
|
||||
|
||||
Be concise: AGENTS.md is prompt context, not documentation.
|
||||
Be concise: AGENTS.md is prompt context, not documentation. For a domain expert that answers from a knowledge base, a good AGENTS.md is a few lines: the role sentence, "answer strictly from the provided context blocks", citation rules ("cite blocks inline as [1][2]"), a refusal rule for questions the context cannot answer, and "answer in the language of the question".
|
||||
|
||||
## Install skills
|
||||
|
||||
@@ -44,8 +44,8 @@ A skill is a directory `agent_state/skills/<skill_name>/` containing a `SKILL.md
|
||||
---
|
||||
name: <skill_name>
|
||||
description: <skill_description>
|
||||
version: 1
|
||||
updated: <ISO 8601 timestamp>
|
||||
version: <natural number — bump it on every content change>
|
||||
updated: <ISO 8601 timestamp — move it together with version>
|
||||
---
|
||||
|
||||
<skill_instructions>
|
||||
@@ -57,6 +57,12 @@ Installing is all it takes: the frontmatter metadata of every `SKILL.md` under `
|
||||
|
||||
Write skills yourself, or fetch existing ones from the internet with shell commands (`curl`, `git clone`) and place them under `skills/`. Anything fetched from the internet must be read in full and reviewed before installing — a skill becomes durable instructions the target agent will follow in every future session; never install one you have not read, and tell the user what it does.
|
||||
|
||||
Library skills can be copied from any agent that already has them (e.g. `default_agent`, which ships the whole library) — copy the entire `skills/<skill_name>/` directory. Common bundles, so you don't under-equip the target:
|
||||
|
||||
- **App builder** (builds apps or web frontends): `penguin-sdk`, `web-design`, `agenthub-models`.
|
||||
- **Knowledge expert** (answers questions over a document set): usually **no** harness agent is needed — build a RAG app with the penguin-sdk skill instead, and configure the app's embedded agent (below).
|
||||
- **Evaluation loop**: `benchmark-design`, `agent-evaluation`, `agent-optimization`.
|
||||
|
||||
## Set name and description
|
||||
|
||||
In the target's `agent_state/system_config.yaml`, set the top-level `name:` and `description:` fields so the agent is recognizable in lists. Edit only these two fields.
|
||||
@@ -71,3 +77,7 @@ cp "$PROJECT_DIR/agents/default_agent/agent_state/system_config.yaml" "$TARGET/a
|
||||
```
|
||||
|
||||
A new agent starts with no skills — install only what it needs. Then write its AGENTS.md, name and description as above.
|
||||
|
||||
## The embedded agent of an SDK app
|
||||
|
||||
An app built with the penguin-sdk skill carries its own agent inside the project (`createAgent({ root })` initializes `<app>/penguin_data/default_project/agents/default_agent/` on first run). That directory has exactly the layout described here, and everything in this skill applies to it: write the app's persona into its `agent_state/AGENTS.md` (the penguin-sdk recipe keeps the source of truth in the project's `persona.md` and copies it in during ingest), and set `name`/`description` in its `system_config.yaml` so the app is recognizable. This is how "the app becomes an expert on X": the persona lives in the embedded agent's AGENTS.md, not in application code.
|
||||
@@ -3,8 +3,8 @@ name: agenthub-models
|
||||
description: Call model APIs through @prismshadow/agenthub — streaming text generation, image generation, speech synthesis and embeddings with one client.
|
||||
short_description: Call model APIs with one AgentHub client.
|
||||
short_description_zh: 用一个 AgentHub 客户端调用模型 API。
|
||||
version: 1
|
||||
updated: 2026-07-17T00:00:00Z
|
||||
version: 7
|
||||
updated: 2026-07-21T00:00:00Z
|
||||
---
|
||||
|
||||
# AgentHub Model APIs
|
||||
@@ -29,6 +29,20 @@ const client = new AutoLLMClient({ model: "<model_id>", apiKey: "<key>", baseUrl
|
||||
|
||||
If the user's message only invokes this skill (e.g. "use agenthub-models skill") without a concrete task, ask the user what they want to build. Do not write code until the requirement is clear.
|
||||
|
||||
**Important prerequisite — set the key up first, then develop.** When the script is an AI app you are building for the user, have them add the model API key in **this agent's key vault** (gear icon on its card, Agents page → settings → key vault tab) *before* you start, so the credential is in your shell environment. If the app stores its own model config, keep its Penguin data root **inside the CWD workspace** (`--root ./penguin_data`), never `~/.penguin`. Model ids can come from the penguin CLI catalog and the id table below.
|
||||
|
||||
Check for a usable API key before writing code — the client needs one for whichever provider you target:
|
||||
|
||||
```bash
|
||||
env | grep -oE "(DEEPSEEK|OPENAI|ANTHROPIC|GEMINI)_API_KEY" || echo none
|
||||
```
|
||||
|
||||
Vault keys also appear in your Vault Keys section. **Only two sources count as a usable key**: a vault-injected environment variable (the check above), or — when the app stores its own model config — a key already configured in the app's own data root (`penguin config model list --root <data_dir>`). Keys living in the global `~/.penguin` or any other `.penguin` directory do **not** count — a bare `penguin config model list` (no `--root`) reads the global store, because the CLI defaults to the global root unless `--root` is given, so a key showing up there proves nothing for your script and must never be used or copied.
|
||||
|
||||
If neither counted source yields a usable key, **stop immediately and ask the user to configure one — do not write code, and do not keep calling tools to retry**: ask them to add one in the agent's **key vault** (gear icon on the agent's card, Agents page → settings → key vault tab); vault values reach your shell environment on the next task. Re-checking the environment or the vault in a loop just wastes turns — one clear check, then hand back to the user.
|
||||
|
||||
Keep model API keys **project-local**: for an app that stores its own model config, write the key into the project under the working directory with the penguin CLI, **always passing `--root <data_dir>` for a directory inside the current working directory** (`penguin config model add --root ./penguin_data --provider <group> --model-id <id> --api-key <key>`) — without `--root` it writes to the global `~/.penguin/data` instead. `--provider` is required alongside `--model-id`: a model entry is the `(provider, model_id)` pair and the group is never inferred (use `custom` for an endpoint outside the built-in groups). Otherwise rely on vault-injected environment variables. Never read, copy or fall back to model keys stored in the user's global `~/.penguin` directory — that config belongs to the person running Penguin, not to your script.
|
||||
|
||||
## Model IDs
|
||||
|
||||
Use exact model ids. If an id is not in the table below and the user has not given one, ask the user to confirm the exact id before writing code.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: firecrawl
|
||||
description: Search the web and scrape pages into clean markdown with the Firecrawl API — query-based discovery, single-URL extraction including public PDFs, driven by curl with a vault-stored API key.
|
||||
short_description: Web search and page scraping via Firecrawl.
|
||||
short_description_zh: 用 Firecrawl 做网络搜索与页面抓取。
|
||||
version: 1
|
||||
updated: 2026-07-20T14:00:00Z
|
||||
---
|
||||
|
||||
# Firecrawl
|
||||
|
||||
Firecrawl turns the live web into agent-ready markdown over a plain REST API (`https://api.firecrawl.dev/v2`, `Authorization: Bearer $FIRECRAWL_API_KEY`). Two calls cover most web work: `/search` to discover pages by query, `/scrape` to extract clean content from a URL you already have. Use it whenever a task needs current web information or the content of a specific page.
|
||||
|
||||
## Before you start
|
||||
|
||||
If the user's message only invokes this skill (e.g. "use firecrawl skill") without a concrete task, ask what they want to search or scrape. When the task is concrete, check the credential first:
|
||||
|
||||
```bash
|
||||
[ -n "$FIRECRAWL_API_KEY" ] && echo ok || echo missing
|
||||
```
|
||||
|
||||
If missing (also visible in your Vault Keys section), ask the user to add `FIRECRAWL_API_KEY` to this agent's **key vault** — gear icon on the agent card → settings → key vault tab; keys come from the Firecrawl dashboard (https://www.firecrawl.dev/signin). Vault values reach your shell environment on the next task. Only fall back to the keyless tier (below) when the user cannot provide a key right now.
|
||||
|
||||
## Search
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://api.firecrawl.dev/v2/search \
|
||||
-H "Authorization: Bearer $FIRECRAWL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"query": "<what you are looking for>", "limit": 5}' \
|
||||
| jq '[.data.web[] | {url, title, description}]'
|
||||
```
|
||||
|
||||
- Results live in `.data.web[]`, each with `url` / `title` / `description`; `limit` defaults to 10 (per source).
|
||||
- Adding `"scrapeOptions": {"formats": ["markdown"]}` returns each result's page content inline — prefer the two-step search → scrape flow instead when only a hit or two matters; content-included search costs far more credits and context.
|
||||
- Useful filters: `"sources": [{"type": "news"}]` (or `images`), `"includeDomains": ["docs.example.com"]`, `"categories": ["github"]` (or `research` / `pdf`), `"tbs"` for time-bounded queries.
|
||||
|
||||
## Scrape
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://api.firecrawl.dev/v2/scrape \
|
||||
-H "Authorization: Bearer $FIRECRAWL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"url": "<page url>"}' \
|
||||
| jq -r '.data.markdown' > <topic>.md
|
||||
```
|
||||
|
||||
- Markdown is the default format; the page's main content only (`onlyMainContent` defaults to true). Metadata sits in `.data.metadata` (`title`, `sourceURL`, `statusCode`).
|
||||
- Public document URLs (PDF, DOCX, …) scrape to markdown the same way.
|
||||
- JS-heavy pages that come back empty: retry with `"waitFor": 2000`.
|
||||
|
||||
## Workflow and context economy
|
||||
|
||||
Search first for discovery, scrape once you have the URL. Never dump full page markdown into your context or reply: pipe it to a file (as above) and read the relevant parts with shell (`grep`/`sed`), then cite `metadata.sourceURL` for every claim you take from a page.
|
||||
|
||||
## No key available
|
||||
|
||||
The keyless free tier only works through official Firecrawl clients and is rate-limited:
|
||||
|
||||
```bash
|
||||
npx -y firecrawl-cli@latest search "<query>"
|
||||
npx -y firecrawl-cli@latest scrape <url> -o page.md
|
||||
```
|
||||
|
||||
Use it as a stopgap and tell the user to add a real key to the vault — accounts unlock the full API and higher limits.
|
||||
|
||||
## Errors
|
||||
|
||||
- `401` — missing/invalid key: re-check the vault entry name `FIRECRAWL_API_KEY`.
|
||||
- `402` / `429` — out of credits or rate-limited: report to the user; do not retry-loop.
|
||||
- Anything else: `https://docs.firecrawl.dev` is the source of truth for request/response schemas.
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.07-2.14-.22-4.05 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.15.43-2.29 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -3,8 +3,8 @@ name: penguin-cli
|
||||
description: Manage model API keys, default models and per-agent vault secrets with the penguin CLI.
|
||||
short_description: Manage models and secrets with the penguin CLI.
|
||||
short_description_zh: 用 penguin CLI 管理模型与密钥。
|
||||
version: 1
|
||||
updated: 2026-07-17T00:00:00Z
|
||||
version: 3
|
||||
updated: 2026-07-21T00:00:00Z
|
||||
---
|
||||
|
||||
# Penguin CLI
|
||||
@@ -17,20 +17,20 @@ If the user's message only invokes this skill (e.g. "use penguin-cli skill") wit
|
||||
|
||||
## Models
|
||||
|
||||
Add or update a model (upsert by the stored model id; re-run with more options to amend an entry):
|
||||
Add or update a model (upsert by the `(provider, model_id)` pair; re-run with more options to amend an entry):
|
||||
|
||||
```bash
|
||||
penguin config model add --model-id <upstream_id> [--provider <group>] [--api-key <key>] [--base-url <url>] \
|
||||
penguin config model add --provider <group> --model-id <upstream_id> [--api-key <key>] [--base-url <url>] \
|
||||
[--client-type <type>] [--context-window <n>] [--vision | --no-vision] \
|
||||
[--price-cache-read <n>] [--price-cache-write <n>] [--price-output <n>] \
|
||||
[--project-id <id>] [--root <dir>] [--set-default]
|
||||
```
|
||||
|
||||
- `--model-id` takes the provider's upstream model id (what the API expects). The stored id is always `<provider>/<upstream_id>`: `--provider` picks the provider group, and when omitted it is inferred from the built-in catalog (unrecognized ids fall back to `custom`). The upstream id is persisted automatically as the entry's request id, so nothing extra is needed for it to reach the API unchanged.
|
||||
- A model is identified by the `(provider, model_id)` pair, so `--provider` and `--model-id` are **both required** — the group is never inferred from the model id, because gateways resell vendor models under their upstream ids and a wrong guess would send the key to another vendor's endpoint. `--model-id` takes the provider's upstream model id (what the API expects) and is persisted as the entry's request id, so it reaches the API unchanged; `--provider` names the group (`deepseek`, `openai`, `anthropic`, `google`, `openrouter`, `siliconflow`, … — `custom` for any other endpoint).
|
||||
- For any OpenAI chat-completion compatible endpoint use `--client-type openai --base-url <endpoint>`; omit `--client-type` to auto-route by model id.
|
||||
- Prices are USD per million tokens (cache read / cache write / output).
|
||||
- `--vision` / `--no-vision` mark whether the model accepts images; omitting both keeps the current value (default is vision-capable).
|
||||
- All `penguin config model ...` and `penguin config vault ...` commands accept `--root <dir>` to target another data root (default `PENGUIN_HOME`, then `~/.penguin/data`).
|
||||
- All `penguin config model ...` and `penguin config vault ...` commands accept `--root <dir>` to target another data root (default `PENGUIN_HOME`, then `~/.penguin/data`). **When configuring models for an AI app you are building, always pass `--root <dir>` pointing at the app's own data directory inside the current working directory** (e.g. `--root ./penguin_data`, the same path the app gives `createAgent({ root })`); running without `--root` writes to the global `~/.penguin/data`, which belongs to the person running Penguin — not to the app.
|
||||
|
||||
Other model commands:
|
||||
|
||||
@@ -61,7 +61,7 @@ penguin config lang <en|zh> # persist the CLI language via PENGUIN_LANG in you
|
||||
|
||||
## Running agents
|
||||
|
||||
`penguin run -m "<task>" [--model-id <id>] [--agent-id <id>] [--workspace <path>] [--approve <mode>]` runs one task; `penguin chat [--resume [session_id]]` starts or resumes an interactive chat with the same options.
|
||||
`penguin run -m "<task>" [--provider <group> --model-id <id>] [--agent-id <id>] [--workspace <path>] [--approve <mode>]` runs one task; `penguin chat [--resume [session_id]]` starts or resumes an interactive chat with the same options. The model reference stays a pair here too: pass `--provider` and `--model-id` together, or neither to run on the project's default model — one without the other is rejected.
|
||||
|
||||
## Storage
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
name: penguin-sdk
|
||||
description: Build AI apps on the Penguin Harness SDK — self-contained projects inside the Workspace, model configuration, and the createSession/run streaming loop.
|
||||
short_description: Build AI apps on the Penguin Harness SDK.
|
||||
short_description_zh: 基于 Penguin Harness SDK 构建 AI 应用。
|
||||
version: 1
|
||||
updated: 2026-07-17T00:00:00Z
|
||||
description: Build AI apps on the Penguin Harness SDK — self-contained projects, the createSession/run streaming loop, and a complete RAG recipe that ingests documents into a knowledge base and answers with citations behind a web UI.
|
||||
short_description: Build AI and RAG apps on the Penguin Harness SDK.
|
||||
short_description_zh: 基于 Penguin SDK 构建 AI 与 RAG 应用。
|
||||
version: 11
|
||||
updated: 2026-07-21T00:00:00Z
|
||||
---
|
||||
|
||||
# Penguin Harness SDK
|
||||
@@ -19,48 +19,65 @@ To have an agent perform a task, use the `run_subagent` tool — the SDK is for
|
||||
|
||||
## Before you start
|
||||
|
||||
If the user's message only invokes this skill (e.g. "use penguin-sdk skill") without a concrete app to build, ask the user what they want to build. Do not start until the requirement is clear.
|
||||
If the user's message only invokes this skill (e.g. "use penguin-sdk skill") without a concrete app to build, ask the user what they want to build. But when the request names a concrete goal — even a single sentence like "build a RAG app that answers questions about these docs" — do **not** ask follow-up questions: build it end to end with the defaults in this skill (self-contained workspace project, project default model, BM25 retrieval, web UI styled per the web-design skill) and list the assumptions you made in your final reply.
|
||||
|
||||
## Project location
|
||||
|
||||
Create the app in the current workspace directory by default (the `CWD` value from your Environment section), as a self-contained project — do not place it under `<project_dir>` or depend on any path outside the project folder. Point the agent data root at a directory inside the project with `createAgent({ root })`, resolved from the source file so it stays relative:
|
||||
Create the app in the current workspace directory by default (the `CWD` value from your Environment section), as a self-contained project — do not place it under `<project_dir>` or depend on any path outside the project folder. When creating the app's agent, the data root defaults **under the working directory (CWD)** too: point `createAgent({ root })` at a directory inside the project, resolved from the source file so it stays relative:
|
||||
|
||||
```ts
|
||||
const agent = await createAgent({ root: path.resolve(import.meta.dirname, "penguin_data") });
|
||||
const agent = await createAgent({ root: path.join(import.meta.dirname, "penguin_data") });
|
||||
```
|
||||
|
||||
With every reference relative to the project, the user can move or copy the folder anywhere and it still runs.
|
||||
|
||||
## Before you build: keys and the data root
|
||||
|
||||
**Important prerequisite — set the key up first, then develop.** For AI-app development, have the user add the model API key in **this agent's key vault** (gear icon on its card, Agents page → settings → key vault tab) *before* you start building, so the credential is in your shell environment when you configure and test the app. Model ids to offer the user can come straight from the penguin CLI catalog (`penguin config model add --help`, and the agenthub-models skill's id table).
|
||||
|
||||
**The app's Penguin data root must live inside the CWD workspace — never `~/.penguin`.** Point `createAgent({ root })` and every `penguin config ... --root <dir>` at a directory under the current working directory (e.g. `./penguin_data`); the global `~/.penguin` directory belongs to the person running Penguin and must never hold the app's config or keys.
|
||||
|
||||
## Check the model first
|
||||
|
||||
Before writing any code, verify a usable model credential exists — a finished app that cannot answer is a failed delivery discovered too late:
|
||||
|
||||
```bash
|
||||
env | grep -oE "(DEEPSEEK|OPENAI|ANTHROPIC|GEMINI)_API_KEY" || echo none
|
||||
```
|
||||
|
||||
Vault keys also appear in your Vault Keys section. **Only two sources count as a usable credential**: a vault-injected environment variable (the check above), or a key already configured in the app's own data root (`penguin config model list --root <data_dir>`). Keys living in the global `~/.penguin` or any other `.penguin` directory do **not** count — a bare `penguin config model list` (no `--root`) reads the global store, because the CLI defaults to the global root unless `--root` is given, so a key showing up there proves nothing for the app and must never be used or copied.
|
||||
|
||||
If neither counted source yields a usable key, **stop immediately and ask the user to configure one — do not start building, and do not keep calling tools to retry**: ask them to open the agent's settings via the **gear icon** on its card (left side, Agents page) and add a model API key (e.g. `DEEPSEEK_API_KEY`) in the **key vault** tab — vault values reach your shell environment on the next task. Re-running `env`, re-checking the vault, or attempting the build in a loop wastes turns and money; one clear check, then hand back to the user. Build only after a credential is confirmed, or clearly agree with the user to build now and verify later.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install @prismshadow/penguin-core
|
||||
npm install @prismshadow/penguin-core tsx
|
||||
```
|
||||
|
||||
If the package is not yet available on your npm registry (it is developed in the PenguinHarness monorepo and may not be published), develop inside a checkout of the PenguinHarness repo instead: add your app as a workspace package under `packages/` and depend on `"@prismshadow/penguin-core": "workspace:*"`, then run `pnpm install && pnpm build` at the repo root. Tell the user which route you took.
|
||||
If the package is not on your npm registry (it is developed in the PenguinHarness monorepo and may not be published), develop inside a checkout of the PenguinHarness repo instead: add your app as a workspace package under `packages/`, depend on `"@prismshadow/penguin-core": "workspace:*"`, then `pnpm install && pnpm build` at the repo root. Tell the user which route you took.
|
||||
|
||||
A model must be configured for the app's data root. Two ways:
|
||||
Configure a model for the app's data root, in this order — stop at the first that works:
|
||||
|
||||
1. The penguin CLI, pointed at the project-local data directory:
|
||||
1. `penguin config model add --root <data_dir> --provider <group> --model-id <id> --api-key <key> [--base-url <url>] [--client-type openai] --set-default` — prefer `--client-type openai --base-url <endpoint>` (works with any OpenAI-compatible endpoint; exact ids in the agenthub-models skill). `--provider` is required: a model is always the `(provider, model_id)` pair and the group is never inferred from the id (`custom` for an endpoint outside the built-in groups).
|
||||
2. Environment variables cover the **credential only** (`DEEPSEEK_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, …) — model selection still comes from the project config, whose preset default is `deepseek-v4-pro`. Env-only setup therefore works out of the box only with `DEEPSEEK_API_KEY`; for another vendor either run the CLI command above or pass a configured `{ provider, modelId }` pair to `createSession`.
|
||||
|
||||
```bash
|
||||
penguin config model add --root <data_dir> --model-id <id> --api-key <key> [--base-url <url>] [--client-type openai] --set-default
|
||||
```
|
||||
Keep model API keys **project-local**: configure them with the penguin CLI into the app's own data root under the working directory, so the project stays self-contained and movable. When building an AI app, **always pass `--root <data_dir>` pointing at the app's data directory inside the current working directory** (the same path you give `createAgent({ root })`, e.g. `./penguin_data`) — never run `penguin config ...` without `--root`, or it writes to the global `~/.penguin/data` instead of the project. Never read, copy or fall back to model keys stored in the user's global `~/.penguin` directory — that config belongs to the person running Penguin, not to the app you are building.
|
||||
|
||||
Generally prefer the OpenAI protocol client (chat completion): `--client-type openai --base-url <endpoint>` works with any OpenAI-compatible endpoint. Use exact model ids — see the agenthub-models skill for the id table.
|
||||
Model config lives in one hidden file under the data root's project directory: `.project_config.toml`. It is CLI-only — never read, print or edit it.
|
||||
|
||||
2. Environment variables as a fallback: without a configured credential the SDK reads the provider's env vars (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`).
|
||||
If neither route yields a usable credential, do not fake the verification: finish the build, report it as **unverified**, and tell the user exactly how to unblock you — in the Penguin web app, open this agent's settings via the **gear icon** on its card (Agents page) and add a model API key (e.g. `DEEPSEEK_API_KEY`) under the **key vault** tab. Vault keys are injected into your shell environment on the next task, so once the user has added one, you can run the self-test to completion.
|
||||
|
||||
Model config lives in a single hidden file under the data root's project directory: `.project_config.toml` (model list, settings and per-model credentials such as `api_key` inlined in each model entry). It is CLI-only — never read, print or edit it; the CLI above manages it.
|
||||
## Streaming loop
|
||||
|
||||
## Minimal conversational app
|
||||
The raw `run()` stream mixes model, event and session-meta payloads — always narrow with the exported guards (`isModelMessage`, `isCompleteModelMessage`, `isEventMessage`) before touching `payload.type`; accessing `msg.payload.type` directly does not typecheck.
|
||||
|
||||
```ts
|
||||
import path from "node:path";
|
||||
import readline from "node:readline/promises";
|
||||
import { createAgent, userText } from "@prismshadow/penguin-core";
|
||||
import { createAgent, isModelMessage, userText } from "@prismshadow/penguin-core";
|
||||
|
||||
const agent = await createAgent({ root: path.resolve(import.meta.dirname, "penguin_data") });
|
||||
const agent = await createAgent({ root: path.join(import.meta.dirname, "penguin_data") });
|
||||
const session = await agent.createSession({ workspaceDir: process.cwd() });
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
@@ -71,18 +88,246 @@ for (;;) {
|
||||
for await (const msg of session.run([userText(line)], {
|
||||
approve: async () => "allow", // demo only — a real app should ask its user ("deny" blocks the call)
|
||||
})) {
|
||||
const p = msg.payload;
|
||||
if (p.type === "partial_text" && p.event_type === "delta") process.stdout.write(p.text);
|
||||
if (isModelMessage(msg)) {
|
||||
const p = msg.payload;
|
||||
if (p.type === "partial_text" && p.event_type === "delta") process.stdout.write(p.text);
|
||||
}
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
rl.close();
|
||||
session.dispose();
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- `createSession({ workspaceDir, provider, modelId })` — `workspaceDir` must already exist (omit for an auto temp dir); the model reference is the `(provider, modelId)` pair, so pass both to pick a configured model or neither for the project default — passing one alone throws.
|
||||
- The `approve` callback gates every tool call; **omitting it denies everything**.
|
||||
- An Agent's behavior is edited in its `agent_state/` files (system_config.yaml, AGENTS.md, skills/), not in code.
|
||||
- `createSession({ workspaceDir, modelId })` — omit `workspaceDir` for a temporary workspace, omit `modelId` for the project default model.
|
||||
- `session.run(messages, { approve, signal })` is an async generator of OmniMessages; filter the payload types you care about (`partial_text` deltas carry the streamed answer).
|
||||
- The `approve` callback gates every tool call — auto-allow is for demos; a production app should prompt its user before returning `"allow"`.
|
||||
- Call `session.run()` again on the same Session for the next user turn — the context carries over.
|
||||
- Call `session.dispose()` when done to release background processes.
|
||||
|
||||
## RAG knowledge app
|
||||
|
||||
The default recipe when the user wants an app that answers questions over a document set ("docs QA", "knowledge base", "chat with our docs", "become an expert on X"). The core contributes the agent loop only — retrieval is app code. Default to **lexical BM25**: no extra dependencies, no embedding credential, works offline. (Semantic upgrade: embed chunks via `@prismshadow/agenthub` — see the agenthub-models skill — and rank by cosine; only when an embedding-capable key is configured.)
|
||||
|
||||
```
|
||||
my-app/
|
||||
package.json # "type": "module"; scripts: ingest / start
|
||||
persona.md # the embedded agent's role — write it per the agent-creation skill
|
||||
ingest.ts # corpus/ → data/index.json; initializes penguin_data/, installs persona
|
||||
rag.ts # BM25 retrieval over the chunk index
|
||||
server.ts # POST /api/ask streams SSE; serves public/
|
||||
public/index.html # chat UI — build it per the web-design skill
|
||||
corpus/ # collected source documents
|
||||
data/index.json # generated chunk index
|
||||
penguin_data/ # agent data root (generated; model config lives here)
|
||||
```
|
||||
|
||||
**Collect** — clone or fetch the sources into `corpus/`, keeping only text formats:
|
||||
|
||||
```bash
|
||||
git clone --depth 1 <repo_url> corpus/<name> # or curl pages into corpus/
|
||||
find corpus -type f ! -regex '.*\.\(md\|mdx\|txt\|html?\)$' -delete && rm -rf corpus/*/.git
|
||||
```
|
||||
|
||||
**Ingest** (`ingest.ts`) — split on markdown headings, cap chunk size, write one JSON index; also initialize `penguin_data/`, install the persona and strip the skills the embedded agent doesn't need:
|
||||
|
||||
```ts
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createAgent } from "@prismshadow/penguin-core";
|
||||
|
||||
const ROOT = import.meta.dirname;
|
||||
const walk = (d: string): string[] =>
|
||||
fs.readdirSync(d, { withFileTypes: true }).flatMap((e) =>
|
||||
e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]);
|
||||
|
||||
const STATE = path.join(
|
||||
ROOT, "penguin_data", "default_project", "agents", "default_agent", "agent_state");
|
||||
await createAgent({ root: path.join(ROOT, "penguin_data") });
|
||||
fs.copyFileSync(path.join(ROOT, "persona.md"), path.join(STATE, "AGENTS.md"));
|
||||
// A fresh default_agent is initialized with the whole built-in Skill library, and every installed
|
||||
// Skill's metadata is injected into the system prompt of every /api/ask. This app only answers
|
||||
// from retrieved context, so remove them: unrelated skill descriptions cost tokens on each
|
||||
// question and pull the answer off-topic when one happens to match the wording of a question.
|
||||
fs.rmSync(path.join(STATE, "skills"), { recursive: true, force: true });
|
||||
|
||||
const chunks: { id: number; source: string; heading: string; text: string }[] = [];
|
||||
for (const f of walk(path.join(ROOT, "corpus")).filter((f) => /\.(md|mdx|txt|html?)$/i.test(f))) {
|
||||
const raw = fs.readFileSync(f, "utf8");
|
||||
const text = /\.html?$/i.test(f) ? raw.replace(/<[^>]+>/g, " ") : raw;
|
||||
const source = path.relative(ROOT, f);
|
||||
let heading = path.basename(f);
|
||||
for (const block of text.split(/^(?=#{1,3} )/m)) {
|
||||
heading = block.match(/^#{1,3} (.+)/)?.[1] ?? heading;
|
||||
for (let i = 0; i < block.length; i += 1500) {
|
||||
const piece = block.slice(i, i + 1500).trim();
|
||||
if (piece.length > 40) chunks.push({ id: chunks.length, source, heading, text: piece });
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(path.join(ROOT, "data"), { recursive: true });
|
||||
fs.writeFileSync(path.join(ROOT, "data", "index.json"), JSON.stringify(chunks));
|
||||
console.log(`indexed ${chunks.length} chunks`);
|
||||
```
|
||||
|
||||
**Retrieve** (`rag.ts`) — standard BM25 (k1 = 1.2, b = 0.75); the tokenizer treats each CJK character as a token so Chinese queries work. The corpus-wide statistics (per-chunk term frequencies, document frequencies, average length) never change once the corpus is indexed, so build them **once** in `loadIndex` — a per-query rescan would make every question O(corpus):
|
||||
|
||||
```ts
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface Chunk { id: number; source: string; heading: string; text: string }
|
||||
export interface Index {
|
||||
chunks: Chunk[];
|
||||
tf: Map<string, number>[]; // per-chunk term → count
|
||||
len: number[]; // per-chunk token length
|
||||
df: Map<string, number>; // term → number of chunks containing it
|
||||
avg: number; // mean chunk length (BM25 length normalization)
|
||||
}
|
||||
|
||||
const tokenize = (s: string): string[] => s.toLowerCase().match(/[a-z0-9]+|[一-鿿]/g) ?? [];
|
||||
|
||||
export function loadIndex(): Index {
|
||||
const chunks: Chunk[] = JSON.parse(
|
||||
fs.readFileSync(path.join(import.meta.dirname, "data", "index.json"), "utf8"));
|
||||
const tf: Map<string, number>[] = [];
|
||||
const len: number[] = [];
|
||||
const df = new Map<string, number>();
|
||||
for (const c of chunks) {
|
||||
const toks = tokenize(`${c.heading} ${c.text}`);
|
||||
const m = new Map<string, number>();
|
||||
for (const t of toks) m.set(t, (m.get(t) ?? 0) + 1);
|
||||
for (const t of m.keys()) df.set(t, (df.get(t) ?? 0) + 1);
|
||||
tf.push(m);
|
||||
len.push(toks.length);
|
||||
}
|
||||
const avg = len.reduce((n, l) => n + l, 0) / Math.max(len.length, 1);
|
||||
return { chunks, tf, len, df, avg };
|
||||
}
|
||||
|
||||
export function search(index: Index, query: string, k = 6): Chunk[] {
|
||||
const { chunks, tf, len, df, avg } = index;
|
||||
const q = [...new Set(tokenize(query))];
|
||||
const score = (i: number): number => {
|
||||
let s = 0;
|
||||
for (const t of q) {
|
||||
const f = tf[i]!.get(t) ?? 0;
|
||||
if (f === 0) continue;
|
||||
const n = df.get(t) ?? 0;
|
||||
s += Math.log(1 + (chunks.length - n + 0.5) / (n + 0.5)) *
|
||||
(f * 2.2) / (f + 1.2 * (0.25 + (0.75 * len[i]!) / avg));
|
||||
}
|
||||
return s;
|
||||
};
|
||||
return chunks.map((_, i) => [score(i), i] as const)
|
||||
.filter(([s]) => s > 0).sort((a, b) => b[0] - a[0]).slice(0, k)
|
||||
.map(([, i]) => chunks[i]!);
|
||||
}
|
||||
```
|
||||
|
||||
**Answer & serve** (`server.ts`) — one Session per request (stateless QA), retrieved chunks numbered into the prompt, deltas streamed over SSE, sources sent as the final event. A pure QA session needs no tool calls — deny every approval; a denied or tool-less turn terminates normally. Do **not** clear the toolset with `tools: { builtin: [] }`: an empty tools array is sent to the provider verbatim and some OpenAI-compatible endpoints reject it with a 400, which surfaces as a silent empty answer. Guard the request boundary — a malformed body must return 400, never reject the async handler (an unhandled rejection takes the whole server down) — and abort the run if the client disconnects mid-answer so you stop generating (and paying) for a page nobody is reading.
|
||||
|
||||
```ts
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { createAgent, isModelMessage, userText } from "@prismshadow/penguin-core";
|
||||
import { loadIndex, search } from "./rag.ts";
|
||||
|
||||
const ROOT = import.meta.dirname;
|
||||
const PUB = path.join(ROOT, "public");
|
||||
const agent = await createAgent({ root: path.join(ROOT, "penguin_data") });
|
||||
const index = loadIndex();
|
||||
const MIME: Record<string, string> = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript" };
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
res.on("error", () => {}); // a client that vanishes mid-write must not throw an uncaught EPIPE
|
||||
if (req.method === "POST" && req.url === "/api/ask") {
|
||||
let question: string;
|
||||
try {
|
||||
let body = "";
|
||||
for await (const part of req) body += part; // a mid-body connection reset rejects here — caught below, never fatal
|
||||
const parsed = JSON.parse(body) as { question?: unknown };
|
||||
if (typeof parsed.question !== "string" || !parsed.question.trim()) throw new Error();
|
||||
question = parsed.question;
|
||||
} catch {
|
||||
res.writeHead(400, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "expected a JSON body { question: string }" }));
|
||||
return;
|
||||
}
|
||||
const hits = search(index, question);
|
||||
const context = hits.map((c, i) => `[${i + 1}] ${c.source} — ${c.heading}\n${c.text}`).join("\n\n");
|
||||
const ac = new AbortController();
|
||||
res.on("close", () => ac.abort()); // client navigated away → cancel the in-flight generation
|
||||
// Create the Session BEFORE committing headers: a model-config failure then returns a real
|
||||
// HTTP error instead of an unhandled rejection with a 200 already on the wire.
|
||||
let session;
|
||||
try {
|
||||
session = await agent.createSession({ workspaceDir: ROOT });
|
||||
} catch {
|
||||
res.writeHead(503, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "no model configured yet — see the setup steps" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
||||
try {
|
||||
const prompt = `Answer from the context below; cite blocks inline as [1][2]. If the context is not enough, say so.\n\n${context}\n\nQuestion: ${question}`;
|
||||
for await (const msg of session.run([userText(prompt)], { approve: async () => "deny", signal: ac.signal })) {
|
||||
if (isModelMessage(msg)) {
|
||||
const p = msg.payload;
|
||||
if (p.type === "partial_text" && p.event_type === "delta" && !res.writableEnded)
|
||||
res.write(`data: ${JSON.stringify({ delta: p.text })}\n\n`);
|
||||
}
|
||||
}
|
||||
// Sources carry the matched chunk text verbatim: the UI must be able to show the exact
|
||||
// block behind each [n], not just a file link.
|
||||
if (!res.writableEnded)
|
||||
res.write(`data: ${JSON.stringify({ sources: hits.map((c) => ({ source: c.source, heading: c.heading, url: `/${c.source}`, text: c.text })) })}\n\n`);
|
||||
} catch {
|
||||
// The run failed after headers were sent, or the client left: surface an error event (best effort), then clean up.
|
||||
if (!res.writableEnded) res.write(`data: ${JSON.stringify({ error: "generation failed" })}\n\n`);
|
||||
} finally {
|
||||
session.dispose();
|
||||
if (!res.writableEnded) res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const pathname = (req.url ?? "/").split("?")[0] ?? "/";
|
||||
// /corpus/* serves the source documents read-only, so citation links resolve to real files.
|
||||
const inCorpus = pathname.startsWith("/corpus/");
|
||||
const base = inCorpus ? path.join(ROOT, "corpus") : PUB;
|
||||
const rel = inCorpus
|
||||
? pathname.slice("/corpus/".length)
|
||||
: pathname === "/"
|
||||
? "index.html"
|
||||
: pathname.slice(1);
|
||||
const file = path.normalize(path.join(base, rel));
|
||||
if (file.startsWith(base + path.sep) && fs.existsSync(file) && fs.statSync(file).isFile()) {
|
||||
res.writeHead(200, { "content-type": MIME[path.extname(file)] ?? "text/plain" });
|
||||
res.end(fs.readFileSync(file));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
}).listen(Number(process.env.PORT ?? 4630), () => console.log("http://localhost:4630"));
|
||||
```
|
||||
|
||||
**UI** (`public/index.html`) — a chat interface built per the web-design skill: message list, streamed assistant text appended delta by delta, the final `sources` event rendered as citation chips, an empty state inviting the first question with **3–4 example questions the corpus can actually answer** (pill chips; clicking one submits it), and a visible error state when `/api/ask` fails. Citations must satisfy both of these, never bare text:
|
||||
|
||||
- **Reveal the original chunk**: clicking a citation chip (or an inline `[n]`) opens a popover/panel showing the matched chunk's `text` from the sources event **verbatim** — the numbering maps 1:1 to the context blocks in the prompt, so `[n]` always reveals exactly the block the answer drew on.
|
||||
- **Link to the real document**: inside the popover, `<a href="<url>" target="_blank">` using the `url` field (`/corpus/<path>`, which this server serves) — clicking the chip itself opens the popover, the document link lives within it. When the corpus was cloned from a public repository, prefer mapping the path to the canonical upstream page instead (e.g. the GitHub blob URL derived from the clone URL).
|
||||
|
||||
**Persona** (`persona.md`) — the embedded agent's role, written per the agent-creation skill. Shape: one role sentence ("You are an expert on X; you answer strictly from the provided context blocks"), citation and refusal rules, answer language follows the question.
|
||||
|
||||
## Verify before you hand over
|
||||
|
||||
Never declare the app done without running it:
|
||||
|
||||
1. `npm install` succeeds (or the workspace route builds).
|
||||
2. Model configured for `penguin_data` (CLI or env var; no usable key → see Setup: ask the user to add one to this agent's key vault, and report the app as unverified for now).
|
||||
3. `npm run ingest` prints `indexed N chunks` with N > 0.
|
||||
4. Start `npm start` in the background, then ask a real question:
|
||||
`curl -N -sS -X POST localhost:4630/api/ask -H 'content-type: application/json' -d '{"question":"<something the corpus answers>"}'` — expect streamed `data:` deltas ending in a `sources` event that carries `source`, `url` **and the matched chunk `text`** per hit. If nothing streams, the model call failed: re-check step 2 and the provider endpoint before touching the code.
|
||||
Then `curl` one of the returned source `url`s — it must return the document, not a 404 (citation links have to resolve).
|
||||
5. Open the UI (or screenshot it) to confirm the layout renders.
|
||||
|
||||
Fix any failure and re-verify. Report with backtick-wrapped relative paths (`server.ts`, `public/index.html`, …), how to start the app, and the assumptions you made.
|
||||
@@ -1,40 +1,98 @@
|
||||
---
|
||||
name: web-design
|
||||
description: Default visual language for generated web pages — minimal black-white-gray, square corners, hierarchy from weight and spacing; colors, radii and gradients only on explicit request.
|
||||
short_description: Minimal monochrome defaults for generated web pages.
|
||||
short_description_zh: 生成网页的极简黑白默认视觉规范。
|
||||
version: 1
|
||||
updated: 2026-07-17T00:00:00Z
|
||||
description: Penguin visual language for generated web pages and app UIs — GitHub-style simplicity with a single blue accent, light and pure-black dark themes, design tokens, and component and chat-interface recipes.
|
||||
short_description: Penguin-style visual defaults for generated web UIs.
|
||||
short_description_zh: 生成网页的 Penguin 风格视觉规范。
|
||||
version: 3
|
||||
updated: 2026-07-20T15:00:00Z
|
||||
---
|
||||
|
||||
# Web Design
|
||||
|
||||
Default visual rules for every web page or frontend interface you generate. Apply them to any HTML/CSS you produce unless the user explicitly asks otherwise.
|
||||
Default visual language for every web page or frontend you generate, distilled from the Penguin Harness landing page and web app. The idea is **GitHub-style simplicity**: solid backgrounds, 1px borders instead of shadows, system fonts, one blue accent used sparingly. Depth comes from hairline borders, not shadows or gradients; dark mode is pure black, not navy. Apply these defaults unless the user explicitly asks for another style.
|
||||
|
||||
## Before you start
|
||||
|
||||
If the user's message only invokes this skill (e.g. "use web-design skill") without a concrete page or interface to build, ask the user what they want to build. Do not start until the requirement is clear.
|
||||
If the user's message only invokes this skill (e.g. "use web-design skill") without a concrete page or interface to build, ask what they want to build. When a concrete build is already requested (an app UI, a landing page, a RAG chat interface), do **not** ask about styling — apply the defaults below.
|
||||
|
||||
## Core rules
|
||||
Non-negotiable for ANY text input that sends on Enter: **never send while an IME composition is in progress** (check `event.isComposing`, falling back to `event.keyCode === 229`, on keydown). For Chinese/Japanese/Korean input methods, that Enter only confirms the composed text — auto-sending on it fires half-typed messages. Details in the composer recipe below.
|
||||
|
||||
- Monochrome only: black, white and grays. No accent colors, no gradients, no decorative shadows.
|
||||
- Square corners everywhere: `border-radius: 0` on buttons, cards, inputs, images and modals.
|
||||
- Hierarchy comes from font weight, font size, spacing and thin light-gray borders — never from colored blocks or backgrounds.
|
||||
- Generous whitespace: prefer more spacing over more dividers; let sections breathe.
|
||||
- Introduce colors, rounded corners or gradients **only when the user explicitly asks for them**, and only where asked — the rest of the page stays monochrome and square.
|
||||
|
||||
## Tokens
|
||||
|
||||
Base every stylesheet on a small monochrome token set:
|
||||
## Design tokens
|
||||
|
||||
```css
|
||||
:root {
|
||||
--fg: #111111; /* primary text */
|
||||
--fg-muted: #666666; /* secondary text */
|
||||
--bg: #ffffff; /* page background */
|
||||
--bg-subtle: #f5f5f5; /* raised surfaces */
|
||||
--border: #e2e2e2; /* hairline borders (1px) */
|
||||
color-scheme: light;
|
||||
/* Brand blue — the only accent family. Use sparingly: links, eyebrows, tiny dots, tints. */
|
||||
--brand-50: #e8f0fe; --brand-100: #d2e3fc; --brand-300: #8ab4f8; --brand-500: #4285f4;
|
||||
--brand-600: #1a73e8; /* accent text/icons in light mode */ --brand-700: #0b57d0; /* links on white */
|
||||
/* Neutrals (Tailwind gray) */
|
||||
--gray-50: #f9fafb; --gray-100: #f3f4f6; --gray-200: #e5e7eb; --gray-300: #d1d5db;
|
||||
--gray-400: #9ca3af; --gray-500: #6b7280; --gray-600: #4b5563; --gray-900: #111827;
|
||||
--bg: #ffffff; --surface: #ffffff; --border: var(--gray-200); --control-border: var(--gray-300);
|
||||
--fg: var(--gray-900); --fg-muted: var(--gray-600); --fg-faint: var(--gray-500);
|
||||
--accent-bg: #111827; --accent-fg: #ffffff; /* primary buttons are near-black, not blue */
|
||||
--radius-control: 6px; /* buttons, inputs, chips */ --radius-card: 12px; /* cards, panels */
|
||||
--ease: cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
.dark {
|
||||
color-scheme: dark; /* pure black, no blue tint */
|
||||
--bg: #000000; --surface: #0d0d0d; --border: #1f1f1f; --control-border: #303030;
|
||||
--fg: #f3f4f6; --fg-muted: #9ca3af; --fg-faint: #6b7280;
|
||||
--accent-bg: #f3f4f6; --accent-fg: #111827; /* primary button inverts to light */
|
||||
--brand-600: #8ab4f8; --brand-700: #8ab4f8; /* brand text flips to the 300 tone */
|
||||
}
|
||||
```
|
||||
|
||||
Tailwind equivalent: stick to `text-neutral-900` / `text-neutral-500` / `bg-white` / `bg-neutral-100` / `border-neutral-200` / `rounded-none`; do not use color utilities (`blue-*`, `emerald-*`, ...), `rounded-*` variants other than `rounded-none`, or `bg-gradient-*`.
|
||||
- Toggle dark mode with a `dark` class on `<html>` (persist the choice; default to `prefers-color-scheme`).
|
||||
- Primary buttons are **neutral black/white**, never blue fills. Brand blue is reserved for accents: links, section eyebrows, small status dots, `--brand-50` tinted chips.
|
||||
- Pills, badges and dots use `border-radius: 9999px`; everything else uses the two radii above. No gradients or elevation shadows on content (modals excepted; flat focus rings drawn with `box-shadow` are fine).
|
||||
|
||||
## Typography
|
||||
|
||||
System fonts only — no CDN fonts, no @font-face. The CJK entries matter (bilingual product):
|
||||
|
||||
```css
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
"PingFang SC", "Microsoft YaHei", sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
code, pre, kbd { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
```
|
||||
|
||||
Headings are always `font-weight: 600` with `letter-spacing: -0.025em` — nothing heavier, no thin weights. Scale: page/hero title 30–36px; section title 24–30px; card title 16–18px; body 14px / line-height 1.5; captions 12px in `--fg-faint`; code 13px / line-height 1.85. Hierarchy comes from weight, size, spacing and hairlines — never from colored blocks.
|
||||
|
||||
## Language
|
||||
|
||||
Write the page's UI copy in the language the user's request was made in — a Chinese request gets a Chinese interface, an English request an English one — and set `<html lang>` to match. Code identifiers, CSS class names and code comments stay English.
|
||||
|
||||
## Components
|
||||
|
||||
- **Primary button** — `background: var(--accent-bg); color: var(--accent-fg); border-radius: 6px; padding: 6px 12px; font-size: 14px; font-weight: 500;` hover: `opacity: .9`. Large CTA variant: height 44px, radius 8px, padding 0 20px.
|
||||
- **Secondary button** — white/`--surface` bg, `1px solid var(--control-border)`, same paddings; hover swaps bg to `--gray-100`/dark `#1f1f1f`.
|
||||
- **Card** — `border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 24px;` hover changes **only the border color** (one step darker) — no lift, shadow or scale.
|
||||
- **Input / textarea** — `border: 1px solid var(--control-border); border-radius: 6px; padding: 8px 12px;` focus: border one step darker + `box-shadow: 0 0 0 2px rgb(156 163 175 / .3)`; no default outline stacking.
|
||||
- **Pill chip** — `border-radius: 9999px; border: 1px solid var(--border); padding: 4px 10px; font-size: 12px; color: var(--fg-muted);` hover: bg `--gray-50`. Brand-tinted variant: `--brand-50` bg, `--brand-700` text.
|
||||
- **Sticky nav** — `height: 56px; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(8px);` logo 28px + product name 15px semibold.
|
||||
- **Code block** — `--gray-50`/dark `--surface` bg card with a bordered header row (mono 12px label + copy button), body mono 13px.
|
||||
- **Focus** — `:focus-visible { outline: 3px solid rgb(107 114 128 / .4); outline-offset: 2px; }`.
|
||||
|
||||
## Motion
|
||||
|
||||
One easing everywhere: `var(--ease)`, durations 120–280ms. Entrances rise in (`translateY(10px)` + fade, 280ms, stagger siblings by 40ms); overlays fade (120ms); hover feedback is **color-only** (`transition: color, background-color, border-color 150ms`) — never transform on hover. Always include:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
||||
```
|
||||
|
||||
## Chat / RAG app layout
|
||||
|
||||
The default shape for a generated conversational or docs-QA app:
|
||||
|
||||
- **Shell** — centered column, `max-width: 48rem`, `padding: 0 16px`; sticky nav on top with the app name; message list grows, composer pinned at the bottom.
|
||||
- **Empty state** — vertically centered title + one-line subtitle in `--fg-muted`, over an optional dot-grid backdrop (`background-image: radial-gradient(rgb(26 115 232 / .14) 1px, transparent 1px); background-size: 22px 22px;` faded out with a bottom mask) — the only decorative flourish allowed. Below it, a wrapped row of 3–4 example-question pill chips the app can genuinely answer; clicking one fills and submits the composer.
|
||||
- **Messages** — user messages right-aligned in a `--gray-100`/dark `#1f1f1f` rounded bubble (radius 12px, padding 8px 14px, max-width 85%); assistant messages plain on the page background, no bubble. Stream deltas into the assistant message as they arrive with a 1-character pulsing cursor; render markdown.
|
||||
- **Citations** — after an answer, a wrapped row of pill chips: `[1] path — heading`, brand-tinted variant. Clicking a chip (or an inline `[n]` in the answer) opens a popover/panel showing the **verbatim original text block** the citation refers to, with a link to open the full source document; a citation that is only a label or only a link is not enough.
|
||||
- **Composer** — a bordered card (radius 12px) with a borderless textarea inside and a small primary send button bottom-right; Enter sends, Shift+Enter for newline; disable while streaming. **Never send while an IME composition is in progress**: on keydown, ignore Enter when `event.isComposing` (or `event.keyCode === 229`) — for CJK input methods that Enter only confirms the composed text, and auto-sending on it fires half-typed messages.
|
||||
- **States** — loading: three pulsing dots in `--fg-faint`; error: 13px `#b91c1c` text on `#fef2f2` (dark: `#f87171` on `#450a0a`) in a rounded box with a retry affordance. Never leave a silent failure.
|
||||
|
||||
## Page layout (marketing / landing)
|
||||
|
||||
Content width `max-width: 72rem`, gutters 16–24px; section rhythm `padding: 64–96px 0`; section header = uppercase 14px semibold brand-colored eyebrow → title → one-line subtitle in `--fg-muted`, then a card grid (`gap: 20px`, 2–3 columns, collapsing to one on mobile). Hero: centered, logo + name, headline with at most one brand-colored word, CTA pair (primary + secondary). Responsive by default: single column under 640px, tap targets ≥ 40px, no horizontal scroll.
|
||||
@@ -139,34 +139,28 @@ export function loadLibrarySkills(): LibrarySkill[] {
|
||||
*/
|
||||
export const SKILL_GROUPS: SkillGroupInfo[] = [
|
||||
{
|
||||
id: "agent-development",
|
||||
title: "Agent Development",
|
||||
titleZh: "Agent 开发",
|
||||
skills: ["agent-creation", "benchmark-design", "agent-evaluation", "agent-optimization"],
|
||||
id: "office-productivity",
|
||||
title: "Office Productivity",
|
||||
titleZh: "办公效率",
|
||||
skills: ["data-analysis", "firecrawl"],
|
||||
},
|
||||
{
|
||||
id: "data-analysis",
|
||||
title: "Data Analysis",
|
||||
titleZh: "数据分析",
|
||||
skills: ["data-analysis"],
|
||||
id: "software-development",
|
||||
title: "Software Development",
|
||||
titleZh: "软件开发",
|
||||
skills: ["web-design", "software-engineering"],
|
||||
},
|
||||
{
|
||||
id: "penguin-development",
|
||||
title: "Penguin Development",
|
||||
titleZh: "Penguin 开发",
|
||||
id: "ai-app-development",
|
||||
title: "AI App Development",
|
||||
titleZh: "AI 应用开发",
|
||||
skills: ["penguin-sdk", "penguin-cli", "agenthub-models"],
|
||||
},
|
||||
{
|
||||
id: "web-development",
|
||||
title: "Web Development",
|
||||
titleZh: "网页开发",
|
||||
skills: ["web-design"],
|
||||
},
|
||||
{
|
||||
id: "software-engineering",
|
||||
title: "Software Engineering",
|
||||
titleZh: "软件工程",
|
||||
skills: ["software-engineering"],
|
||||
id: "agent-tuning",
|
||||
title: "Agent Tuning",
|
||||
titleZh: "Agent 调优",
|
||||
skills: ["agent-creation", "benchmark-design", "agent-evaluation", "agent-optimization"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -40,8 +40,9 @@ describe("loadLibrarySkills", () => {
|
||||
expect(skill.shortDescriptionZh, skill.name).toBeTruthy();
|
||||
expect(skill.shortDescription!.length, skill.name).toBeLessThan(skill.description.length);
|
||||
expect(skill.shortDescriptionZh!.length, skill.name).toBeLessThan(skill.description.length);
|
||||
// Pre-release, version is always 1.
|
||||
expect(skill.version).toBe(1);
|
||||
// version is a natural number, bumped on every content change (updated moves with it).
|
||||
expect(Number.isInteger(skill.version), skill.name).toBe(true);
|
||||
expect(skill.version, skill.name).toBeGreaterThanOrEqual(1);
|
||||
expect(skill.updated).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/);
|
||||
// content is the full SKILL.md text including frontmatter (written as-is on install).
|
||||
expect(skill.content.startsWith("---\n")).toBe(true);
|
||||
@@ -88,32 +89,32 @@ describe("loadSkillGroups / groupSkills", () => {
|
||||
it("loads groups per SKILL_GROUPS, members complete with Chinese titles, no Other group", () => {
|
||||
const groups = loadSkillGroups();
|
||||
expect(groups.map((g) => g.id)).toEqual([
|
||||
"agent-development",
|
||||
"data-analysis",
|
||||
"penguin-development",
|
||||
"web-development",
|
||||
"software-engineering",
|
||||
"office-productivity",
|
||||
"software-development",
|
||||
"ai-app-development",
|
||||
"agent-tuning",
|
||||
]);
|
||||
expect(groups[0]!.skills.map((s) => s.name)).toEqual([
|
||||
"agent-creation",
|
||||
"benchmark-design",
|
||||
"agent-evaluation",
|
||||
"agent-optimization",
|
||||
]);
|
||||
expect(groups[1]!.skills.map((s) => s.name)).toEqual(["data-analysis"]);
|
||||
expect(groups[1]!.title).toBe("Data Analysis");
|
||||
expect(groups[1]!.titleZh).toBe("数据分析");
|
||||
expect(groups[0]!.skills.map((s) => s.name)).toEqual(["data-analysis", "firecrawl"]);
|
||||
expect(groups[0]!.title).toBe("Office Productivity");
|
||||
expect(groups[0]!.titleZh).toBe("办公效率");
|
||||
expect(groups[1]!.skills.map((s) => s.name)).toEqual(["web-design", "software-engineering"]);
|
||||
expect(groups[1]!.title).toBe("Software Development");
|
||||
expect(groups[1]!.titleZh).toBe("软件开发");
|
||||
expect(groups[2]!.skills.map((s) => s.name)).toEqual([
|
||||
"penguin-sdk",
|
||||
"penguin-cli",
|
||||
"agenthub-models",
|
||||
]);
|
||||
expect(groups[3]!.skills.map((s) => s.name)).toEqual(["web-design"]);
|
||||
expect(groups[3]!.title).toBe("Web Development");
|
||||
expect(groups[3]!.titleZh).toBe("网页开发");
|
||||
expect(groups[4]!.skills.map((s) => s.name)).toEqual(["software-engineering"]);
|
||||
expect(groups[4]!.title).toBe("Software Engineering");
|
||||
expect(groups[4]!.titleZh).toBe("软件工程");
|
||||
expect(groups[2]!.title).toBe("AI App Development");
|
||||
expect(groups[2]!.titleZh).toBe("AI 应用开发");
|
||||
expect(groups[3]!.skills.map((s) => s.name)).toEqual([
|
||||
"agent-creation",
|
||||
"benchmark-design",
|
||||
"agent-evaluation",
|
||||
"agent-optimization",
|
||||
]);
|
||||
expect(groups[3]!.title).toBe("Agent Tuning");
|
||||
expect(groups[3]!.titleZh).toBe("Agent 调优");
|
||||
for (const group of groups) {
|
||||
expect(group.title).toBeTruthy();
|
||||
expect(group.titleZh).toBeTruthy();
|
||||
@@ -126,14 +127,13 @@ describe("loadSkillGroups / groupSkills", () => {
|
||||
const stray = fakeSkill("stray-skill");
|
||||
const groups = groupSkills([fakeSkill("agent-creation"), stray]);
|
||||
expect(groups.map((g) => g.id)).toEqual([
|
||||
"agent-development",
|
||||
"data-analysis",
|
||||
"penguin-development",
|
||||
"web-development",
|
||||
"software-engineering",
|
||||
"office-productivity",
|
||||
"software-development",
|
||||
"ai-app-development",
|
||||
"agent-tuning",
|
||||
"other",
|
||||
]);
|
||||
const other = groups[5]!;
|
||||
const other = groups[4]!;
|
||||
expect(other.title).toBe("Other");
|
||||
expect(other.titleZh).toBe("其他");
|
||||
expect(other.skills).toEqual([stray]);
|
||||
@@ -142,29 +142,29 @@ describe("loadSkillGroups / groupSkills", () => {
|
||||
it("groupSkills: missing members are skipped; no Other group when all are grouped", () => {
|
||||
const groups = groupSkills([fakeSkill("penguin-cli")]);
|
||||
expect(groups.map((g) => g.id)).toEqual([
|
||||
"agent-development",
|
||||
"data-analysis",
|
||||
"penguin-development",
|
||||
"web-development",
|
||||
"software-engineering",
|
||||
"office-productivity",
|
||||
"software-development",
|
||||
"ai-app-development",
|
||||
"agent-tuning",
|
||||
]);
|
||||
expect(groups[0]!.skills).toEqual([]);
|
||||
expect(groups[1]!.skills).toEqual([]);
|
||||
expect(groups[2]!.skills.map((s) => s.name)).toEqual(["penguin-cli"]);
|
||||
expect(groups[3]!.skills).toEqual([]);
|
||||
expect(groups[4]!.skills).toEqual([]);
|
||||
});
|
||||
|
||||
it("SKILL_GROUPS hardcodes member names (sole group info source outside library files)", () => {
|
||||
expect(SKILL_GROUPS.map((g) => ({ id: g.id, skills: g.skills }))).toEqual([
|
||||
{ id: "office-productivity", skills: ["data-analysis", "firecrawl"] },
|
||||
{ id: "software-development", skills: ["web-design", "software-engineering"] },
|
||||
{
|
||||
id: "agent-development",
|
||||
id: "ai-app-development",
|
||||
skills: ["penguin-sdk", "penguin-cli", "agenthub-models"],
|
||||
},
|
||||
{
|
||||
id: "agent-tuning",
|
||||
skills: ["agent-creation", "benchmark-design", "agent-evaluation", "agent-optimization"],
|
||||
},
|
||||
{ id: "data-analysis", skills: ["data-analysis"] },
|
||||
{ id: "penguin-development", skills: ["penguin-sdk", "penguin-cli", "agenthub-models"] },
|
||||
{ id: "web-development", skills: ["web-design"] },
|
||||
{ id: "software-engineering", skills: ["software-engineering"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user