feat: 博客栏目——navbar 第三行按月分类(2026-08/2025-07),日期页预览文章卡片,卡片跳文章页;markdown 原文随仓 + 迷你渲染器全量风格化(t2▲节标/积木点列表/墨底码块/虚线引言/幽灵年号横幅),日期取首提 git 日期

This commit is contained in:
cqh963852 committed 2026-10-08 16:34:48 +08:00
1 parent 99948ad129
commit 2f279c2082
14 files changed
+490 -2

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>博客 · 2025-07</title>
</head>
<body class="bg-paper text-ink">
<div id="root"></div>
<script type="module" src="/src/pages/blog-2025-07/main.tsx"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>博客 · 2026-08</title>
</head>
<body class="bg-paper text-ink">
<div id="root"></div>
<script type="module" src="/src/pages/blog-2026-08/main.tsx"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>gorzil:面向多人协作的 AI 原生工具开发自述</title>
</head>
<body class="bg-paper text-ink">
<div id="root"></div>
<script type="module" src="/src/pages/blog-gorzil/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sapling_transformer 近半月开发自述</title>
</head>
<body class="bg-paper text-ink">
<div id="root"></div>
<script type="module" src="/src/pages/blog-sapling-transformer/main.tsx"></script>
</body>
</html>
+213
View File
@@ -0,0 +1,213 @@
/** 博客页——日期页(月内文章卡片预览)+ 文章页(markdown 风格化渲染)。
* 迷你 md 解析:#/##/###、> 引言、``` 代码块、-/* 列表、**粗体**、`行内码`。 */
import type { ReactNode } from 'react'
import { Page, Section } from '../lib/ui'
import { MONTHS, POSTS, monthOf } from '../content/blogs'
import { BrickDot, Mark, Tri } from '@stack/design-system'
/* ── 行内:**粗体** 与 `行内码` ── */
function inline(text: string): ReactNode[] {
const parts: ReactNode[] = []
const re = /(\*\*[^*]+\*\*|`[^`]+`)/g
let last = 0
let m: RegExpExecArray | null
let i = 0
while ((m = re.exec(text))) {
if (m.index > last) parts.push(text.slice(last, m.index))
const t = m[0]
if (t.startsWith('**')) parts.push(<b key={i++}>{t.slice(2, -2)}</b>)
else parts.push(
<code key={i++} className="rounded bg-paper-deep px-1 py-0.5 font-mono text-[0.9em] text-ink">
{t.slice(1, -1)}
</code>,
)
last = m.index + t.length
}
if (last < text.length) parts.push(text.slice(last))
return parts
}
/* ── 块级渲染(对齐字体系统:h2=t2、h3=t3、正文=content、列表=积木点) ── */
function Markdown({ raw }: { raw: string }) {
const lines = raw.replace(/^---\n[\s\S]*?\n---\n/, '').split('\n')
const out: ReactNode[] = []
let i = 0
let key = 0
while (i < lines.length) {
const ln = lines[i]
if (!ln.trim() || ln.trim() === '{/* truncate */}') {
i++
continue
}
if (ln.startsWith('```')) {
const buf: string[] = []
i++
while (i < lines.length && !lines[i].startsWith('```')) buf.push(lines[i++])
i++
out.push(
<pre
key={key++}
className="overflow-x-auto rounded-xl border-2 border-ink bg-ink p-4 font-mono text-small leading-relaxed text-paper shadow-hard"
>
{buf.join('\n')}
</pre>,
)
continue
}
if (ln.startsWith('> ')) {
out.push(
<blockquote key={key++} className="m-0 rounded-xl border-2 border-dashed border-ink/40 bg-paper-deep/60 p-4">
<p className="m-0 text-content text-body">{inline(ln.slice(2))}</p>
</blockquote>,
)
i++
continue
}
if (ln.startsWith('# ')) {
out.push(
<h1 key={key++} className="m-0 text-t1 font-extrabold text-ink">
{inline(ln.slice(2))}
</h1>,
)
i++
continue
}
if (ln.startsWith('## ')) {
out.push(
<h2 key={key++} className="m-0 flex items-center gap-2 text-t2 font-extrabold text-ink">
<Tri size={13} />
{inline(ln.slice(3))}
</h2>,
)
i++
continue
}
if (ln.startsWith('### ')) {
out.push(
<h3 key={key++} className="m-0 text-t3 font-bold text-ink">
{inline(ln.slice(4))}
</h3>,
)
i++
continue
}
if (/^\d+\. |- /.test(ln)) {
const items: string[] = []
const ordered = /^\d+\. /.test(ln)
while (i < lines.length && (ordered ? /^\d+\. /.test(lines[i]) : /^- /.test(lines[i]))) {
items.push(lines[i].replace(/^(\d+\.|-) /, ''))
i++
}
out.push(
<ul key={key++} className="m-0 list-none space-y-2 p-0">
{items.map((it, n) => (
<li key={n} className="flex items-start gap-2.5">
<span className="mt-[9px] flex shrink-0">
<BrickDot size={11} on={ordered} />
</span>
<span className="text-content text-body">{inline(it)}</span>
</li>
))}
</ul>,
)
continue
}
// 段落:连续非格式行合并
const buf: string[] = [ln]
i++
while (
i < lines.length &&
lines[i].trim() &&
!/^(#{1,3} |> |```|\d+\. |- |\/\* |\{\/\*)/.test(lines[i])
) {
buf.push(lines[i])
i++
}
out.push(
<p key={key++} className="m-0 text-content text-body">
{inline(buf.join(' '))}
</p>,
)
}
return <div className="space-y-5">{out}</div>
}
/* ── 文章卡片(日期页预览) ── */
function BlogCard({ post }: { post: (typeof POSTS)[number] }) {
const quote =
post.raw
.split('\n')
.find((l) => l.startsWith('> ') && l.trim() !== '{/* truncate */}') ??
post.raw.split('\n').find((l) => l.trim() && !l.startsWith('#') && !l.startsWith('---') && !l.startsWith('---')) ??
''
return (
<a
href={`/blog-${post.slug}.html`}
className="block w-[420px] rounded-2xl border-2 border-ink bg-paper p-5 no-underline shadow-hard transition-transform duration-150 hover:-translate-y-[3px] hover:shadow-hard-lift"
>
<div className="flex items-center gap-2.5">
<span className="rounded-full bg-clash3b/15 px-2.5 py-0.5 font-mono t-small-strong">{post.date}</span>
<span className="t-small-mute">{post.author}</span>
</div>
<h2 className="m-0 mt-3 text-t3 font-extrabold text-ink">{post.title}</h2>
<p className="m-0 mt-2 line-clamp-2 text-small leading-relaxed text-body">{quote.replace(/^> /, '').slice(0, 90)}…</p>
<p className="m-0 mt-3 t-small-strong">
阅读全文 <span aria-hidden>→</span>
</p>
</a>
)
}
/* ── 日期页:某月的文章卡片 ── */
export function BlogDatePage({ dateKey }: { dateKey: string }) {
const month = MONTHS.find((m) => m.key === dateKey)!
return (
<Page
active={`blog-${dateKey}`}
title={`博客 · ${dateKey}`}
desc={`本月 ${month.posts.length} 篇——开发自述与工程笔记。`}
>
<Section label="本月文章" note="点卡片进入文章">
<div className="flex flex-wrap gap-6">
{month.posts.map((p) => (
<BlogCard key={p.slug} post={p} />
))}
</div>
</Section>
</Page>
)
}
/* ── 文章页 ── */
export function BlogPostPage({ slug }: { slug: string }) {
const post = POSTS.find((p) => p.slug === slug)!
return (
<Page active={`blog-${monthOf(slug)}`}>
<div className="relative overflow-hidden rounded-2xl border-2 border-ink bg-paper p-5 shadow-hard">
<span className="t-display pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 select-none" aria-hidden>
{post.date.slice(2, 4)}
</span>
<h1 className="m-0 max-w-[46ch] text-t1 font-extrabold text-ink">{post.title}</h1>
<p className="m-0 mt-3 flex flex-wrap items-center gap-3">
<span className="rounded-full bg-clash3b/15 px-2.5 py-0.5 font-mono t-small-strong">{post.date}</span>
<span className="t-small-mute">{post.author}</span>
<Mark className="t-small-strong">开发自述</Mark>
</p>
</div>
<article className="mt-8 max-w-[76ch]">
<Markdown raw={post.raw} />
</article>
<div className="mt-10 flex items-center justify-between">
<a
href={`/blog-${monthOf(slug)}.html`}
className="rounded-xl border-2 border-ink bg-paper px-4 py-2 text-small font-bold text-ink no-underline shadow-hard"
>
← 返回 {monthOf(slug)}
</a>
<a href="/index.html" className="rounded-xl border-2 border-ink bg-ink px-4 py-2 text-small font-bold text-paper no-underline">
返回书架
</a>
</div>
</Page>
)
}
+100
View File
@@ -0,0 +1,100 @@
---
authors: cqh963852
---
# gorzil:面向多人协作的 AI 原生工具开发自述
> 文章结构整理自 glm-5.3
## 为什么做
最近观察到一个现象:AI 编码工具越来越强。
但它们始终是"单人玩具"。Cursor、Claude Code 这类产品默认一个开发者对着一个终端。人与人围绕 AI 工作过程的协作,基本是空白。
{/* truncate */}
我想做的事情可以概括为:多人 + 多 agent 共享同一个工作现场的工具。人发起任务,agent 执行,所有人(包括其他 agent)看到同一份不断演进的状态。
## 发生了什么
在这个开发过程中,我只负责提 ISSUE、把控架构和技术走向。总结性、文档性的工作交给了 ai,编码工作也让 ai 处理,结果好坏参半,后续也会提到。
## 核心设计
gorzil 是一个 Rust 多 Agent 协作平台,三个关键词:CRDT 协同、看板驱动、点对点数据同步。
### 数据本质是共享状态,不是消息流
这是整个项目最重要的一个技术决策。传统 IM(Slack 式)把协作建模为消息流,但 AI 协作的场景里,任务状态是不断被多方修改的:人创建任务、agent 领取、执行中、产出物挂回任务。如果用消息流建模,状态同步就是一场灾难。
所以选择了 CRDT,具体是 Loro。看板/房间状态是共享文档,agent 是文档间的翻译者,图片存引用而非二进制。同步难题由 CRDT 自然消解,不需要中心服务器做仲裁。
### 三端架构
```
coord(公网):信令 + Logto 认证 + 组织管理 + TURN
daemon(PC):本地 agent 宿主,接受 WebRTC,Loro 同步
app(浏览器):Dioxus Web 客户端,发起 WebRTC,Loro 同步
```
coord 只做信令和认证,不碰业务数据。数据在 daemon 和 app 之间通过 WebRTC DataChannel 点对点同步,P2P 失败时降级 coturn 中继。这个"云端尽量薄"的形态是有意为之——协作数据不应该经过我的服务器。
### 双 AI 审查的安全模型
本地 agent 要执行命令、改文件,安全是绕不开的问题。软件把风险暴露给用户做决策,是很常见的偷懒行为——即对风险不做设计,弹个窗让用户选,责任就转移出去了。
这是一种经典的工程师做法。静态规则也是同类问题:区分不了 `rm -rf` 清理 target 和清理 home 的区别,于是干脆都问用户。
但协作软件要面对的是不同身份的人群。既要减少风险对用户的干扰,也要保留工程师的安全审查能力。这两件事不能靠同一个机制完成。
在这里,gorzil 做了如下设计:
1. 减少干扰上
引入 ai 辅助审查:在 tool_calls 提取和执行之间插入一个独立的审查 AI,做意图对齐判断——主 AI 要执行的动作,是否服务于用户的原始意图。审查 AI 的输入是净化过的结构化摘要,不接收主 AI 的原始上下文,从源头切断 prompt injection 的路径。已知危险命令走硬规则,语义模糊的才交给模型。
对于审查 AI 的额外要求是,接入用户足够信任的模型,不做中转站之类的危险做法——安全链路上如果再引入一个不受信任的环节,那就等于白做。
2. 审查能力上
要处理的是传统 ai 群聊看不到 agent 工作过程的难题。群聊里 agent 只输出结论,过程是黑盒。虽然有些聊天工具可以不断更新当前的消息,但是我想,应该没有人愿意一直盯着 ai 在干什么。那样没有节省多少时间。
gorzil 的做法是把工作过程结构化:看板的任务操作记录,将看板任务抽象为 thread 下的引用,操作历史跟着任务走。
如果要看某个 agent 是怎么做的,则通过 agent doc 检查它的历史记录。工程师不再需要盯着消息流,而是直接审计过程本身。
### 被否决的路线
点对点通信、传输层曾经动摇过,最后还是使用 WebRTC 方案。最初的方案也是 WebRTC —— 自己做信令、自己管 ICE、自己部署 coturn。每一个步骤都是琐碎的活,甚至 ai 编码留下了 bug。
看到 iroh 后,优雅的抽象,地址即身份,打洞内置,看起来可以砍掉一大片代码。几乎要迁过去了,甚至开始规划迁移的步骤。
但是在让 ai 真正动手时,ai 说“出问题啦:浏览器端的 P2P 做不了”(一些技术上的限制)。
而 WebRTC 那套虽然琐碎,但每一件琐碎的事我都已经理解了。迁移到 iroh 并不会省掉我熟悉的麻烦,而且增加了我不熟悉的黑盒。
我最终的选择是那个已经踩过坑的方案。
### CRDT 增量同步的坑
wasm 端的 CRDT 增量同步是最痛苦的一段。由于 dioxus 是跨平台,rtc 有 native、wasm 两个端。最痛苦的是,不知道 bug 长什么样。
bug 修复大部分是 ai 在做。因为无法定位 bug,对 ai 的修复过程既不做严格审查,也不纠结代码对不对。协作方式只有两条:
1. 反复告诉 ai"出错了",让 ai 打日志,再把日志喂回去
2. 后续让他自己控制浏览器去读日志
折腾到快要放弃 ai 修复,准备人手工处理时,glm-5.3 发布了。用对待 ai 的老方式尝试了一下,问题解决了(绷不住了)。
为了这个问题,大概燃烧了 2B 的 token。
解决后的第一件事,没有让 ai 接着做事,而是抓紧把集成测试用例写起来。
让 ai 趁着神志清晰的时候,把集成测试写上。不至于出现相同的错误时,再费劲告诉 ai 应该怎么做。
### UI 体系
UI 刚开始做,所以我几乎是 1:1 的模仿 Slack。三栏布局、频道、右侧详情面板,样式体系切到 daisyUI,抽了 gorzil-ui crate,配 storybook 和 Playwright 视觉基线。这部分编码工作 ai 参与度较高,因为组件迁移是模式明确的机械劳动。
但模仿的过程中一个问题一直悬着:如果我的软件在形式上和 Slack 几乎接近,那 Slack 也可以解决 gorzil 能解决的问题——除了技术核心不同。这个质疑我给不出反驳。CRDT、P2P、双 AI 审查都是看不见的东西,用户看见的就是又一个聊天软件。
所以我还在思考:软件的形式本身是否需要变化,才能更好地揭露问题、处理问题。看板驱动是我目前的一个答案——任务状态是一等公民,消息只是状态的注释。但这个答案对不对,要等真实使用之后才知道。形式追随的还是问题本身,而不是 Slack 的截图。
@@ -0,0 +1,43 @@
---
authors: cqh963852
---
# sapling_transformer 近半月开发自述
## 为什么做这件事情
作为一个在前端深耕的工程师,工程效率,代码运行效率一直是我所关注的内容。受 solidjs 与 rust 的影响,我正在考虑是否可以有更`"好"`的方式来 transfrom jsx/tsx 代码。
{/* truncate */}
在这个过程中,对于一些文档性的工作,大部分我交给了ai,也包括这篇文章的 30%。一些编码工作也尝试过让 ai 编写,但是结果通常比较差,所以不做过多描述。
## 发生了什么
过去半个月,sapling_transformer 项目经历了多次重构和技术路线调整。项目之所以发展到当前状态,主要是因为在实际开发过程中,原有方案暴露出一系列问题,促使不得不做出改变。
## 技术路线调整的原因
最初采用了 oxc 及相关依赖,目的是快速实现 AST 转换。
但 oxc 方案在实际开发中暴露出多方面问题,尤其是在内存管理上。所以不再描述 oxc 方案下的变化。
1. 由于 oxc 过度追求性能,放弃了平稳的内存管理库,编写相关代码时异常痛苦,文档匮乏,仅靠 ai 阅读源码,维护难度极高。
2. 文档缺失,用例缺失。在 oxc 源码历史中,出现过多次 breaking change,甚至是采用不完整的方案,以至于文档老旧。
3. 库的部分缺失,在使用 oxc 方案的后半段,尝试自行实现 Visitor Traverse,但在当时的架构设计下,部分关键实现缺失,以至于无法实现架构目标。
## biome 方案下的变化
迁移到 biome 相关依赖后,项目的架构演进经历了四个阶段。
最初尝试直接使用 biome 的 transform 能力,但发现 biome 作为 lint/format 工具的定位,导致其转换接口无法满足语义转换的需求,实际功能受限。
为此,第二阶段自建了 Traverse 过程,试图将 solidjs transformer 按语义迁移,但由于架构设计不成熟,代码组织混乱,维护和扩展都遇到障碍。
第三阶段对 Traverse 过程进行了重构,转向 transform 架构,但实际实现仍存在缺陷,模块划分和接口设计无法支撑复杂转换流程,架构问题依然突出。如父子关系处理,transfrom 复用。
在第四阶段(当前版本),针对前三版暴露的各种问题,对第三版进行了部分重构。具体做法包括:
1. 使用自定义的树遍历。自定义的树遍历,相比于高度抽象的遍历,可以更加自由的修改遍历函数,以此来支持父子关系处理
2. 增加同类型节点的不同处理方式。针对 ast transfromer,存在单个语句,不同上下文情况下,输出不同结果的场景。
3. transfrom 函数复用,结构的重新调整
+48
View File
@@ -0,0 +1,48 @@
/** 博客内容注册表——markdown 原文随仓,日期取首提日期(git) */
import gorzil from './blog/gorzil.md?raw'
import sapling from './blog/sapling_transformer.md?raw'
export interface BlogPost {
slug: string
title: string
/** YYYY-MM-DD(首提日期) */
date: string
author: string
raw: string
}
export const POSTS: BlogPost[] = [
{
slug: 'gorzil',
title: 'gorzil:面向多人协作的 AI 原生工具开发自述',
date: '2026-08-19',
author: 'cqh963852',
raw: gorzil,
},
{
slug: 'sapling-transformer',
title: 'sapling_transformer 近半月开发自述',
date: '2025-07-17',
author: 'cqh963852',
raw: sapling,
},
]
/** 按月分组(navbar 第三行的日期分类) */
export interface BlogMonth {
key: string // YYYY-MM
posts: BlogPost[]
}
export const MONTHS: BlogMonth[] = [...POSTS]
.sort((a, b) => (a.date < b.date ? 1 : -1))
.reduce<BlogMonth[]>((acc, p) => {
const key = p.date.slice(0, 7)
const last = acc[acc.length - 1]
if (last && last.key === key) last.posts.push(p)
else acc.push({ key, posts: [p] })
return acc
}, [])
/** 文章所属月(用于文章页 navbar 高亮) */
export const monthOf = (slug: string) => POSTS.find((p) => p.slug === slug)!.date.slice(0, 7)
+21 -2
View File
@@ -5,6 +5,7 @@ import '@stack/design-system'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { ChapterId } from '@stack/design-system' import type { ChapterId } from '@stack/design-system'
import { CHAPTERS } from './chapters' import { CHAPTERS } from './chapters'
import { MONTHS } from '../content/blogs'
/** 设计系统四册(自 design-lab 迁入) */ /** 设计系统四册(自 design-lab 迁入) */
export type DesignKey = 'design-colors' | 'design-typography' | 'design-symbols' | 'design-components' export type DesignKey = 'design-colors' | 'design-typography' | 'design-symbols' | 'design-components'
@@ -34,8 +35,8 @@ export function Page({
/** 章节页标题在章头横幅里,可省 */ /** 章节页标题在章头横幅里,可省 */
title?: string title?: string
desc?: string desc?: string
/** 站头高亮:章节 id / 书架 / 设计系统册 */ /** 站头高亮:章节 id / 书架 / 设计系统册 / 博客月(blog-YYYY-MM) */
active?: ChapterId | 'index' | DesignKey active?: ChapterId | 'index' | DesignKey | `blog-${string}`
children: ReactNode children: ReactNode
}) { }) {
return ( return (
@@ -83,6 +84,24 @@ export function Page({
) )
})} })}
</div> </div>
{/* 第三行:博客(按月分类) */}
<div className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-dashed border-ink/20 pt-2">
<span className="t-small-mute mr-1">博客</span>
{MONTHS.map((m) => {
const on = active === `blog-${m.key}`
return (
<a
key={m.key}
href={`/blog-${m.key}.html`}
className={`rounded-lg px-2.5 py-1 font-mono text-[12.5px] font-bold no-underline transition-colors ${
on ? 'bg-paper-deep text-ink shadow-[inset_0_0_0_2px_var(--color-ink)]' : 'text-body hover:bg-paper-deep'
}`}
>
{m.key}
</a>
)
})}
</div>
</header> </header>
{(title || desc) && ( {(title || desc) && (
<div className="mb-7"> <div className="mb-7">
@@ -0,0 +1,4 @@
import { createRoot } from 'react-dom/client'
import { BlogDatePage } from '../../components/BlogPages'
createRoot(document.getElementById('root')!).render(<BlogDatePage dateKey="2025-07" />)
@@ -0,0 +1,4 @@
import { createRoot } from 'react-dom/client'
import { BlogDatePage } from '../../components/BlogPages'
createRoot(document.getElementById('root')!).render(<BlogDatePage dateKey="2026-08" />)
@@ -0,0 +1,4 @@
import { createRoot } from 'react-dom/client'
import { BlogPostPage } from '../../components/BlogPages'
createRoot(document.getElementById('root')!).render(<BlogPostPage slug="gorzil" />)
@@ -0,0 +1,4 @@
import { createRoot } from 'react-dom/client'
import { BlogPostPage } from '../../components/BlogPages'
createRoot(document.getElementById('root')!).render(<BlogPostPage slug="sapling-transformer" />)
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />