给静态博客造一个"AI 透镜":零 token、零依赖的实现全过程
我的每篇文章顶部有一个 AI 透镜卡片——导读、30 秒速读、向你提问。它不调用任何 AI 接口,断网也能用。这篇一步步讲它是怎么做出来的。
我的博客每篇文章顶部有一个卡片,叫 AI 透镜:三个标签——导读、30 秒速读、向你提问——点击切换,文字以打字机效果缓缓浮现。
不少朋友看到后的第一反应是:”这需要 token 吧?长期挂在网上,API key 怎么藏?”
答案是:不需要任何 token,永远不需要。这篇把它从设计到代码一步步拆开。
第 0 步:想清楚 AI 发生在什么时候
这个功能的全部秘密是一次角色调换:
AI 的智能发生在写作时,不是阅读时。
传统思路是读者打开页面 → 前端调大模型 API → 生成摘要。这条路对个人博客是死路:要暴露 key 或者搭中转服务、每次访问都花钱、API 一改版功能就坏、断网就废。
调换之后:我发布文章之前,把草稿丢给 AI,让它生成三段文字(导读、速读、提问),然后把这三段文字当作文章的一部分写进源文件。之后的一切——构建、部署、阅读——都只是在搬运静态文本。
代价是内容不是实时的。但博客文章发布后本来就不怎么变,”实时”在这里是伪需求。
第 1 步:设计数据结构(front matter)
Jekyll 的每篇文章头部有一段 YAML front matter。给它加一个自定义字段 ai_lens:
1
2
3
4
5
6
7
8
9
10
---
title: 复眼人-吴明益
description: 一本充满想象力的小说……
ai_lens:
digest: 一本充满想象力的小说,魔幻现实主义色彩的生态文学。多视角叙事从孤岛部落开始……
flash: 吴明益用多个第一视角编织了一座孤岛与一片海……所有线索汇向同一个问题……
questions:
- 如果你能拥有鲸鱼的眼睛一天,你最想看什么?
- 你生活里有没有一座自己造的"孤岛"?
---
三个字段对应三个标签:digest(导读)、flash(30 秒速读)、questions(问题数组)。都是可选的——没有 flash 就不渲染那个标签,整个 ai_lens 都没有时降级用 description,功能自动瘦身而不是报错。
第 2 步:构建期烘焙成 JSON
Jekyll 构建时,用 Liquid 把 front matter 里的数据序列化成一段静态 JSON,埋进页面。Chirpy 主题预留了 _includes/metadata-hook.html 扩展点,加三行:
1
2
3
{% if page.ai_lens %}
<script type="application/json" id="ai-lens-data">{{ page.ai_lens | jsonify }}</script>
{% endif %}
两个细节:
type="application/json"的 script 标签不会被浏览器执行,它只是一块惰性的数据容器,这是页面内嵌数据的标准做法jsonify过滤器负责转义,中文、引号、换行都不用自己操心
构建完成后,每篇文章的 HTML 里就带着自己的透镜数据,和正文一样是纯静态字节。
第 3 步:前端读取与降级
浏览器里的 JS 做的第一件事是把数据捞出来,并且处处留退路:
1
2
3
4
5
6
7
8
9
10
11
12
13
var article = document.querySelector('main > article');
var content = article && article.querySelector(':scope > .content');
// 只在文章页注入:标签页、关于页没有 header,直接返回
if (!content || !article.querySelector(':scope > header')) return;
var lens = null;
var lensEl = document.getElementById('ai-lens-data');
if (lensEl) {
try { lens = JSON.parse(lensEl.textContent); } catch (e) { lens = null; }
}
// 降级链:ai_lens.digest → meta description → 不渲染
var digest = (lens && lens.digest) || metaDescription;
if (!digest || digest.length < 8) return;
降级链是这个功能”十年不坏”的关键:JSON 解析失败→用 description;description 也没有→整个卡片不出现。任何一环坏掉,读者只是少看到一个卡片,正文永远完好。
第 4 步:动态创建卡片
卡片不写在模板里,而是 JS 动态插入正文最前面——这样 JS 被禁用时页面干干净净,没有空壳:
1
2
3
4
5
6
7
8
9
10
var box = document.createElement('aside');
box.className = 'ai-digest';
box.innerHTML =
'<div class="ai-digest-label"><span class="ai-dot"></span>AI 透镜' +
'<span class="ai-lens-tabs">' +
'<button class="ai-lens-tab active" data-lens="digest">导读</button>' +
(lens.flash ? '<button class="ai-lens-tab" data-lens="flash">30 秒速读</button>' : '') +
(lens.questions ? '<button class="ai-lens-tab" data-lens="questions">向你提问</button>' : '') +
'</span></div><div class="ai-digest-text"></div>';
content.insertBefore(box, content.firstChild);
注意标签按钮是按数据有无条件渲染的:哪篇文章没写 flash,那篇就没有”30 秒速读”按钮。
第 5 步:打字机效果
“AI 感”的一半来自打字机。实现朴素到让人放心:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function show(kind) {
if (timer) clearInterval(timer);
target.innerHTML = '';
if (kind === 'questions') { // 问题列表直接渲染,不打字
lens.questions.forEach(function (q) { /* 逐条 append li */ });
return;
}
var t = kind === 'flash' ? lens.flash : digest;
if (reduced) { target.textContent = t; return; } // 减动效用户直接显示全文
var i = 0;
timer = setInterval(function () {
target.textContent = t.slice(0, ++i);
if (i >= t.length) clearInterval(timer);
}, 24);
}
三个经验:
- 切换标签先
clearInterval,否则上一段的定时器会和新一段打架,文字闪烁错乱 prefers-reduced-motion的用户直接显示全文——打字机是装饰,不是内容- 24ms 一个字符是中文的舒适速度;英文可以更快,因为单词自带断句节奏
样式上,卡片用一圈微弱的渐变光边和一个呼吸的小圆点暗示”这来自 AI”,克制就好——它是页边的一枚导读签,不是弹窗。
第 6 步:写作流程闭环
最后是流程:每写完一篇草稿,丢给任意大模型,提示词固定:
阅读这篇文章,输出 YAML:digest(60 字以内导读)、flash(120 字以内的一段话速读,有观点密度)、questions(2-3 个促使读者联系自身的开放问题)。
把输出粘进 front matter,构建,发布。全程 AI 只在我的写作环节出现一次,之后这篇文章就带着它的透镜独立生存了——没有 API 调用、没有 token、没有第三方服务,读者断网都能用。
结语
这个功能教我的东西比它本身大:判断一个”AI 功能”该不该做进产品,先问一句——这里的智能必须发生在运行时吗?很多答案是否定的。能挪到构建期的智能,就是能挪到成本、风险、依赖都为零的地方。
十年后大模型 API 不知换了几轮协议,而这个卡片还会在这里,一个字一个字地浮现。