Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
/**
|
||||
* Capture real product screenshots for the landing page.
|
||||
*
|
||||
* Flow: host a scripted mock LLM (speaks BOTH Anthropic SSE and OpenAI chat-completions
|
||||
* SSE, so whichever client AgentHub routes to gets a valid stream) -> start the Web
|
||||
* server against a temp data root serving the built web dist -> drive a genuine
|
||||
* "build an Agent app" conversation (tools actually execute in the workspace) ->
|
||||
* screenshot chat / trace view / evaluation center, per UI language (zh / en, each
|
||||
* with its own user so sidebars stay monolingual) and per theme (light / dark), into
|
||||
* src/assets/shots/ as <page>-<lang>-<theme>.webp (12 files, re-encoded to WebP
|
||||
* inside Chromium to keep the repo small).
|
||||
*
|
||||
* Prereqs: `pnpm --filter @prismshadow/penguin-{skills,core,server,web} build` and
|
||||
* Playwright's chromium. Run: `node scripts/capture-shots.mjs`.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } 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(HERE, "../src/assets/shots");
|
||||
const MOCK_PORT = 8941;
|
||||
const SRV_PORT = 8940;
|
||||
const BASE = `http://127.0.0.1:${SRV_PORT}`;
|
||||
const MOCK = `http://127.0.0.1:${MOCK_PORT}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scripted conversation: the Agent builds an Agent application from scratch.
|
||||
// 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'
|
||||
{
|
||||
"name": "csv-analyst",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "start": "tsx src/agent.ts" },
|
||||
"dependencies": { "@prismshadow/penguin-core": "^0.1.0" }
|
||||
}
|
||||
EOF
|
||||
ls -R csv-analyst`;
|
||||
|
||||
const CMD_ENTRY = `cat > csv-analyst/src/agent.ts <<'EOF'
|
||||
import { createAgent, isCompleteModelMessage, userText } from "@prismshadow/penguin-core";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
EOF
|
||||
wc -l csv-analyst/src/agent.ts`;
|
||||
|
||||
const TREE = `\`\`\`text
|
||||
csv-analyst/
|
||||
├── package.json
|
||||
└── src/
|
||||
└── agent.ts
|
||||
\`\`\``;
|
||||
|
||||
/** Per-language script: user prompt marker -> turns + session title. */
|
||||
const SCRIPTS = {
|
||||
zh: {
|
||||
marker: "数据分析 Agent 应用",
|
||||
prompt: "用 PenguinHarness SDK 创建一个数据分析 Agent 应用:读取 CSV 并输出汇总报告",
|
||||
title: "构建数据分析 Agent 应用",
|
||||
turns: [
|
||||
{
|
||||
thinking:
|
||||
"需求是基于 penguin-core 的数据分析 Agent 应用。先创建项目骨架:package.json 与源码目录。",
|
||||
text: "我来创建应用骨架:",
|
||||
cmd: CMD_SCAFFOLD,
|
||||
},
|
||||
{
|
||||
thinking:
|
||||
"骨架已建好。入口代码用 createAgent + createSession,把 CSV 分析任务交给 session.run。",
|
||||
text: "骨架就绪,写入 Agent 入口代码:创建 Session,把 CSV 分析任务交给 session.run 并流式输出。",
|
||||
cmd: CMD_ENTRY,
|
||||
},
|
||||
{
|
||||
text: `数据分析 Agent 应用已创建完成:
|
||||
|
||||
${TREE}
|
||||
|
||||
- 入口 \`csv-analyst/src/agent.ts\`:创建 Agent 与 Session,任务经 \`session.run\` 流式执行,工具调用逐个审批;
|
||||
- 运行方式:\`cd csv-analyst && npm install && npm start\`;
|
||||
- 建议下一步:在评估中心为它配一组 CSV 任务 Benchmark,交给 Optimizer 持续优化。`,
|
||||
},
|
||||
],
|
||||
},
|
||||
en: {
|
||||
marker: "data-analysis Agent app",
|
||||
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",
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
text: `The data-analysis Agent app 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.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function scriptFor(flat) {
|
||||
return flat.includes(SCRIPTS.en.marker) ? SCRIPTS.en : SCRIPTS.zh;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock LLM: Anthropic SSE on */messages, OpenAI chunks on */chat/completions.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sse(res, event, data) {
|
||||
res.write(`event: ${event}\n`);
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
function anthropicReply(res, body) {
|
||||
const flat = JSON.stringify(body.messages ?? []);
|
||||
const script = scriptFor(flat);
|
||||
const isTitle = flat.includes("concise title");
|
||||
const toolResults = flat.split('"tool_result"').length - 1;
|
||||
const turn = script.turns[Math.min(toolResults, script.turns.length - 1)];
|
||||
const msgCount = (body.messages ?? []).length;
|
||||
|
||||
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
||||
sse(res, "message_start", {
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: `msg_shot_${Date.now()}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "deepseek-v4-pro",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 380,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 2400 * msgCount,
|
||||
cache_creation_input_tokens: 620,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const block = (index, start, deltas, extra) => {
|
||||
sse(res, "content_block_start", { type: "content_block_start", index, content_block: start });
|
||||
for (const d of deltas)
|
||||
sse(res, "content_block_delta", { type: "content_block_delta", index, delta: d });
|
||||
if (extra)
|
||||
sse(res, "content_block_delta", { type: "content_block_delta", index, delta: extra });
|
||||
sse(res, "content_block_stop", { type: "content_block_stop", index });
|
||||
};
|
||||
const finish = (stopReason, outputTokens) => {
|
||||
sse(res, "message_delta", {
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
||||
usage: { output_tokens: outputTokens },
|
||||
});
|
||||
sse(res, "message_stop", { type: "message_stop" });
|
||||
res.end();
|
||||
};
|
||||
const textDeltas = (text) =>
|
||||
(text.match(/[\s\S]{1,24}/g) ?? []).map((t) => ({ type: "text_delta", text: t }));
|
||||
|
||||
if (isTitle) {
|
||||
block(0, { type: "text", text: "" }, [{ type: "text_delta", text: script.title }]);
|
||||
finish("end_turn", 8);
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
if (turn.thinking) {
|
||||
block(
|
||||
index++,
|
||||
{ type: "thinking", thinking: "" },
|
||||
turn.thinking.match(/[\s\S]{1,18}/g).map((t) => ({ type: "thinking_delta", thinking: t })),
|
||||
{ type: "signature_delta", signature: "sig_shot" },
|
||||
);
|
||||
}
|
||||
if (turn.text) block(index++, { type: "text", text: "" }, textDeltas(turn.text));
|
||||
if (turn.cmd) {
|
||||
const json = JSON.stringify({ cmd: turn.cmd });
|
||||
block(
|
||||
index++,
|
||||
{ type: "tool_use", id: `toolu_shot_${toolResults + 1}`, name: "exec_command", input: {} },
|
||||
(json.match(/[\s\S]{1,32}/g) ?? []).map((partial_json) => ({
|
||||
type: "input_json_delta",
|
||||
partial_json,
|
||||
})),
|
||||
);
|
||||
finish("tool_use", 160);
|
||||
} else {
|
||||
finish("end_turn", 420);
|
||||
}
|
||||
}
|
||||
|
||||
function openaiReply(res, body) {
|
||||
const flat = JSON.stringify(body.messages ?? []);
|
||||
const script = scriptFor(flat);
|
||||
const isTitle = flat.includes("concise title");
|
||||
const toolResults = flat.split('"role":"tool"').length - 1;
|
||||
const turn = script.turns[Math.min(toolResults, script.turns.length - 1)];
|
||||
|
||||
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
||||
const chunk = (delta, finishReason = null, usage) => {
|
||||
const payload = {
|
||||
id: "chatcmpl-shot",
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: "deepseek-v4-pro",
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||
};
|
||||
if (usage) payload.usage = usage;
|
||||
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
const usage = {
|
||||
prompt_tokens: 5200,
|
||||
completion_tokens: turn.cmd ? 180 : 420,
|
||||
total_tokens: 5620,
|
||||
prompt_cache_hit_tokens: 4300,
|
||||
prompt_cache_miss_tokens: 900,
|
||||
};
|
||||
|
||||
chunk({ role: "assistant" });
|
||||
if (isTitle) {
|
||||
chunk({ content: script.title });
|
||||
chunk({}, "stop", usage);
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (turn.thinking) {
|
||||
for (const t of turn.thinking.match(/[\s\S]{1,18}/g)) chunk({ reasoning_content: t });
|
||||
}
|
||||
if (turn.text) {
|
||||
for (const t of turn.text.match(/[\s\S]{1,24}/g)) chunk({ content: t });
|
||||
}
|
||||
if (turn.cmd) {
|
||||
chunk({
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: `call_shot_${toolResults + 1}`,
|
||||
type: "function",
|
||||
function: { name: "exec_command", arguments: "" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const json = JSON.stringify({ cmd: turn.cmd });
|
||||
for (const part of json.match(/[\s\S]{1,32}/g)) {
|
||||
chunk({ tool_calls: [{ index: 0, function: { arguments: part } }] });
|
||||
}
|
||||
chunk({}, "tool_calls", usage);
|
||||
} else {
|
||||
chunk({}, "stop", usage);
|
||||
}
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
|
||||
function startMock() {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
let json = {};
|
||||
try {
|
||||
json = JSON.parse(body);
|
||||
} catch {}
|
||||
if (req.url?.includes("chat/completions")) return openaiReply(res, json);
|
||||
if (req.url?.includes("messages")) return anthropicReply(res, json);
|
||||
console.log(`[mock] unexpected path ${req.url}`);
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
});
|
||||
return new Promise((resolve) => server.listen(MOCK_PORT, "127.0.0.1", () => resolve(server)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server + API helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function waitFor(url, tries = 60) {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return;
|
||||
} catch {}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`server not ready: ${url}`);
|
||||
}
|
||||
|
||||
async function api(cookie, method, url, body) {
|
||||
const res = await fetch(`${BASE}${url}`, {
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${method} ${url} -> ${res.status} ${await res.text()}`);
|
||||
return { json: await res.json().catch(() => ({})), setCookie: res.headers.get("set-cookie") };
|
||||
}
|
||||
|
||||
async function login(userId, password) {
|
||||
const { json, setCookie } = await api(null, "POST", "/api/auth/login", { userId, password });
|
||||
if (!setCookie) throw new Error("no session cookie from login");
|
||||
return { cookie: setCookie.split(";")[0], user: json.user };
|
||||
}
|
||||
|
||||
/** Per-language demo users so sidebars stay monolingual in the shots. */
|
||||
const USERS = {
|
||||
zh: {
|
||||
userId: "demo",
|
||||
agents: [
|
||||
{
|
||||
agentId: "data_analyst",
|
||||
name: "数据分析师",
|
||||
description: "面向 CSV / Excel 的数据分析、图表与报表生成",
|
||||
},
|
||||
{ agentId: "web_scout", name: "网页调研员", description: "网页检索、信息核对与调研纪要整理" },
|
||||
{
|
||||
agentId: "agent_optimizer",
|
||||
name: "Agent 优化师",
|
||||
description: "评估其他 Agent 的表现并迭代其提示词与技能",
|
||||
},
|
||||
],
|
||||
},
|
||||
en: {
|
||||
userId: "alex",
|
||||
agents: [
|
||||
{
|
||||
agentId: "data_analyst",
|
||||
name: "Data Analyst",
|
||||
description: "CSV / Excel analysis, charts and report generation",
|
||||
},
|
||||
{
|
||||
agentId: "web_scout",
|
||||
name: "Web Scout",
|
||||
description: "Web research, fact checking and note-taking",
|
||||
},
|
||||
{
|
||||
agentId: "agent_optimizer",
|
||||
name: "Agent Optimizer",
|
||||
description: "Evaluates other Agents and iterates their prompts and Skills",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Provision a user with models + a few Agents; returns { cookie, password, projectId }. */
|
||||
async function provisionUser(adminCookie, lang) {
|
||||
const { userId, agents } = USERS[lang];
|
||||
const initial = `${userId}12345`;
|
||||
await api(adminCookie, "POST", "/api/admin/users", { userId, password: initial }).catch((e) => {
|
||||
if (!String(e).includes("409")) throw e;
|
||||
});
|
||||
let session = await login(userId, initial);
|
||||
// Rotate once so the initial-password banner disappears from the shots.
|
||||
let password = initial;
|
||||
try {
|
||||
await api(session.cookie, "PUT", "/api/me/password", {
|
||||
oldPassword: initial,
|
||||
newPassword: `penguin-${userId}-2026`,
|
||||
});
|
||||
password = `penguin-${userId}-2026`;
|
||||
} catch {}
|
||||
session = await login(userId, password);
|
||||
|
||||
const projects = (await api(session.cookie, "GET", "/api/projects")).json;
|
||||
const projectId = projects.projects[0].projectId;
|
||||
|
||||
await api(session.cookie, "PUT", `/api/projects/${projectId}/models`, {
|
||||
defaultModel: { provider: "deepseek", modelId: "deepseek-v4-pro" },
|
||||
models: [
|
||||
{
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-pro",
|
||||
apiKey: "sk-demo",
|
||||
baseUrl: MOCK,
|
||||
contextWindow: 1000000,
|
||||
pricing: { cacheRead: 0.003571, cacheWrite: 0.428571, output: 0.857143 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for (const agent of agents) {
|
||||
await api(session.cookie, "POST", `/api/projects/${projectId}/agents`, agent).catch((e) => {
|
||||
if (!String(e).includes("409")) throw e;
|
||||
});
|
||||
}
|
||||
|
||||
return { cookie: session.cookie, password, projectId, userId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const dataRoot = mkdtempSync(path.join(os.tmpdir(), "penguin-shots-"));
|
||||
const wsDir = path.join(dataRoot, "workspace-apps");
|
||||
mkdirSync(wsDir, { recursive: true });
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const mock = await startMock();
|
||||
console.log(`[shots] mock LLM on ${MOCK}`);
|
||||
|
||||
const srv = spawn("node", [path.join(ROOT, "packages/server/dist/index.js")], {
|
||||
env: {
|
||||
...process.env,
|
||||
PENGUIN_HOME: path.join(dataRoot, "home"),
|
||||
PENGUIN_WEB_DB: path.join(dataRoot, "web.db"),
|
||||
PENGUIN_WEB_DIST: path.join(ROOT, "packages/web/dist"),
|
||||
PORT: String(SRV_PORT),
|
||||
HOST: "127.0.0.1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
srv.stderr.on("data", (d) => process.stderr.write(`[srv!] ${d}`));
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
srv.kill();
|
||||
} catch {}
|
||||
try {
|
||||
mock.close();
|
||||
} catch {}
|
||||
};
|
||||
process.on("exit", cleanup);
|
||||
|
||||
try {
|
||||
await waitFor(`${BASE}/`);
|
||||
console.log(`[shots] server ready on ${BASE}`);
|
||||
|
||||
const admin = await login("admin", "admin123");
|
||||
const browser = await chromium.launch();
|
||||
|
||||
// WebP encoder: Chromium re-encodes the PNG screenshot buffer via canvas, which
|
||||
// keeps repo assets small (~5x lighter than PNG) with no native image deps.
|
||||
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.82);
|
||||
}, pngBuffer.toString("base64"));
|
||||
writeFileSync(path.join(OUT_DIR, fileName), Buffer.from(dataUrl.split(",")[1], "base64"));
|
||||
console.log(`[shots] ${fileName}`);
|
||||
}
|
||||
|
||||
/** The final answer is the only turn mentioning the npm run command. */
|
||||
const DONE_MARKER = "npm install && npm start";
|
||||
|
||||
for (const lang of ["zh", "en"]) {
|
||||
const user = await provisionUser(admin.cookie, lang);
|
||||
const script = SCRIPTS[lang];
|
||||
|
||||
// Language-specific workspace subdir so zh/en runs don't collide on files.
|
||||
const ws = path.join(wsDir, lang);
|
||||
mkdirSync(ws, { recursive: true });
|
||||
|
||||
const sess = (
|
||||
await api(
|
||||
user.cookie,
|
||||
"POST",
|
||||
`/api/projects/${user.projectId}/agents/default_agent/sessions`,
|
||||
{
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-pro",
|
||||
approvalMode: "allow-all",
|
||||
workspace: ws,
|
||||
},
|
||||
)
|
||||
).json;
|
||||
const sessionId = sess.session.sessionId;
|
||||
|
||||
let firstTheme = true;
|
||||
for (const theme of ["light", "dark"]) {
|
||||
// 1280x800 @1.5x -> 1920x1200: sharp enough for the landing's ~1024px-wide
|
||||
// frames on retina, while keeping the WebP assets small.
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 800 },
|
||||
deviceScaleFactor: 1.5,
|
||||
locale: lang === "zh" ? "zh-CN" : "en-US",
|
||||
});
|
||||
await context.addInitScript(
|
||||
([t, l]) => {
|
||||
localStorage.setItem("penguin.theme", t);
|
||||
localStorage.setItem("penguin.lang", l);
|
||||
},
|
||||
[theme, lang],
|
||||
);
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${BASE}/login`);
|
||||
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
|
||||
data: { userId: user.userId, password: user.password },
|
||||
});
|
||||
if (!loginRes.ok()) throw new Error(`browser login failed: ${loginRes.status()}`);
|
||||
|
||||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||||
if (firstTheme) {
|
||||
// Drive the conversation once per language; the other theme restores it.
|
||||
const input = page.getByPlaceholder(/输入消息|Type a message/);
|
||||
await input.waitFor({ timeout: 20000 });
|
||||
await input.fill(script.prompt);
|
||||
await page.getByRole("button", { name: /发送|Send/ }).click();
|
||||
firstTheme = false;
|
||||
}
|
||||
await page.getByText(DONE_MARKER).first().waitFor({ timeout: 90000 });
|
||||
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);
|
||||
await saveWebp(await page.screenshot(), `traces-${lang}-${theme}.webp`);
|
||||
|
||||
// Evaluation center: open the pre-provisioned example Benchmark scoreboard.
|
||||
await page.goto(`${BASE}/benchmark`);
|
||||
await page.waitForTimeout(1500);
|
||||
await page
|
||||
.getByText("Example Benchmark")
|
||||
.first()
|
||||
.click()
|
||||
.catch(() => {});
|
||||
await page.waitForTimeout(2500);
|
||||
await saveWebp(await page.screenshot(), `benchmark-${lang}-${theme}.webp`);
|
||||
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`[shots] done -> ${OUT_DIR}`);
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("[shots] FAILED:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Post-build step for GitHub Pages: copy index.html to 404.html so deep links
|
||||
* (/blog/xxx) served by Pages' 404 fallback still boot the SPA router, and add
|
||||
* .nojekyll so Pages serves the dist verbatim without Jekyll processing.
|
||||
*/
|
||||
import { copyFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const dist = join(dirname(fileURLToPath(import.meta.url)), "..", "dist");
|
||||
copyFileSync(join(dist, "index.html"), join(dist, "404.html"));
|
||||
writeFileSync(join(dist, ".nojekyll"), "");
|
||||
console.log("[postbuild] wrote dist/404.html and dist/.nojekyll");
|
||||
Reference in New Issue
Block a user