From 3806e4e4411f21d6238f5f467030df35da8f3de9 Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Wed, 22 Jul 2026 22:22:02 +0800 Subject: [PATCH] feat(blog): grouped list, pinned posts, author/date/copy-link metadata (#20) Co-authored-by: Alice Co-authored-by: Claude Fable 5 --- .../blog/introducing-penguinharness.en.md | 1 + .../blog/introducing-penguinharness.zh.md | 1 + .../blog/local-agents-on-amd-gpus.en.md | 3 +- .../blog/local-agents-on-amd-gpus.zh.md | 3 +- ...rness-self-improvement-with-amd-gpu.en.md} | 5 +- ...rness-self-improvement-with-amd-gpu.zh.md} | 5 +- .../landing/src/components/category-badge.tsx | 30 +++-- packages/landing/src/components/icons.tsx | 8 ++ packages/landing/src/lib/blog.ts | 65 +++++++++- packages/landing/src/lib/strings-en.ts | 6 +- packages/landing/src/lib/strings.ts | 6 +- packages/landing/src/pages/blog-list.tsx | 42 +++---- packages/landing/src/pages/blog-post.tsx | 77 +++++++++++- packages/landing/test/blog.test.ts | 111 ++++++++++++++++++ 14 files changed, 314 insertions(+), 49 deletions(-) rename packages/landing/content/blog/{penguin-harness-self-improvement_with_amdgpu_EN.md => penguin-harness-self-improvement-with-amd-gpu.en.md} (99%) rename packages/landing/content/blog/{penguin-harness-self-improvement_with_amdgpu_ZH.md => penguin-harness-self-improvement-with-amd-gpu.zh.md} (99%) create mode 100644 packages/landing/test/blog.test.ts diff --git a/packages/landing/content/blog/introducing-penguinharness.en.md b/packages/landing/content/blog/introducing-penguinharness.en.md index f516864..26f2f55 100644 --- a/packages/landing/content/blog/introducing-penguinharness.en.md +++ b/packages/landing/content/blog/introducing-penguinharness.en.md @@ -2,6 +2,7 @@ title: "Introducing PenguinHarness: agents that build agents" date: 2026-07-17 category: news +pinned: true 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. --- diff --git a/packages/landing/content/blog/introducing-penguinharness.zh.md b/packages/landing/content/blog/introducing-penguinharness.zh.md index 22864bf..aa476fd 100644 --- a/packages/landing/content/blog/introducing-penguinharness.zh.md +++ b/packages/landing/content/blog/introducing-penguinharness.zh.md @@ -2,6 +2,7 @@ title: PenguinHarness 正式发布:让 Agent 为你构建 Agent date: 2026-07-17 category: news +pinned: true excerpt: 我们在 GDPevo Benchmark 中验证了 Agent 自我进化的能力,现在把它带给所有人——首个支持递归自我进化的开源 Harness 正式发布,从一句话构建 Agent 到持续自我进化,一套基础设施全部覆盖。 --- diff --git a/packages/landing/content/blog/local-agents-on-amd-gpus.en.md b/packages/landing/content/blog/local-agents-on-amd-gpus.en.md index 583c85a..4524b54 100644 --- a/packages/landing/content/blog/local-agents-on-amd-gpus.en.md +++ b/packages/landing/content/blog/local-agents-on-amd-gpus.en.md @@ -1,7 +1,8 @@ --- title: "A closer look at PenguinHarness — and running a self-improving agent locally on an AMD GPU" date: 2026-07-20 -category: news +category: practice +author: Ning Zhang (AMD), Yuyang Gao (AMD), Yaowei Zheng (PrismShadow) excerpt: What PenguinHarness actually is, the ideas behind its architecture, and a real end-to-end run — a local open-weight model on an AMD GPU that fails a scored task, then improves itself from 0 to 4 out of 5 by editing its own files, entirely on-device. --- diff --git a/packages/landing/content/blog/local-agents-on-amd-gpus.zh.md b/packages/landing/content/blog/local-agents-on-amd-gpus.zh.md index 4c69f47..4897991 100644 --- a/packages/landing/content/blog/local-agents-on-amd-gpus.zh.md +++ b/packages/landing/content/blog/local-agents-on-amd-gpus.zh.md @@ -1,7 +1,8 @@ --- title: 深入了解 PenguinHarness——并在 AMD GPU 上本地跑通一个自我进化的 Agent date: 2026-07-20 -category: news +category: practice +author: 张宁(AMD)、高钰洋(AMD)、郑耀威(PrismShadow) excerpt: PenguinHarness 到底是什么、它的架构背后有哪些理念,以及一次真实的端到端运行——一个完全本地、运行在 AMD GPU 上的开源权重模型,先在一个打分任务上失败,再通过编辑自己的文件把分数从 0 提升到 4(满分 5),全程数据不出本机。 --- diff --git a/packages/landing/content/blog/penguin-harness-self-improvement_with_amdgpu_EN.md b/packages/landing/content/blog/penguin-harness-self-improvement-with-amd-gpu.en.md similarity index 99% rename from packages/landing/content/blog/penguin-harness-self-improvement_with_amdgpu_EN.md rename to packages/landing/content/blog/penguin-harness-self-improvement-with-amd-gpu.en.md index d8f8caf..1fe8b73 100644 --- a/packages/landing/content/blog/penguin-harness-self-improvement_with_amdgpu_EN.md +++ b/packages/landing/content/blog/penguin-harness-self-improvement-with-amd-gpu.en.md @@ -1,15 +1,14 @@ --- title: "Implementing Agent Self-Improvement with PenguinHarness on an AMD GPU" date: 2026-07-22 -category: "news" +category: practice +author: Yuyang Gao (AMD), Ning Zhang (AMD), Yaowei Zheng (PrismShadow) excerpt: "A complete PenguinHarness self-improvement loop—from baseline evaluation and Trace analysis to Agent optimization and rollback—using a local Qwen3:8B model alongside the Fireworks API." description: "Learn how PenguinHarness combines Benchmarks, Traces, editable Agent State, Snapshots, and rollback in a dual-model experiment with local Qwen3:8B and the Fireworks API." --- -# Implementing Agent Self-Improvement with PenguinHarness on an AMD GPU - *AMD × PrismShadow — Yuyang Gao and Ning Zhang (AMD), Yaowei Zheng (PrismShadow).* [PenguinHarness](https://github.com/Prism-Shadow/penguin-harness) is an open-source Agent Harness that brings model integrations, Agent configuration, workspace tools, Sessions, Traces, Skills, and Benchmarks into one runtime, with both a CLI and a Web UI. It can use hosted models as well as local models exposed through an OpenAI-compatible endpoint. diff --git a/packages/landing/content/blog/penguin-harness-self-improvement_with_amdgpu_ZH.md b/packages/landing/content/blog/penguin-harness-self-improvement-with-amd-gpu.zh.md similarity index 99% rename from packages/landing/content/blog/penguin-harness-self-improvement_with_amdgpu_ZH.md rename to packages/landing/content/blog/penguin-harness-self-improvement-with-amd-gpu.zh.md index 8d1218f..c6f5b1e 100644 --- a/packages/landing/content/blog/penguin-harness-self-improvement_with_amdgpu_ZH.md +++ b/packages/landing/content/blog/penguin-harness-self-improvement-with-amd-gpu.zh.md @@ -1,12 +1,11 @@ --- title: "在 AMD GPU 上用 PenguinHarness 实现 Agent 自我进化" date: 2026-07-22 -category: "news" +category: practice +author: 高钰洋(AMD)、张宁(AMD)、郑耀威(PrismShadow) excerpt: "通过本地 Qwen3:8B 与 Fireworks API 的双模型分工,完整演示 PenguinHarness 从基线评测、Trace 分析到 Agent 优化与回滚的自我进化闭环。" description: "介绍 PenguinHarness 如何通过 Benchmark、Trace、可编辑的 Agent State 与 Snapshot 回滚构建自我进化闭环,并用本地 Qwen3:8B 与 Fireworks API 完成一次双模型实验。" --- -# 在 AMD GPU 上用 PenguinHarness 实现 Agent 自我进化 - *AMD × PrismShadow——高钰洋、张宁(AMD),郑耀威(PrismShadow)。* [PenguinHarness](https://github.com/Prism-Shadow/penguin-harness) 是一个开源的 Agent Harness。它把模型接入、Agent 配置、工作区工具、Session、Trace、Skill 和 Benchmark 放在同一套运行环境中,并同时提供 CLI 与 Web UI。在线模型和通过 OpenAI 兼容接口暴露的本地模型,都可以作为其中的推理后端。 diff --git a/packages/landing/src/components/category-badge.tsx b/packages/landing/src/components/category-badge.tsx index 7a8c0c4..343aee0 100644 --- a/packages/landing/src/components/category-badge.tsx +++ b/packages/landing/src/components/category-badge.tsx @@ -1,18 +1,32 @@ -/** Blog category badge: product news (brand tint) vs release notes (neutral tint). */ +/** + * Blog badges: category (news = brand fill, practice = brand outline, changelog = + * neutral) + the pinned marker, all sharing the same pill shape. + */ import { S } from "../lib/strings"; import type { BlogCategory } from "../lib/blog"; +const CATEGORY_STYLES: Record = { + news: "border-brand-200 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950 dark:text-brand-300", + practice: + "border-brand-200 bg-transparent text-brand-700 dark:border-brand-800 dark:text-brand-300", + changelog: + "border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300", +}; + export function CategoryBadge({ category }: { category: BlogCategory }) { - const isNews = category === "news"; return ( - {isNews ? S.blog.news : S.blog.changelog} + {S.blog[category]} + + ); +} + +export function PinnedBadge() { + return ( + + {S.blog.pinned} ); } diff --git a/packages/landing/src/components/icons.tsx b/packages/landing/src/components/icons.tsx index 306e8b2..526a5f5 100644 --- a/packages/landing/src/components/icons.tsx +++ b/packages/landing/src/components/icons.tsx @@ -339,6 +339,14 @@ export function ExternalLinkIcon(props: IconProps) { ); } +export function LinkIcon(props: IconProps) { + return ( + + + + ); +} + export function KeyIcon(props: IconProps) { return ( diff --git a/packages/landing/src/lib/blog.ts b/packages/landing/src/lib/blog.ts index a2fd7d1..4f110e1 100644 --- a/packages/landing/src/lib/blog.ts +++ b/packages/landing/src/lib/blog.ts @@ -7,7 +7,10 @@ import { parseFrontmatter } from "./frontmatter"; import type { Locale } from "../state/locale"; -export type BlogCategory = "news" | "changelog"; +export type BlogCategory = "news" | "practice" | "changelog"; + +/** Byline used when a post has no `author` frontmatter. */ +export const DEFAULT_AUTHOR = "Yaowei Zheng (PrismShadow AI)"; export interface BlogPost { slug: string; @@ -17,9 +20,27 @@ export interface BlogPost { date: string; category: BlogCategory; excerpt: string; + /** Author list from comma-separated `author` frontmatter (default byline when absent). */ + authors: string[]; + /** `pinned: true` in frontmatter: sorts before everything else, badge on the card. */ + pinned: boolean; body: string; } +/** Split `author` frontmatter on comma-class separators (`,` `,` `、`); default when empty. */ +export function parseAuthors(raw: string | undefined): string[] { + const authors = (raw ?? "") + .split(/[,,、]/) + .map((name) => name.trim()) + .filter(Boolean); + return authors.length > 0 ? authors : [DEFAULT_AUTHOR]; +} + +/** Join authors for display: 顿号 in Chinese, comma otherwise. */ +export function formatAuthors(authors: string[], locale: Locale): string { + return authors.join(locale === "zh" ? "、" : ", "); +} + const files = import.meta.glob("../../content/blog/*.md", { query: "?raw", import: "default", @@ -31,7 +52,8 @@ function toPost(path: string, raw: string): BlogPost | null { const match = /^(.+)\.(zh|en)\.md$/.exec(file); if (!match) return null; const { meta, body } = parseFrontmatter(raw); - const category: BlogCategory = meta.category === "changelog" ? "changelog" : "news"; + const category: BlogCategory = + meta.category === "changelog" || meta.category === "practice" ? meta.category : "news"; return { slug: match[1]!, lang: match[2] as Locale, @@ -39,15 +61,50 @@ function toPost(path: string, raw: string): BlogPost | null { date: meta.date ?? "", category, excerpt: meta.excerpt ?? "", + authors: parseAuthors(meta.author), + pinned: meta.pinned === "true", body, }; } +/** Sort order for post lists: pinned first, then newest date, then slug as tie-break. */ +export function comparePosts( + a: Pick, + b: Pick, +): number { + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; + if (a.date !== b.date) return a.date < b.date ? 1 : -1; + return a.slug.localeCompare(b.slug); +} + +/** + * Format a YYYY-MM-DD post date for display ("July 20, 2026" / "2026年7月20日"). + * Parsed as UTC and formatted in UTC so the calendar day never shifts with the + * viewer's timezone; unexpected input falls back to the raw string. + */ +export function formatPostDate(date: string, locale: Locale): string { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + if (!match) return date; + const [y, m, d] = [Number(match[1]), Number(match[2]), Number(match[3])]; + const utc = new Date(Date.UTC(y, m - 1, d)); + // Date.UTC silently normalizes impossible dates (2026-02-31 -> March 3): fall back + // to the raw string when the components do not survive the round-trip. + if (utc.getUTCFullYear() !== y || utc.getUTCMonth() !== m - 1 || utc.getUTCDate() !== d) { + return date; + } + return new Intl.DateTimeFormat(locale === "zh" ? "zh-CN" : "en-US", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", + }).format(utc); +} + const ALL: BlogPost[] = Object.entries(files) .map(([path, raw]) => toPost(path, raw)) .filter((p): p is BlogPost => p !== null); -/** Pick the locale's version of each slug (fallback to the other language), newest first. */ +/** Pick the locale's version of each slug (fallback to the other language), pinned/newest first. */ export function postsFor(locale: Locale, category?: BlogCategory): BlogPost[] { const bySlug = new Map(); for (const post of ALL) { @@ -58,7 +115,7 @@ export function postsFor(locale: Locale, category?: BlogCategory): BlogPost[] { } return [...bySlug.values()] .filter((p) => (category ? p.category === category : true)) - .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : a.slug.localeCompare(b.slug))); + .sort(comparePosts); } export function getPost(slug: string, locale: Locale): BlogPost | undefined { diff --git a/packages/landing/src/lib/strings-en.ts b/packages/landing/src/lib/strings-en.ts index 51f29a1..7eca0be 100644 --- a/packages/landing/src/lib/strings-en.ts +++ b/packages/landing/src/lib/strings-en.ts @@ -411,10 +411,14 @@ export const en: Strings = { blog: { title: "Blog", - subtitle: "Product news and release notes", + subtitle: "Product news, tech practices and release notes", all: "All", news: "Product news", + practice: "Tech practice", changelog: "Release notes", + pinned: "Pinned", + copyLink: "Copy page link", + linkCopied: "Copied", back: "Back to blog", empty: "No posts in this category yet", notFound: "Post not found", diff --git a/packages/landing/src/lib/strings.ts b/packages/landing/src/lib/strings.ts index 4b8fea2..a5512d5 100644 --- a/packages/landing/src/lib/strings.ts +++ b/packages/landing/src/lib/strings.ts @@ -405,10 +405,14 @@ export const zh = { blog: { title: "博客", - subtitle: "产品动态与更新日志", + subtitle: "产品动态、技术实践与更新日志", all: "全部", news: "产品动态", + practice: "技术实践", changelog: "更新日志", + pinned: "置顶", + copyLink: "复制页面链接", + linkCopied: "已复制", back: "返回博客", empty: "该分类下暂无文章", notFound: "文章不存在", diff --git a/packages/landing/src/pages/blog-list.tsx b/packages/landing/src/pages/blog-list.tsx index 29d7f4b..690e47a 100644 --- a/packages/landing/src/pages/blog-list.tsx +++ b/packages/landing/src/pages/blog-list.tsx @@ -1,14 +1,20 @@ -/** Blog list: category chips (all / product news / release notes) + post cards. */ +/** + * Blog list: category chips (all / product news / tech practice / release notes) + + * post cards. A flat list in every view — each card carries its category badge, + * chips filter by category, and pinned posts sort first. + */ import { useState } from "react"; import { Link } from "react-router"; import { S } from "../lib/strings"; import { useLocale } from "../state/locale"; -import { postsFor } from "../lib/blog"; +import { formatPostDate, postsFor } from "../lib/blog"; import type { BlogCategory } from "../lib/blog"; -import { CategoryBadge } from "../components/category-badge"; +import { CategoryBadge, PinnedBadge } from "../components/category-badge"; type Filter = "all" | BlogCategory; +const FILTERS: Filter[] = ["all", "news", "practice", "changelog"]; + export function BlogListPage() { const { locale } = useLocale(); const [filter, setFilter] = useState("all"); @@ -27,23 +33,16 @@ export function BlogListPage() {

{S.blog.title}

{S.blog.subtitle}

- - - + {FILTERS.map((value) => ( + + ))}
@@ -61,9 +60,10 @@ export function BlogListPage() { >
+ {post.pinned && }

{post.title} diff --git a/packages/landing/src/pages/blog-post.tsx b/packages/landing/src/pages/blog-post.tsx index 51b5e90..1868759 100644 --- a/packages/landing/src/pages/blog-post.tsx +++ b/packages/landing/src/pages/blog-post.tsx @@ -4,17 +4,17 @@ * (same slugifier as the TOC) and the active section is tracked with an * IntersectionObserver so the TOC highlights while scrolling. */ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Link, useParams } from "react-router"; import { S } from "../lib/strings"; import { useLocale } from "../state/locale"; -import { getPost } from "../lib/blog"; +import { formatAuthors, formatPostDate, getPost } from "../lib/blog"; import { extractToc, slugifyHeading } from "../lib/toc"; import { CategoryBadge } from "../components/category-badge"; -import { ArrowRightIcon } from "../components/icons"; +import { ArrowRightIcon, CheckIcon, LinkIcon } from "../components/icons"; /** Flatten react-markdown heading children to plain text for slugging. */ function nodeText(node: ReactNode): string { @@ -27,6 +27,68 @@ function nodeText(node: ReactNode): string { return ""; } +/** Copy text to the clipboard; reports whether a copy actually happened. */ +async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Clipboard API unavailable (e.g. non-secure context): fall back to a textarea + // kept out of layout (fixed + invisible) so select() cannot scroll the page. + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.top = "0"; + ta.style.left = "0"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + try { + ta.select(); + return document.execCommand("copy"); + } catch { + return false; + } finally { + ta.remove(); + } + } +} + +/** Copies the page URL to the clipboard; the label flips to "copied" for ~2s. */ +function CopyLinkButton() { + const [copied, setCopied] = useState(false); + const timer = useRef | null>(null); + + useEffect( + () => () => { + if (timer.current) clearTimeout(timer.current); + }, + [], + ); + + const onCopy = async () => { + if (!(await copyToClipboard(window.location.href))) return; + setCopied(true); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} + function Toc({ entries, activeId }: { entries: ReturnType; activeId: string }) { return (