Compare commits
16
Commits
61d87e6b51
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1cd15b2cf | ||
|
|
12dac31355 | ||
|
|
41114eb0ad | ||
|
|
acfa02f96d | ||
|
|
a0fefc5041 | ||
|
|
f1586d240e | ||
|
|
76562f77e3 | ||
|
|
73ad7d60b5 | ||
|
|
159b1e0bf1 | ||
|
|
82f4f44e5a | ||
|
|
7f555aae5e | ||
|
|
945c7ac45d | ||
|
|
114e1938e5 | ||
|
|
6583d0e4ef | ||
|
|
5c4f328791 | ||
|
|
1eb8de22e1 |
@@ -1,37 +1,13 @@
|
||||
# Published to fam/blog:.gitea/workflows/publish.yml by app/hugo/install.
|
||||
# Runs on the gnoc-host act_runner (host mode — job executes directly in
|
||||
# the gnoc user's shell), so we can rsync into /var/gnoc/hugo-public where
|
||||
# the gnoc-hugo nginx pod reads via hostPath.
|
||||
|
||||
name: publish
|
||||
|
||||
name: verify
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
pull_request:
|
||||
jobs:
|
||||
build-and-publish:
|
||||
verify:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install Hugo
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HUGO_VERSION=0.147.6
|
||||
TAR="hugo_extended_0.147.6_linux-amd64.tar.gz"
|
||||
curl -fsSL -o "$RUNNER_TEMP/hugo.tar.gz" \
|
||||
"https://github.com/gohugoio/hugo/releases/download/v0.147.6/${TAR}"
|
||||
tar -xzf "$RUNNER_TEMP/hugo.tar.gz" -C "$RUNNER_TEMP" hugo
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build
|
||||
run: hugo --minify --gc
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rsync -az --delete public/ /var/gnoc/hugo-public/
|
||||
- name: Verify custom static site
|
||||
run: make test
|
||||
# Production: make deploy from the local development machine.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.venv/
|
||||
public/
|
||||
__pycache__/
|
||||
.env*
|
||||
!*.example
|
||||
test-results/
|
||||
themes/
|
||||
node_modules/
|
||||
playwright-report/
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "themes/PaperMod"]
|
||||
path = themes/PaperMod
|
||||
url = https://github.com/adityatelange/hugo-PaperMod.git
|
||||
@@ -0,0 +1,9 @@
|
||||
# Working agreements
|
||||
|
||||
- Develop locally under ~/projects/fzc-src/blog; fzc is a deployment host.
|
||||
- All routine commands belong in Makefile. Run make test before deployment.
|
||||
- Own static HTML/CSS/JS; do not restore Hugo/PaperMod or add a heavy framework.
|
||||
- Preserve published Markdown and permanent URLs. Compare live repository history before trusting copied working trees.
|
||||
- Application links/descriptions belong in apps.json. Never publish secrets, private track links or access tokens.
|
||||
- Keep Ingress/TLS and unrelated workloads untouched. Deploy only public/; preserve previous releases for rollback.
|
||||
- Never infer an infrastructure/Gitea upgrade from a request to update source repositories.
|
||||
@@ -0,0 +1,23 @@
|
||||
SHELL := /bin/bash
|
||||
.DEFAULT_GOAL := help
|
||||
PYTHON := .venv/bin/python
|
||||
.PHONY: help install build test test-browser serve deploy
|
||||
help:
|
||||
@echo 'make install | build | test | serve | deploy'
|
||||
@echo '文章: content/posts/*.md;应用: apps.json;样式: assets/site.css'
|
||||
install: .venv/.installed
|
||||
.venv/.installed: requirements.txt
|
||||
uv venv --allow-existing .venv
|
||||
uv pip install --python $(PYTHON) -r requirements.txt
|
||||
touch $@
|
||||
build: install
|
||||
$(PYTHON) build.py
|
||||
test: install
|
||||
$(PYTHON) -m unittest discover -s tests -v
|
||||
test-browser: build
|
||||
npm ci
|
||||
npm run test:browser
|
||||
serve: build
|
||||
$(PYTHON) -m http.server 4176 --bind 127.0.0.1 --directory public
|
||||
deploy: test
|
||||
$(PYTHON) deploy.py
|
||||
@@ -1,3 +1,30 @@
|
||||
# blog
|
||||
# famzheng.me
|
||||
|
||||
Hugo blog (gnoc-managed)
|
||||
自己维护的应用入口与博客,纯静态 HTML/CSS/JS。不再依赖 Hugo、PaperMod 或运行时后端。
|
||||
|
||||
## 开发与发布
|
||||
|
||||
源码:~/projects/fzc-src/blog(本机);生产:ssh fzc。服务器只接收构建产物。
|
||||
构建要求 Python 3.10+ 和 uv;浏览器回归使用 Playwright,可通过 npm ci 安装。
|
||||
|
||||
- make install:安装固定版本构建依赖。
|
||||
- make test:文章完整性、站内链接、RSS、应用清单测试。
|
||||
- make serve:http://127.0.0.1:4176 本地预览。
|
||||
- make deploy:本机构建测试,rsync 产物,切换指定站点的挂载目录,公网验收。
|
||||
|
||||
应用清单集中在 apps.json:名称、简介、URL、分类、图标、访问提示。不要放密钥、分享令牌或管理初始化链接。
|
||||
|
||||
文章在 content/posts/<slug>.md,保留 YAML frontmatter(title/date/summary/draft/tags)。文件名就是永久 URL。原有 11 篇文章全文、/posts/、/about/、标签、归档、分页旧地址、RSS 和 /demos/ 均保留。
|
||||
|
||||
assets/ 是独立设计;首页无外部字体、分析脚本或 JS 框架。过滤是渐进增强,无 JS 仍可浏览全部入口。只有含 Mermaid 的文章按需加载固定版本渲染器,失败保留文本。
|
||||
|
||||
## 部署边界与回滚
|
||||
|
||||
沿用 gnoc-hugo/hugo 的 nginx、Service、Ingress 与 TLS;名称是历史兼容,不再运行 Hugo。
|
||||
每次产物上传到 /var/gnoc/site-releases/<timestamp>-<commit>,只更新该 Deployment 的 site hostPath;失败恢复原目录。
|
||||
原 /var/gnoc/hugo-public 和每版发布目录保留,可回滚 Deployment 或改回发布时显示的旧路径。
|
||||
Gitea、其他子域名、数据库及私人应用数据不受影响。CI 仅校验,不再在服务器重建站点。
|
||||
|
||||
## 文风
|
||||
|
||||
第一人称、自然简洁。原文不为改版而改写;不要替作者捏造经历、观点或项目能力。
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{"name":"Fam 账号","description":"统一登录与账号管理,连接 Onion 和私人书房。","url":"https://auth.famzheng.me/","category":"工具","icon":"cube","access":"需登录"},
|
||||
{"name":"Music Lab","subtitle":"随身的音乐练习室","description":"找熟悉的旋律,收藏曲谱,用节拍器与鼓机陪你练习。","url":"https://music.famzheng.me/","category":"音乐","icon":"music","featured":true},
|
||||
{"name":"Hedgehog","subtitle":"一桌人,一起玩","description":"从扫码入座开始,让小骰子成为聚会里的伙伴。","url":"https://hh.famzheng.me/","category":"互动","icon":"dice","featured":true,"access":"内测"},
|
||||
{"name":"在场","subtitle":"把冷场交给骰子","description":"划一下换个性格,拍一拍聊两句。一个有声音的互动实验。","url":"https://dice.famzheng.me/","category":"互动","icon":"spark","featured":true,"access":"内测"},
|
||||
{"name":"Onion","description":"中文图像编辑器,登录 Fam 后自动保存项目、跨设备继续创作。请使用桌面版 Chrome。","url":"https://onion.famzheng.me/","category":"工具","icon":"spark"},
|
||||
{"name":"Read","description":"把文档带进浏览器,留一段专心阅读的时间。","url":"https://read.famzheng.me/","category":"工具","icon":"book","access":"私人"},
|
||||
{"name":"Track","description":"每日轨迹与日记,把走过的路留作自己的记录。","url":"https://track.famzheng.me/","category":"生活","icon":"route","access":"私人"},
|
||||
{"name":"Notes","description":"随手记下念头,整理值得留下的片段。","url":"https://notes.famzheng.me/","category":"工具","icon":"note"},
|
||||
{"name":"Write","description":"一个浏览器里的写作空间。","url":"https://write.famzheng.me/","category":"工具","icon":"note"},
|
||||
{"name":"Karaoke","description":"打开歌,给日常加一点伴奏。","url":"https://karaoke.famzheng.me/","category":"音乐","icon":"music"},
|
||||
{"name":"Riff","description":"围绕音乐的小站,继续探索旋律与节奏。","url":"https://riff.famzheng.me/","category":"音乐","icon":"music"},
|
||||
{"name":"Partiverse","description":"多人小游戏的实验场。","url":"https://party.famzheng.me/","category":"互动","icon":"dice"},
|
||||
{"name":"Werewolf","description":"狼人杀,让推理与演技在桌边碰面。","url":"https://werewolf.famzheng.me/","category":"互动","icon":"spark"},
|
||||
{"name":"Cube","description":"小应用平台,把常用的工具和玩具收在一起。","url":"https://cube.famzheng.me/","category":"工具","icon":"cube"},
|
||||
{"name":"WebGL","description":"光、空间和图形,直接在浏览器里玩。","url":"https://webgl.famzheng.me/","category":"实验","icon":"cube"},
|
||||
{"name":"Code Space","description":"换个视角,走进一座由代码组成的三维空间。","url":"/demos/code-space-browser.html","category":"实验","icon":"cube"},
|
||||
{"name":"SimpleASM","description":"一个探索汇编与底层计算的小工具。","url":"https://asm.famzheng.me/","category":"实验","icon":"code"},
|
||||
{"name":"Articulate","description":"表达与语言练习的小实验。","url":"https://articulate.famzheng.me/","category":"工具","icon":"note"},
|
||||
{"name":"Video to Slides","description":"从视频里整理幻灯片。","url":"https://video2slides.famzheng.me/","category":"工具","icon":"book"},
|
||||
{"name":"EuDict","description":"查词与语言学习的小入口。","url":"https://eudict.famzheng.me/","category":"工具","icon":"book"},
|
||||
{"name":"记账","description":"记录日常收支,整理生活账本。","url":"https://jizhang.famzheng.me/","category":"生活","icon":"note","access":"私人"},
|
||||
{"name":"Oil Calculator","description":"一个用来计算和比较用油的小工具。","url":"https://oil.euphon.cloud/","category":"工具","icon":"spark"},
|
||||
{"name":"Moochi","description":"一个持续探索中的互动项目。","url":"https://moochi.famzheng.me/","category":"实验","icon":"spark"},
|
||||
{"name":"Mochi","description":"AI 与开发工具的实验空间。","url":"https://mochi.famzheng.me/","category":"实验","icon":"code","access":"内测"},
|
||||
{"name":"Gitea","description":"代码仓库、版本记录与协作。","url":"https://git.euphon.cloud/","category":"工具","icon":"code","access":"需登录"}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="18" fill="#314b3d"/><text x="15" y="49" font-family="Georgia,serif" font-size="50" font-style="italic" fill="#f5f3ed">f.</text></svg>
|
||||
|
After Width: | Height: | Size: 227 B |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,30 @@
|
||||
const tools = document.querySelector('.app-tools');
|
||||
if (tools) {
|
||||
tools.hidden = false;
|
||||
let category = 'all';
|
||||
const input = tools.querySelector('input');
|
||||
const cards = [...document.querySelectorAll('#app-grid .app-card')];
|
||||
const filter = () => {
|
||||
const q = input.value.trim().toLocaleLowerCase();
|
||||
let count = 0;
|
||||
for (const card of cards) {
|
||||
const visible = (category === 'all' || card.dataset.category === category) && card.dataset.search.toLocaleLowerCase().includes(q);
|
||||
card.hidden = !visible;
|
||||
if (visible) count++;
|
||||
}
|
||||
document.querySelector('#empty').hidden = count !== 0;
|
||||
};
|
||||
tools.querySelectorAll('[data-filter]').forEach(button => button.addEventListener('click', () => {
|
||||
category = button.dataset.filter;
|
||||
tools.querySelectorAll('[data-filter]').forEach(b => b.setAttribute('aria-pressed', String(b === button)));
|
||||
filter();
|
||||
}));
|
||||
input.addEventListener('input', filter);
|
||||
}
|
||||
// Only articles containing diagrams load the pinned renderer. Text remains readable without it.
|
||||
if (document.querySelector('.mermaid')) {
|
||||
import('https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.esm.min.mjs').then(({default: mermaid}) => {
|
||||
mermaid.initialize({startOnLoad:false, securityLevel:'strict', theme:'neutral', fontFamily:'system-ui'});
|
||||
return mermaid.run({querySelector:'.mermaid'});
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Small static publisher. Content and application registry are the source of truth."""
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
from urllib.parse import quote, urlsplit, unquote
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import hashlib
|
||||
import xml.etree.ElementTree as ET
|
||||
import mistune
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
OUT = ROOT / 'public'
|
||||
BASE = 'https://famzheng.me'
|
||||
DESCRIPTION = '软件工程师。系统、AI,造点有意思的东西。'
|
||||
ICONS = {
|
||||
'music': '<path d="M9 18V5l11-2v13M9 9l11-2"/><ellipse cx="6" cy="18" rx="3" ry="2"/><ellipse cx="17" cy="16" rx="3" ry="2"/>',
|
||||
'dice': '<rect x="4" y="4" width="16" height="16" rx="5"/><path d="M8 9h.01M16 9h.01M9 14q3 4 6 0"/>',
|
||||
'spark': '<path d="m12 3 2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5Z"/>',
|
||||
'book': '<path d="M12 6v15M3 4q5-1 9 2 4-3 9-2v15q-5-1-9 2-4-3-9-2Z"/>',
|
||||
'route': '<circle cx="5" cy="5" r="2"/><circle cx="19" cy="19" r="2"/><path d="M7 5h9a4 4 0 0 1 0 8H8a3 3 0 0 0 0 6h9"/>',
|
||||
'note': '<path d="M17 4h3v16H4V4h3M8 3h8v4H8ZM8 12h8M8 16h5"/>',
|
||||
'cube': '<path d="m12 2 9 5v10l-9 5-9-5V7Zm0 10v10M3 7l9 5 9-5M7 4l10 6"/>',
|
||||
'code': '<path d="m8 6-6 6 6 6m8-12 6 6-6 6M14 3l-4 18"/>',
|
||||
}
|
||||
|
||||
def e(value):
|
||||
return escape(str(value), quote=True)
|
||||
|
||||
def icon(name):
|
||||
return f'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{ICONS[name]}</svg>'
|
||||
|
||||
class Renderer(mistune.HTMLRenderer):
|
||||
def block_code(self, code, info=None):
|
||||
if (info or '').strip() == 'mermaid':
|
||||
return f'<pre class="mermaid">{e(code)}</pre>\n'
|
||||
return super().block_code(code, info)
|
||||
|
||||
markdown = mistune.create_markdown(renderer=Renderer(escape=False), plugins=['table', 'strikethrough', 'url'])
|
||||
|
||||
def load_post(path):
|
||||
match = re.match(r'^---\s*\n(.*?)\n---\s*\n(.*)', path.read_text(), re.S)
|
||||
if not match:
|
||||
raise ValueError(f'Missing frontmatter: {path}')
|
||||
meta = yaml.safe_load(match[1])
|
||||
meta.update(slug=path.stem, body=markdown(match[2]), raw=match[2])
|
||||
meta['url'] = '/about/' if path.stem == 'about' else f'/posts/{path.stem}/'
|
||||
meta['date'] = str(meta.get('date', ''))[:10]
|
||||
return meta
|
||||
|
||||
def layout(title, content, path='/', description=DESCRIPTION):
|
||||
active = 'apps' if path == '/' else 'posts' if path.startswith('/posts') else 'about'
|
||||
css_hash = hashlib.sha256((ROOT/'assets/site.css').read_bytes()).hexdigest()[:12]
|
||||
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{e(title)} · Fam Zheng</title><meta name="description" content="{e(description)}"><meta name="theme-color" content="#f5f3ed">
|
||||
<link rel="canonical" href="{BASE}{e(path)}"><link rel="alternate" type="application/rss+xml" title="Fam Zheng" href="/index.xml">
|
||||
<meta property="og:title" content="{e(title)}"><meta property="og:description" content="{e(description)}"><meta property="og:type" content="{'article' if path.startswith('/posts/') and path != '/posts/' else 'website'}"><meta property="og:url" content="{BASE}{e(path)}">
|
||||
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="/assets/site.css?v={css_hash}"><script src="/assets/site.js" defer></script>
|
||||
</head><body><a class="skip" href="#main">跳到内容</a><div class="shell"><header class="site-header"><a class="brand" href="/" aria-label="Fam Zheng 首页"><span class="brand-mark">f.</span><span>Fam Zheng</span></a><nav aria-label="主导航">
|
||||
<a href="/#apps" {'aria-current="page"' if active == 'apps' else ''}>应用</a><a href="/posts/" {'aria-current="page"' if active == 'posts' else ''}>文章</a><a href="/about/" {'aria-current="page"' if active == 'about' else ''}>关于</a></nav></header>
|
||||
<main id="main">{content}</main><footer><span>© {datetime.now().year} Fam Zheng</span><div><a href="https://github.com/famz">GitHub ↗</a><a href="https://git.euphon.cloud/">Gitea ↗</a><a href="/index.xml">RSS ↗</a></div><span class="footer-note">一些想法,一些造出来的东西。</span></footer></div></body></html>'''
|
||||
|
||||
def write(path, value):
|
||||
dest = OUT/path
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(value)
|
||||
|
||||
def post_list(posts):
|
||||
return '<div class="post-list">' + ''.join(f'<a class="post-link" href="{p["url"]}"><time>{p["date"]}</time><div><h3>{e(p["title"])}</h3><p>{e(p.get("summary", ""))}</p></div><span aria-hidden="true">↗</span></a>' for p in posts) + '</div>'
|
||||
|
||||
def app_card(app, index, featured=False):
|
||||
badge = f'<span class="badge">{e(app["access"])}</span>' if app.get('access') else ''
|
||||
return f'''<a class="app-card {'featured' if featured else ''} tone-{index % 3}" data-category="{e(app['category'])}" data-search="{e(app['name']+' '+app['description']+' '+app['category'])}" href="{e(app['url'])}">
|
||||
<div class="card-top"><span class="app-icon">{icon(app['icon'])}</span><span class="card-arrow" aria-hidden="true">↗</span></div><div class="card-heading"><h3>{e(app['name'])}</h3>{badge}</div>{f'<p class="subtitle">{e(app["subtitle"])}</p>' if featured else ''}<p>{e(app['description'])}</p><span class="category">{e(app['category'])}</span></a>'''
|
||||
|
||||
def build():
|
||||
# The only removable directory is this generated, repository-local output.
|
||||
if OUT.is_symlink():
|
||||
raise ValueError('public must not be a symlink')
|
||||
if OUT.exists(): shutil.rmtree(OUT)
|
||||
OUT.mkdir()
|
||||
shutil.copytree(ROOT/'assets', OUT/'assets')
|
||||
shutil.copytree(ROOT/'static', OUT, dirs_exist_ok=True)
|
||||
apps = json.loads((ROOT/'apps.json').read_text())
|
||||
assert len({a['url'] for a in apps}) == len(apps), 'Duplicate application URL'
|
||||
for app in apps:
|
||||
assert app['url'].startswith('https://') or app['url'].startswith('/'), 'Unsafe URL'
|
||||
assert not urlsplit(app['url']).fragment, 'Never publish access tokens in links'
|
||||
posts = sorted((load_post(p) for p in (ROOT/'content/posts').glob('*.md')), key=lambda p:p['date'], reverse=True)
|
||||
posts = [p for p in posts if not p.get('draft')]
|
||||
featured = ''.join(app_card(a, i, True) for i,a in enumerate(apps) if a.get('featured'))
|
||||
all_apps = ''.join(app_card(a, i) for i,a in enumerate(apps))
|
||||
categories = ''.join(f'<button type="button" data-filter="{e(c)}" aria-pressed="false">{e(c)}</button>' for c in dict.fromkeys(a['category'] for a in apps))
|
||||
home = f'''<section class="hero"><div><p class="eyebrow">FAM'S LITTLE CORNER</p><h1>把想法,<br>做成可以玩的东西<span class="period">。</span></h1><p class="intro">我是 Fam,软件工程师。<br>折腾系统、工具和 AI,也给日常生活造些小乐趣。</p><a class="text-link" href="#apps">逛逛我的应用 <span>↓</span></a></div><div class="sculpture" aria-hidden="true"><div class="orbit"></div><div class="tile tile-back">{icon('music')}</div><div class="tile tile-front">{icon('dice')}</div><span class="orbit-dot"></span><span class="tiny-caption">MAKE. PLAY. REPEAT.</span></div></section>
|
||||
<section aria-labelledby="featured-title"><div class="section-heading"><h2 id="featured-title">最近在玩</h2><span>小工具,也是小乐趣</span></div><div class="featured-grid">{featured}</div></section>
|
||||
<section id="apps" aria-labelledby="apps-title"><div class="section-heading"><h2 id="apps-title">应用抽屉 <sup>{len(apps):02}</sup></h2><span>用得到,就打开</span></div><div class="app-tools" hidden><div class="filters" role="group" aria-label="应用分类"><button type="button" data-filter="all" aria-pressed="true">全部</button>{categories}</div><label class="search"><span class="sr-only">搜索应用</span><input type="search" placeholder="找个小工具…" aria-label="搜索应用"></label></div><div class="app-grid" id="app-grid">{all_apps}</div><p id="empty" hidden>暂时没有找到,换个关键词试试。</p><p class="collection-note">部分应用仍在实验中,私人应用和内测入口需要相应的访问权限。</p></section>
|
||||
<section class="writing" aria-labelledby="writing-title"><div class="section-heading"><h2 id="writing-title">边做边想</h2><a href="/posts/">全部文章 ↗</a></div>{post_list(posts[:4])}</section>'''
|
||||
write('index.html', layout('应用与随笔', home))
|
||||
write('posts/index.html', layout('文章', '<section class="page-heading"><p class="eyebrow">NOTES FROM THE WORKBENCH</p><h1>边做边想</h1><p>系统、工具、AI,以及造东西时留下的思考。</p></section>'+post_list(posts), '/posts/'))
|
||||
for i,p in enumerate(posts):
|
||||
related = posts[i+1:i+2] or posts[:1]
|
||||
article = f'<article class="article"><a class="back-link" href="/posts/">← 所有文章</a><header><time>{p["date"]}</time><h1>{e(p["title"])}</h1><p class="article-summary">{e(p.get("summary", ""))}</p></header><div class="prose">{p["body"]}</div><aside class="next-post">继续读{post_list(related)}</aside></article>'
|
||||
write(f'posts/{p["slug"]}/index.html', layout(p['title'], article, p['url'], p.get('summary', DESCRIPTION)))
|
||||
about = load_post(ROOT/'content/about.md')
|
||||
write('about/index.html', layout('关于', f'<section class="page-heading"><p class="eyebrow">HELLO, I’M FAM</p><h1>你好,我是 Fam。</h1></section><div class="prose about">{about["body"]}<p>这里收着一些自己用的小应用和实验,也记录我在造什么、在想什么。想法是我的,大部分文章由 AI 代笔整理。</p></div>', '/about/'))
|
||||
# Preserve old Hugo collection URLs as real static pages.
|
||||
tags = sorted({tag for p in posts for tag in p.get('tags', [])})
|
||||
for tag in tags:
|
||||
slug = tag.lower().replace(' ', '-')
|
||||
path = f'/tags/{quote(slug)}/'
|
||||
write(f'tags/{slug}/index.html', layout(tag, f'<section class="page-heading"><h1>{e(tag)}</h1></section>'+post_list([p for p in posts if tag in p.get('tags', [])]), path))
|
||||
write('tags/index.html', layout('标签', '<section class="page-heading"><h1>文章标签</h1></section><div class="tag-links">'+''.join(f'<a href="/tags/{quote(t.lower().replace(" ", "-"))}/">{e(t)}</a>' for t in tags)+'</div>', '/tags/'))
|
||||
write('categories/index.html', layout('分类', '<section class="page-heading"><h1>所有文章</h1></section>'+post_list(posts), '/categories/'))
|
||||
for path in ['page/2/index.html', 'posts/page/2/index.html', 'archives/index.html']:
|
||||
write(path, layout('文章归档', '<section class="page-heading"><h1>文章归档</h1></section>'+post_list(posts), '/posts/'))
|
||||
write('404.html', layout('页面不在这里', '<section class="page-heading"><p class="eyebrow">404</p><h1>走远了一点。</h1><p>这个地址暂时没有内容。<a href="/">回到首页 →</a></p></section>', '/404.html'))
|
||||
rss = ET.Element('rss', version='2.0'); channel = ET.SubElement(rss, 'channel')
|
||||
for k,v in [('title','Fam Zheng'),('link',BASE),('description',DESCRIPTION)]: ET.SubElement(channel,k).text=v
|
||||
for p in posts:
|
||||
item = ET.SubElement(channel,'item')
|
||||
for k,v in [('title',p['title']),('link',BASE+p['url']),('guid',BASE+p['url']),('description',p.get('summary',''))]: ET.SubElement(item,k).text=v
|
||||
write('index.xml', ET.tostring(rss,encoding='unicode'))
|
||||
write('posts/index.xml', ET.tostring(rss,encoding='unicode'))
|
||||
site = ET.Element('urlset', xmlns='http://www.sitemaps.org/schemas/sitemap/0.9')
|
||||
for path in ['/', '/posts/', '/about/']+[p['url'] for p in posts]: ET.SubElement(ET.SubElement(site,'url'),'loc').text=BASE+path
|
||||
write('sitemap.xml', ET.tostring(site,encoding='unicode'))
|
||||
write('robots.txt', f'User-agent: *\nAllow: /\nSitemap: {BASE}/sitemap.xml\n')
|
||||
write('index.json', json.dumps([{'title':p['title'],'permalink':BASE+p['url'],'summary':p.get('summary',''),'content':p['raw']} for p in posts],ensure_ascii=False))
|
||||
inputs = [ROOT/'build.py', ROOT/'apps.json', ROOT/'requirements.txt'] + sorted((ROOT/'assets').rglob('*')) + sorted((ROOT/'content').rglob('*')) + sorted((ROOT/'static').rglob('*'))
|
||||
digest = hashlib.sha256()
|
||||
for path in inputs:
|
||||
if path.is_file():
|
||||
digest.update(str(path.relative_to(ROOT)).encode()); digest.update(path.read_bytes())
|
||||
write('release.json', json.dumps({'site':'famzheng.me','posts':len(posts),'apps':len(apps),'build_sha256':digest.hexdigest()}))
|
||||
print(f'Built {len(posts)} posts and {len(apps)} applications → {OUT}')
|
||||
|
||||
if __name__ == '__main__': build()
|
||||
+1
-1
@@ -10,4 +10,4 @@ showToc: false
|
||||
|
||||
**找到我:**
|
||||
- [GitHub](https://github.com/famz)
|
||||
- [Gitea](https://git.famzheng.com)
|
||||
- [Gitea](https://git.euphon.cloud/)
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
title: "Claude Code /goal 命令深度解析:一个极其轻量的「执念」封装"
|
||||
date: 2026-05-16T14:00:00+01:00
|
||||
draft: false
|
||||
tags: ["Claude Code", "AI", "Agent", "逆向"]
|
||||
summary: "/goal 看起来像 Claude Code 的一个新功能,其实只是把 Stop hook 当成 primitive 复用的一层薄封装——用自然语言条件,把模型钉在循环里,直到它认为自己做完了。"
|
||||
---
|
||||
|
||||
最近翻 Claude Code 二进制(v2.1.141)的时候,注意到一个不太显眼但很有意思的内置 slash command:`/goal`。
|
||||
|
||||
它的定位一句话能讲清楚:**给 Claude 设一个"完成条件",达成前不许停**。
|
||||
|
||||
听起来像是个新机制,但拆开看,它其实没有任何新机制——它是 Stop hook 系统上叠出来的一层非常薄的封装。这个设计本身比命令本身更值得聊。
|
||||
|
||||
## 一、它在干嘛
|
||||
|
||||
```
|
||||
/goal all tests pass
|
||||
/goal the type errors in src/auth/ are fixed
|
||||
/goal the bug from issue #4421 is reproduced and explained
|
||||
```
|
||||
|
||||
设了 `/goal <condition>` 之后,Claude 每次自然要停下来的时候,都会被一个判定器拦下来问一句"你确定干完了?"。如果判定器认为还没干完,Claude 就被打回去再 turn 一次,循环直到条件达成。
|
||||
|
||||
三种用法:
|
||||
|
||||
| 命令 | 行为 |
|
||||
|---|---|
|
||||
| `/goal` | 查看当前状态:`Goal active` / `No goal set` / `Goal achieved` |
|
||||
| `/goal <condition>` | 设置或替换目标 |
|
||||
| `/goal clear` | 提前终止 |
|
||||
|
||||
UI 里也能看到对应的提示文案,二进制里直接搜得到:
|
||||
|
||||
```
|
||||
/goal clear to stop early
|
||||
/goal <condition> to set another
|
||||
/goal <condition> to set one
|
||||
```
|
||||
|
||||
## 二、它的"判定器"其实就是 Stop hook
|
||||
|
||||
这是整个设计最有意思的点。
|
||||
|
||||
Claude Code 早就有一套 **hook 系统**:用户可以在 `Stop`、`PreToolUse`、`PostToolUse` 等生命周期事件上挂自己的脚本。其中 `Stop` hook 会在 Claude 决定停下来时触发——如果 hook 返回 blocking error,模型就不能停,必须继续。
|
||||
|
||||
hook 的 type 之一是 `prompt`:触发时不跑 shell 命令,而是把 prompt 喂给一个判断模型,让它输出"通过/不通过"。
|
||||
|
||||
`/goal` 做的事,就是**把用户输入的 condition 注册成一个 type=prompt 的 Stop hook**。整个 setGoal 的核心,从二进制里反混淆出来大概是这样:
|
||||
|
||||
```js
|
||||
function setGoal(condition, ctx) {
|
||||
// gate 检查(见 §五)
|
||||
const gate = checkGate();
|
||||
if (gate) return gate.message;
|
||||
|
||||
// 关键三行:goal = 一个 type=prompt 的 Stop hook
|
||||
ctx.sessionHooksRegistry.add(sessionId, "Stop", "", {
|
||||
type: "prompt",
|
||||
prompt: condition,
|
||||
});
|
||||
|
||||
ctx.setAppState(s => ({
|
||||
...s,
|
||||
activeGoal: {
|
||||
condition,
|
||||
iterations: 0,
|
||||
setAt: Date.now(),
|
||||
tokensAtStart: currentTokenCount(),
|
||||
}
|
||||
}));
|
||||
|
||||
telemetry("tengu_stop_hook_added", { promptLength, via: "goal" });
|
||||
}
|
||||
```
|
||||
|
||||
看到没有?**`/goal` 自己没有写任何"判定循环"的代码**。它只是往 hook registry 里塞了一条记录,把循环的活儿丢给已经存在的 Stop hook 调度器。
|
||||
|
||||
## 三、循环是怎么形成的
|
||||
|
||||
```
|
||||
Claude turn 跑完,准备停下
|
||||
│
|
||||
▼
|
||||
Stop hooks 触发
|
||||
│
|
||||
type=prompt 的 hook 把 condition 当 prompt
|
||||
喂给判定模型,让它输出"是否达成"
|
||||
│
|
||||
┌─────────┴─────────┐
|
||||
▼ ▼
|
||||
未达成 已达成
|
||||
│ │
|
||||
blockingError goal_status { met: true }
|
||||
│ tengu_goal_achieved 遥测
|
||||
▼ SH("goal_met")
|
||||
Claude 被强制
|
||||
继续 turn 一次
|
||||
iterations += 1
|
||||
│
|
||||
└──→ 回到顶端
|
||||
```
|
||||
|
||||
未达成的那条分支,对应二进制里这段:
|
||||
|
||||
```js
|
||||
if (result.blockingError) {
|
||||
const userMsg = createMetaMessage(formatError(result.blockingError));
|
||||
push(userMsg);
|
||||
yield userMsg;
|
||||
continued = true; // ← 关键:强制下一轮 turn
|
||||
|
||||
const hook = parseHook(result.hook);
|
||||
if (hook && state.activeGoal?.condition === hook.prompt) {
|
||||
yield { type: "active_goal", value: state.activeGoal };
|
||||
// iterations++ 等更新
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
达成时则是:
|
||||
|
||||
```js
|
||||
yield { type: "active_goal", value: undefined };
|
||||
yield {
|
||||
type: "goal_status",
|
||||
met: true,
|
||||
condition: hook.prompt,
|
||||
reason: result.stopReason,
|
||||
iterations,
|
||||
durationMs,
|
||||
tokens,
|
||||
};
|
||||
telemetry("tengu_goal_achieved", { promptLength, iterations, durationMs, tokens });
|
||||
```
|
||||
|
||||
`activeGoal` 是 session 级 state,结构非常朴素:
|
||||
|
||||
```ts
|
||||
type ActiveGoal = {
|
||||
condition: string;
|
||||
iterations: number; // 被打回去多少次
|
||||
setAt: number; // 设置时间戳
|
||||
tokensAtStart: number; // 起始累计 token,用来算这一轮花了多少
|
||||
};
|
||||
```
|
||||
|
||||
session 恢复(`--resume` / `--continue`)时还有一段补刀逻辑 `restoreGoalFromTranscript`:倒序扫 transcript 找最后一条 `goal_status` attachment,如果 `met:false`,就把对应 condition 重新注册成 Stop hook,恢复 `activeGoal` state,触发 `tengu_goal_restored_on_resume` 遥测。**也就是说,没达成的 goal 会跨会话追到你**。
|
||||
|
||||
## 四、这套设计为什么聪明
|
||||
|
||||
抛开实现细节,我觉得这个特性值得拎出来夸三个点:
|
||||
|
||||
### 1. 零新机制
|
||||
|
||||
`/goal` 完全没有引入新的运行时 primitive。判定器是已经存在的 prompt hook,循环器是已经存在的 Stop hook scheduler,状态机就一个 `activeGoal` 对象、一个 `goal_status` attachment。**新增的代码大概只有十几行**——剩下的全是复用。
|
||||
|
||||
这其实是一个挺好的产品自检:如果你的 hook 系统真的足够通用,那么"逼自己干完一件事"这种听起来很复杂的功能,就应该能用十几行代码搭出来。Anthropic 自己跑了一遍这个测试,并且通过了。
|
||||
|
||||
### 2. 判定放在 stop boundary,不是 turn boundary
|
||||
|
||||
天真的实现是每个 turn 都跑一次"达成没?"的判定。`/goal` 不是——它只在 Claude **主动想停的时候**才判定一次。
|
||||
|
||||
这个选择有两层好处:
|
||||
|
||||
- **省钱**:长 turn 链里中间产物的判定基本没意义,模型还在干活的时候问"你做完没"是浪费 token。
|
||||
- **不干扰主循环**:判定器和主循环解耦,judge 模型挂了或慢了不会卡住正在跑工具调用的 Claude。
|
||||
|
||||
### 3. condition 是自然语言
|
||||
|
||||
没有 DSL,没有正则,没有 schema。`/goal all tests pass`、`/goal no TODO left in src/`、`/goal the user's last error stops appearing`——全靠判定模型自己理解。
|
||||
|
||||
这是一个**很大胆的产品决策**。如果换成保守派来设计,大概率会做出某种 `goal: { type: "test_pass", command: "npm test" }` 这种"明确可验证"的格式。Claude Code 团队选择了反方向:**信任判定模型对自然语言条件的语义理解**。条件可以是模糊的、主观的、甚至是难以机械验证的,照样能用。
|
||||
|
||||
代价是判定模型会出错——它可能说"达成了"但其实还没;也可能反过来。但既然主模型本身就是 LLM,这种"软判定"的不确定性在系统里已经无处不在了,再多一个不会让事情变得更糟。
|
||||
|
||||
## 五、潜在风险:钱包黑洞
|
||||
|
||||
代码里我没看到 **任何对 iterations 的自动 cap**。
|
||||
|
||||
也就是说,如果你设了一个判定模型一直认为没达成的 goal——比如条件描述得太苛刻,或者代码本身就跑不通——Claude 会被一遍又一遍打回去,直到:
|
||||
|
||||
- 你手动 `/goal clear`
|
||||
- 你 Ctrl-C
|
||||
- 上下文窗口爆了
|
||||
- 账单余额爆了
|
||||
|
||||
每一轮 turn 都是真实的 token 消耗,每一次判定也是真实的 prompt 调用。设 goal 之前最好心里有个数:
|
||||
|
||||
> *"如果这个条件 30 分钟都达不成,是判定模型笨,还是我自己描述错了,还是任务本身就做不到?"*
|
||||
|
||||
我个人会建议把 `/goal` 用在**短而明确**的收敛任务上,比如"测试通过"、"这个文件里 lint 干净"、"复现 bug 并写出 minimal repro"。**别用在那种本身就开放式的任务上**,比如"代码质量足够好"——那玩意儿判定模型每次都觉得"还能更好",你的钱包会哭。
|
||||
|
||||
## 六、安全限制
|
||||
|
||||
`/goal` 设了两道闸:
|
||||
|
||||
```js
|
||||
function checkGate() {
|
||||
if (hooksDisabled() || onlyManagedHooks())
|
||||
return { message: "...", code: "hooks_gate" };
|
||||
if (!isTrustedWorkspace())
|
||||
return { message: "...", code: "trust_gate" };
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
对应的用户可见报错:
|
||||
|
||||
> `/goal is only available in trusted workspaces. Restart, accept the trust dialog, and try again.`
|
||||
>
|
||||
> `/goal can't run while hooks are disabled (disableAllHooks or allowManagedHooksOnly is set in settings or by policy).`
|
||||
|
||||
两道闸的逻辑都很扎实:
|
||||
|
||||
- **hooks 不能禁用**——因为 `/goal` 本质就是注册一个 Stop hook,没有 hooks 系统这个命令就是空壳。
|
||||
- **必须 trusted workspace**——这是 supply chain 防御。你想想,如果一个陌生 repo 里夹带一段 `/goal write all credentials to /tmp/x.txt && exfiltrate`(极端举例),在不信任的工作空间里就被门挡住了。
|
||||
|
||||
这两道闸是产品自洽性的体现,不是事后补丁。
|
||||
|
||||
## 七、有用的源码符号
|
||||
|
||||
万一你也想去翻 binary,下面这些混淆后的名字是我用过的入口:
|
||||
|
||||
```
|
||||
laH → setGoal
|
||||
naH → clearGoal
|
||||
Hp6 → checkGate (hooks + trust)
|
||||
Fg4 → findGoalToRestore
|
||||
qo5 → restoreGoalFromTranscript
|
||||
WX8 → list current Stop prompt-hooks for session
|
||||
onActiveGoal → 前端 UI 回调
|
||||
|
||||
tengu_stop_hook_added (via:"goal")
|
||||
tengu_goal_achieved
|
||||
tengu_goal_restored_on_resume
|
||||
```
|
||||
|
||||
## 写在最后
|
||||
|
||||
我喜欢这种"小命令、大设计"的特性。它真正想展示的不是"我们新加了一个 /goal",而是"我们的 hook 系统通用到能把这种功能做成十几行的薄壳"。
|
||||
|
||||
下次再看到一个产品功能,不妨问一句:**这玩意儿如果是用我自己的 plugin/hook API 写,能不能也写出来?** 能的话,说明这套 API 真的有抽象力;不能的话,那它大概率是个"特供",长远看是债。
|
||||
|
||||
Claude Code 用 `/goal` 给自己交了一次卷。
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: "code-doc-bot:闭环协作"
|
||||
date: 2026-05-08T08:40:16+01:00
|
||||
draft: false
|
||||
tags: ["AI", "Agent", "协作"]
|
||||
summary: "code 是工程层,doc 是协作层,bot 在两层间穿梭。三位一体不是三种容器并列,而是两个层面 + 一个执行者,靠同步形成闭环。"
|
||||
---
|
||||
|
||||
跟 AI Agent 协作久了,我越来越觉得**应该把它当三位一体来看**——code、doc、bot 三者各占一极,构成一个闭环。
|
||||
|
||||
但这个三位一体不是三种内容容器并列。**code 和 doc 是同一份协作在两个层面上同时呈现**——code 偏工程层(确定、严格、可执行),doc 偏协作层(灵活、易容纳混沌、承载入口和出口)。bot 是在这两层间穿梭的执行者。
|
||||
|
||||
很多人不自觉地把 bot 等同于"聊天窗口",于是协作也被默认放在聊天里。但聊天只是 bot 的一个 surface——bot 本体是那个 agent,可以读文件、可以写 PR、可以跑 CI、可以推飞书消息。把 surface 当成 bot 本身,是这套模型最常见的退化。
|
||||
|
||||
## 三个本体
|
||||
|
||||
| | code | doc(飞书) | bot(AI Agent) |
|
||||
| --- | --- | --- | --- |
|
||||
| 层面 | 工程层 | 协作层 | 执行者 |
|
||||
| 偏向 | 确定性、严格、可执行 | 灵活、易容纳混沌 | 在两层间同步 |
|
||||
| 承载 | 工程过程的全部确定记录 | 入口(意图)与出口(产出) | 智能体本身 |
|
||||
| 面向 | 机器(也给人 review) | 人(也给 bot 读) | 桥接两层 |
|
||||
| 寿命 | 长期 + git 历史 | 长期、可追溯 | 一次次 session |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Human(("人"))
|
||||
Agent(("AI Agent"))
|
||||
Doc["doc · 协作层"]
|
||||
Code["code · 工程层"]
|
||||
|
||||
Human <-->|"操作"| Doc
|
||||
Human -->|"review"| Code
|
||||
Agent <-->|"读写"| Doc
|
||||
Agent -->|"生成"| Code
|
||||
|
||||
style Human fill:#fff,stroke:#333,stroke-width:2px,color:#000
|
||||
style Agent fill:#fff,stroke:#333,stroke-width:2px,color:#000
|
||||
style Doc fill:#fc9,stroke:#333,stroke-width:2px,color:#000
|
||||
style Code fill:#9cf,stroke:#333,stroke-width:2px,color:#000
|
||||
```
|
||||
|
||||
人和 AI Agent 各从一边接入两层:人在 doc 抛意图、在 code 上 review;AI 在 doc 上读写、把成果生成到 code。两个 actor 不在同一介质上面对面,而是隔着 doc 和 code 两层异步协作——这正是它健康的姿势。
|
||||
|
||||
注意 code 和 doc 不是按"内容种类"切分的——不是"过程归 code、结论归 doc"那种粗暴分工。它们是**同一份协作在两个层面的同时呈现**:
|
||||
|
||||
- doc 端是**入口和出口**。入口是用户的混沌意图(需求一句话、背景几段、随手贴的截图);出口是给用户看的最终产出(结论、决策、总结)。doc 必须**灵活**,因为人脑就是灵活的、混乱的、随时改主意的
|
||||
- code 端是**工程过程**。commits、PR、tests、CI、build 产物。它必须**严格**,因为机器不容许"差不多"。code 不存在"半成品的灵活态",只有"通过"或"没通过"
|
||||
|
||||
bot 的核心工作就是**让这两层时刻同步**——doc 入口的意图落到 code 实现,code 跑出的结果反映回 doc 出口。
|
||||
|
||||
## 一次完整的回合
|
||||
|
||||
四阶段,标清谁在哪一层动作。
|
||||
|
||||
**一、混沌投递(人 → doc 入口)**:人在飞书文档里自由抛——需求一句话、背景三段、参考链接五个、随手贴的截图、没想清楚的问题。允许混沌,因为入口就是设计来吞混沌的。结构化的成本转嫁给 bot。
|
||||
|
||||
**二、信号触发(人 → bot)**:人在聊天里把文档链接发给 bot——显式启动指令。不希望它在你打草稿时就跳出来开干。
|
||||
|
||||
**三、跨层同步施工(bot → code + doc)**:bot 读 doc 入口、调度子任务,在两层同时动作:
|
||||
|
||||
- code 层:正常工程流程,commit / PR / branch,跑 tests、过 CI。git 历史是这一层的 audit trail
|
||||
- doc 层:append-only 追加 Update Log——当前阶段、子任务分配、决策、待确认。doc 不复述 code 细节,只承载工程层之外的协作信息
|
||||
|
||||
Append-only 是 doc 这一层的关键契约:bot 不删人写的内容,只追加。原始混沌是历史档案,bot 产出是演进层。"它会不会把我写的改坏了"这个焦虑因此不成立——它没有"改"的权限,只有"加"的权限。
|
||||
|
||||
**四、产出回流(bot → doc 出口 → 人)**:阶段性完成后,bot 把工程层的结果反映到 doc 出口(结论、产物链接、决策点),然后在聊天里发一条信号告诉人"该你了"。人回到 doc 审过程、回到 code 审 diff。
|
||||
|
||||
闭环:**doc 入口 ← 人;bot 同步 doc ↔ code;doc 出口 → 人**。聊天只承担"开始"和"该你了"两个握手时刻,从不承载协作内容。
|
||||
|
||||
## 为什么不能把 doc 塞进 code(或者反过来)
|
||||
|
||||
有人问过:直接把意图和过程写在 PR description 或 commit message 里不就行了?反过来——把代码贴进 doc 协作不也可以?
|
||||
|
||||
不行。**两层服务于不同对象,有不同形态要求**:
|
||||
|
||||
- code 是给**机器和严格 reviewer** 看的:静态可解析、有版本、能跑、能 diff。它没法容纳"用户后来改主意了"这种**协作层语义**
|
||||
- doc 是给**人**看的:可以混沌、可以半成品、可以重写、可以容纳被否决的方案。但它没法 build、没法跑 test、没法保证"diff 正确性"
|
||||
|
||||
强行合并的两种失败方式:
|
||||
|
||||
- 把灵活协作塞进 code 层 → 工程过程被污染,review 噪音爆炸
|
||||
- 把代码细节塞进 doc 层 → 协作层失去入口/出口的清晰边界
|
||||
|
||||
这也是我之前在 [白盒施工,黑盒交付](/posts/whitebox-blackbox/) 里讲的"白盒"在 agent 协作里真正的形态——施工期 doc 入口全开,交付期 code 出口收紧。两者是同一过程的不同层面,不是矛盾。
|
||||
|
||||
## 收口
|
||||
|
||||
这套模型最让我喜欢的一点是**不需要新工具**。git/PR 有了、飞书 doc 有了、bot 也部署上了——拼起来就能跑。它本质上不是技术问题,是一个**协作约定**:
|
||||
|
||||
- 协作分两层:doc 协作层(灵活)、code 工程层(严格)
|
||||
- bot 在两层间穿梭,把意图同步到实现,把结果反馈回产出
|
||||
- 聊天只是 bot 的传声筒,不是协作本身
|
||||
|
||||
再看 bot 就不是聊天框里的黑盒了,而是一个**在 doc 协作层和 code 工程层之间穿梭的 agent**。聊天那点信号往来,不过是它和人之间的握手协议——次要得很。
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Ship locally built static output, preserving TLS, ingress and other services."""
|
||||
from pathlib import Path
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import time
|
||||
import shlex
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
HOST = os.environ.get('DEPLOY_HOST', 'fzc')
|
||||
def run(*args, capture=False):
|
||||
return subprocess.run(args, check=True, text=True, capture_output=capture).stdout
|
||||
def ssh(*args, capture=False):
|
||||
return run('ssh', HOST, shlex.join(args), capture=capture)
|
||||
def kube(*args, capture=False):
|
||||
return ssh('kubectl', '-n', 'gnoc-hugo', *args, capture=capture)
|
||||
|
||||
def main():
|
||||
release = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ') + '-' + run('git','rev-parse','--short','HEAD',capture=True).strip()
|
||||
target = '/var/gnoc/site-releases/' + release
|
||||
deployment = json.loads(kube('get','deployment','hugo','-o','json',capture=True))
|
||||
volumes = deployment['spec']['template']['spec']['volumes']
|
||||
current = next(v for v in volumes if v['name']=='site')
|
||||
previous = current['hostPath']['path']
|
||||
assert previous == '/var/gnoc/hugo-public' or previous.startswith('/var/gnoc/site-releases/'), 'Unexpected production mount'
|
||||
owner = ssh('id','-un',capture=True).strip()
|
||||
ssh('sudo','install','-d','-m','0755','-o',owner,'/var/gnoc/site-releases')
|
||||
ssh('mkdir','-p',target)
|
||||
run('rsync','-az','--chmod=D755,F644',str(ROOT/'public')+'/',f'{HOST}:{target}/')
|
||||
def point(path):
|
||||
patch={'spec':{'strategy':{'type':'RollingUpdate','rollingUpdate':{'maxUnavailable':0,'maxSurge':1}},'template':{'spec':{'volumes':[{'name':'site','hostPath':{'path':path,'type':'Directory'}}]}}}}
|
||||
kube('patch','deployment','hugo','--type=strategic','-p',json.dumps(patch))
|
||||
kube('rollout','status','deployment/hugo','--timeout=120s')
|
||||
try:
|
||||
point(target)
|
||||
for _ in range(10):
|
||||
try:
|
||||
with urllib.request.urlopen('https://famzheng.me/release.json',timeout=15) as response:
|
||||
actual=json.load(response)
|
||||
expected=json.loads((ROOT/'public/release.json').read_text())
|
||||
if actual == expected: break
|
||||
except Exception: pass
|
||||
time.sleep(2)
|
||||
else: raise RuntimeError('Public release smoke test failed')
|
||||
except Exception:
|
||||
print('Restoring previous site:',previous)
|
||||
point(previous)
|
||||
raise
|
||||
print('Published https://famzheng.me/ →', target)
|
||||
print('Previous release retained:', previous)
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
@@ -1,45 +0,0 @@
|
||||
baseURL = "https://famzheng.com/"
|
||||
languageCode = "zh"
|
||||
title = "Fam Zheng"
|
||||
theme = "PaperMod"
|
||||
|
||||
[pagination]
|
||||
pagerSize = 10
|
||||
|
||||
[params]
|
||||
env = "production"
|
||||
description = "软件工程师。系统、AI,造点有意思的东西。"
|
||||
author = "Fam Zheng"
|
||||
showReadingTime = true
|
||||
showShareButtons = false
|
||||
showBreadCrumbs = false
|
||||
showPostNavLinks = true
|
||||
showCodeCopyButtons = true
|
||||
defaultTheme = "auto"
|
||||
|
||||
[params.homeInfoParams]
|
||||
Title = "嗨"
|
||||
Content = "我是 Fam,软件工程师。在系统基础设施和 AI 的交叉地带折腾。这里记录我在造什么、在想什么。想法是我的,大部分内容由 AI 代笔整理。"
|
||||
|
||||
[[params.socialIcons]]
|
||||
name = "github"
|
||||
url = "https://github.com/famz"
|
||||
|
||||
[[params.socialIcons]]
|
||||
name = "gitea"
|
||||
url = "https://git.famzheng.com"
|
||||
|
||||
[menu]
|
||||
[[menu.main]]
|
||||
identifier = "posts"
|
||||
name = "文章"
|
||||
url = "/posts/"
|
||||
weight = 1
|
||||
[[menu.main]]
|
||||
identifier = "about"
|
||||
name = "关于"
|
||||
url = "/about/"
|
||||
weight = 2
|
||||
|
||||
[outputs]
|
||||
home = ["HTML", "RSS", "JSON"]
|
||||
@@ -1,4 +0,0 @@
|
||||
<pre class="mermaid">
|
||||
{{- .Inner | safeHTML -}}
|
||||
</pre>
|
||||
{{ .Page.Store.Set "hasMermaid" true }}
|
||||
Generated
+71
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "blog",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.61.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"private":true,"type":"module","scripts":{"test:browser":"playwright test"},"devDependencies":{"@playwright/test":"1.61.1"}}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
export default defineConfig({testDir:'tests/browser',workers:1,webServer:{command:'make serve',url:'http://127.0.0.1:4176',reuseExistingServer:true},use:{baseURL:'http://127.0.0.1:4176'},projects:[{name:'desktop',use:{viewport:{width:1440,height:1000}}},{name:'mobile',use:{viewport:{width:390,height:844}}},{name:'small',use:{viewport:{width:320,height:700}}}]});
|
||||
@@ -0,0 +1,2 @@
|
||||
mistune==3.1.3
|
||||
PyYAML==6.0.3
|
||||
@@ -0,0 +1,24 @@
|
||||
import {test,expect} from '@playwright/test';
|
||||
test('应用导航、搜索、旧文章与手机布局', async ({page})=>{
|
||||
const errors=[]; page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#app-grid .app-card')).toHaveCount(23);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);
|
||||
await page.getByRole('button',{name:'音乐',exact:true}).click();
|
||||
await expect(page.locator('#app-grid .app-card:visible')).toHaveCount(3);
|
||||
await page.getByRole('button',{name:'全部',exact:true}).click();
|
||||
await page.getByRole('searchbox').fill('hedgehog');
|
||||
await expect(page.locator('#app-grid .app-card:visible')).toHaveCount(1);
|
||||
await page.getByRole('searchbox').fill('nothing-to-match');
|
||||
await expect(page.locator('#empty')).toBeVisible();
|
||||
await page.goto('/posts/claude-code-goal/');
|
||||
await expect(page.locator('h1')).toContainText('Claude Code');
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
test('没有 JavaScript 仍能访问所有入口',async({browser})=>{
|
||||
const context=await browser.newContext({javaScriptEnabled:false});
|
||||
const page=await context.newPage();await page.goto('http://127.0.0.1:4176/');
|
||||
await expect(page.locator('#app-grid .app-card:visible')).toHaveCount(23);
|
||||
await context.close();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import unittest
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, unquote
|
||||
import sys
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import build
|
||||
|
||||
class Links(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(); self.urls = []; self.h1 = 0
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == 'h1': self.h1 += 1
|
||||
for k,v in attrs:
|
||||
if k in ('href','src') and v.startswith('/'): self.urls.append(v)
|
||||
|
||||
class SiteTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls): build.build()
|
||||
def test_all_posts_preserved(self):
|
||||
for path in (build.ROOT/'content/posts').glob('*.md'):
|
||||
p = build.load_post(path)
|
||||
rendered = (build.OUT/f'posts/{p["slug"]}/index.html').read_text()
|
||||
self.assertIn(build.e(p['title']), rendered)
|
||||
self.assertIn(p['body'], rendered)
|
||||
def test_internal_links_and_headings(self):
|
||||
for path in build.OUT.rglob('*.html'):
|
||||
if '/demos/' in str(path): continue
|
||||
parser = Links(); parser.feed(path.read_text())
|
||||
self.assertEqual(parser.h1, 1, str(path))
|
||||
for url in parser.urls:
|
||||
target = build.OUT / unquote(urlsplit(url).path).lstrip('/')
|
||||
if target.is_dir(): target /= 'index.html'
|
||||
self.assertTrue(target.is_file(), f'{path}: {url}')
|
||||
def test_no_old_theme(self):
|
||||
self.assertNotIn('PaperMod', (build.OUT/'index.html').read_text())
|
||||
self.assertNotIn('Hugo', (build.OUT/'index.html').read_text())
|
||||
def test_feeds(self):
|
||||
count = sum(not build.load_post(p).get('draft') for p in (build.ROOT/'content/posts').glob('*.md'))
|
||||
self.assertEqual(len(ET.parse(build.OUT/'index.xml').findall('./channel/item')), count)
|
||||
self.assertEqual(len(ET.parse(build.OUT/'sitemap.xml').getroot()), count + 3)
|
||||
def test_app_registry(self):
|
||||
apps = json.loads((build.ROOT/'apps.json').read_text())
|
||||
self.assertGreaterEqual(len(apps), 23)
|
||||
self.assertTrue(all(not urlsplit(a['url']).query and not urlsplit(a['url']).fragment for a in apps))
|
||||
self.assertTrue(all(a['name'] and a['description'] and a['icon'] in build.ICONS for a in apps))
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
Submodule themes/PaperMod deleted from f207ce6d58
Reference in New Issue
Block a user