diff --git a/apps/tutorial/blog-2025-07.html b/apps/tutorial/blog-2025-07.html
new file mode 100644
index 0000000..bcee989
--- /dev/null
+++ b/apps/tutorial/blog-2025-07.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ 博客 · 2025-07
+
+
+
+
+
+
diff --git a/apps/tutorial/blog-2026-08.html b/apps/tutorial/blog-2026-08.html
new file mode 100644
index 0000000..958003c
--- /dev/null
+++ b/apps/tutorial/blog-2026-08.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ 博客 · 2026-08
+
+
+
+
+
+
diff --git a/apps/tutorial/blog-gorzil.html b/apps/tutorial/blog-gorzil.html
new file mode 100644
index 0000000..d86584c
--- /dev/null
+++ b/apps/tutorial/blog-gorzil.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ gorzil:面向多人协作的 AI 原生工具开发自述
+
+
+
+
+
+
diff --git a/apps/tutorial/blog-sapling-transformer.html b/apps/tutorial/blog-sapling-transformer.html
new file mode 100644
index 0000000..b05c8e2
--- /dev/null
+++ b/apps/tutorial/blog-sapling-transformer.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ sapling_transformer 近半月开发自述
+
+
+
+
+
+
diff --git a/apps/tutorial/src/components/BlogPages.tsx b/apps/tutorial/src/components/BlogPages.tsx
new file mode 100644
index 0000000..8cb54c4
--- /dev/null
+++ b/apps/tutorial/src/components/BlogPages.tsx
@@ -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({t.slice(2, -2)})
+ else parts.push(
+
+ {t.slice(1, -1)}
+ ,
+ )
+ 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(
+
+ {buf.join('\n')}
+ ,
+ )
+ continue
+ }
+ if (ln.startsWith('> ')) {
+ out.push(
+
+ {inline(ln.slice(2))}
+
,
+ )
+ i++
+ continue
+ }
+ if (ln.startsWith('# ')) {
+ out.push(
+
+ {inline(ln.slice(2))}
+
,
+ )
+ i++
+ continue
+ }
+ if (ln.startsWith('## ')) {
+ out.push(
+
+
+ {inline(ln.slice(3))}
+
,
+ )
+ i++
+ continue
+ }
+ if (ln.startsWith('### ')) {
+ out.push(
+
+ {inline(ln.slice(4))}
+
,
+ )
+ 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(
+
+ {items.map((it, n) => (
+ -
+
+
+
+ {inline(it)}
+
+ ))}
+
,
+ )
+ 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(
+
+ {inline(buf.join(' '))}
+
,
+ )
+ }
+ return {out}
+}
+
+/* ── 文章卡片(日期页预览) ── */
+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 (
+
+
+ {post.date}
+ {post.author}
+
+ {post.title}
+ {quote.replace(/^> /, '').slice(0, 90)}…
+
+ 阅读全文 →
+
+
+ )
+}
+
+/* ── 日期页:某月的文章卡片 ── */
+export function BlogDatePage({ dateKey }: { dateKey: string }) {
+ const month = MONTHS.find((m) => m.key === dateKey)!
+ return (
+
+
+
+ {month.posts.map((p) => (
+
+ ))}
+
+
+
+ )
+}
+
+/* ── 文章页 ── */
+export function BlogPostPage({ slug }: { slug: string }) {
+ const post = POSTS.find((p) => p.slug === slug)!
+ return (
+
+
+
+ {post.date.slice(2, 4)}
+
+
{post.title}
+
+ {post.date}
+ {post.author}
+ 开发自述
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/tutorial/src/content/blog/gorzil.md b/apps/tutorial/src/content/blog/gorzil.md
new file mode 100644
index 0000000..6082dda
--- /dev/null
+++ b/apps/tutorial/src/content/blog/gorzil.md
@@ -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 的截图。
diff --git a/apps/tutorial/src/content/blog/sapling_transformer.md b/apps/tutorial/src/content/blog/sapling_transformer.md
new file mode 100644
index 0000000..6236b94
--- /dev/null
+++ b/apps/tutorial/src/content/blog/sapling_transformer.md
@@ -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 函数复用,结构的重新调整
diff --git a/apps/tutorial/src/content/blogs.ts b/apps/tutorial/src/content/blogs.ts
new file mode 100644
index 0000000..c04fa49
--- /dev/null
+++ b/apps/tutorial/src/content/blogs.ts
@@ -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((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)
diff --git a/apps/tutorial/src/lib/ui.tsx b/apps/tutorial/src/lib/ui.tsx
index 821a1ac..8536efc 100644
--- a/apps/tutorial/src/lib/ui.tsx
+++ b/apps/tutorial/src/lib/ui.tsx
@@ -5,6 +5,7 @@ import '@stack/design-system'
import type { ReactNode } from 'react'
import type { ChapterId } from '@stack/design-system'
import { CHAPTERS } from './chapters'
+import { MONTHS } from '../content/blogs'
/** 设计系统四册(自 design-lab 迁入) */
export type DesignKey = 'design-colors' | 'design-typography' | 'design-symbols' | 'design-components'
@@ -34,8 +35,8 @@ export function Page({
/** 章节页标题在章头横幅里,可省 */
title?: string
desc?: string
- /** 站头高亮:章节 id / 书架 / 设计系统册 */
- active?: ChapterId | 'index' | DesignKey
+ /** 站头高亮:章节 id / 书架 / 设计系统册 / 博客月(blog-YYYY-MM) */
+ active?: ChapterId | 'index' | DesignKey | `blog-${string}`
children: ReactNode
}) {
return (
@@ -83,6 +84,24 @@ export function Page({
)
})}
+ {/* 第三行:博客(按月分类) */}
+
+
博客
+ {MONTHS.map((m) => {
+ const on = active === `blog-${m.key}`
+ return (
+
+ {m.key}
+
+ )
+ })}
+
{(title || desc) && (
diff --git a/apps/tutorial/src/pages/blog-2025-07/main.tsx b/apps/tutorial/src/pages/blog-2025-07/main.tsx
new file mode 100644
index 0000000..16a433e
--- /dev/null
+++ b/apps/tutorial/src/pages/blog-2025-07/main.tsx
@@ -0,0 +1,4 @@
+import { createRoot } from 'react-dom/client'
+import { BlogDatePage } from '../../components/BlogPages'
+
+createRoot(document.getElementById('root')!).render()
diff --git a/apps/tutorial/src/pages/blog-2026-08/main.tsx b/apps/tutorial/src/pages/blog-2026-08/main.tsx
new file mode 100644
index 0000000..324cba0
--- /dev/null
+++ b/apps/tutorial/src/pages/blog-2026-08/main.tsx
@@ -0,0 +1,4 @@
+import { createRoot } from 'react-dom/client'
+import { BlogDatePage } from '../../components/BlogPages'
+
+createRoot(document.getElementById('root')!).render()
diff --git a/apps/tutorial/src/pages/blog-gorzil/main.tsx b/apps/tutorial/src/pages/blog-gorzil/main.tsx
new file mode 100644
index 0000000..09a6f29
--- /dev/null
+++ b/apps/tutorial/src/pages/blog-gorzil/main.tsx
@@ -0,0 +1,4 @@
+import { createRoot } from 'react-dom/client'
+import { BlogPostPage } from '../../components/BlogPages'
+
+createRoot(document.getElementById('root')!).render()
diff --git a/apps/tutorial/src/pages/blog-sapling-transformer/main.tsx b/apps/tutorial/src/pages/blog-sapling-transformer/main.tsx
new file mode 100644
index 0000000..8241eb5
--- /dev/null
+++ b/apps/tutorial/src/pages/blog-sapling-transformer/main.tsx
@@ -0,0 +1,4 @@
+import { createRoot } from 'react-dom/client'
+import { BlogPostPage } from '../../components/BlogPages'
+
+createRoot(document.getElementById('root')!).render()
diff --git a/apps/tutorial/src/vite-env.d.ts b/apps/tutorial/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/tutorial/src/vite-env.d.ts
@@ -0,0 +1 @@
+///