国庆节10月1日
--


从零拆解博客AI摘要功能的完整实现:构建时调用千问API批量生成摘要、前端打字机动画组件、IntersectionObserver懒触发、以及标点停顿节奏控制,附全部源码。

这篇文章会把我博客里「AI 摘要」功能的完整实现拆开来讲。整个功能由两部分组成:一个构建时脚本负责调用 AI 生成摘要写入文章 frontmatter,一个前端组件负责把摘要以打字机动画的形式展示给读者。
1┌─────────────────────────────────────────────────────┐2│ 构建时 (Build Time) │3│ │4│ scripts/fill-descriptions.ts │5│ ├── 扫描 src/content/posts/ 下所有 .md/.mdx │6│ ├── 跳过已有 description 的文章 │7│ ├── 调用千问 API 生成摘要 │8│ └── 写回 frontmatter (description + descriptionSource) │9└─────────────────────────────────────────────────────┘10 ↓11┌─────────────────────────────────────────────────────┐12│ 运行时 (Runtime) │13│ │14│ src/components/widget/AiSummary.astro │15│ ├── 读取 description 和 descriptionSource │16│ ├── IntersectionObserver 监听滚动进入视口 │17│ └── 逐字打字机动画,标点处自动停顿 │18└─────────────────────────────────────────────────────┘脚本位于 scripts/fill-descriptions.ts,使用 npx tsx 直接运行:
1npx tsx scripts/fill-descriptions.ts核心配置:
1// 千问 API 配置(DashScope 兼容 OpenAI 格式)2const QWEN_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";3const QWEN_MODEL = "qwen-plus";4
5// 每篇文章最多取前 2600 字作为上下文6const MAX_CONTEXT_CHARS = 2600;7
8// API 失败最多重试 2 次9const MAX_RETRIES = 2;API 密钥直接写在脚本里,但这个文件已加入 .gitignore,不会被推送到 GitHub。
脚本会递归扫描 src/content/posts/ 目录下所有 .md 和 .mdx 文件,然后用 gray-matter 解析 frontmatter:
1const POSTS_DIR = path.resolve("src/content/posts");2
3async function main() {4 const mdFiles = collectMarkdownFiles(POSTS_DIR);5 const missing: MissingItem[] = [];6 let skipped = 0;7
8 for (const filePath of mdFiles) {9 const raw = fs.readFileSync(filePath, "utf-8");10 const gm = matter(raw);11 if (gm.data.description) {12 skipped++; // 已有 description,跳过13 continue;14 }15 missing.push({16 filePath,17 title: gm.data.title || path.basename(filePath, path.extname(filePath)),18 raw,19 });20 }21}关键设计:只处理没有 description 字段的文章,已经写了 description 的文章(不管是手动写的还是之前生成的)完全不动,不会覆盖。
在发送给 AI 之前,需要把 Markdown 正文清理成纯文本:
1function extractContext(body: string, maxChars: number): string {2 const cleaned = body3 .replace(/^---[\s\S]*?---\n?/, "") // 去掉 frontmatter4 .replace(/#{1,6}\s+/g, "") // 去掉标题标记5 .replace(/```[\s\S]*?```/g, "[代码块]") // 代码块替换为占位符6 .replace(/`[^`]+`/g, "[代码]") // 行内代码替换7 .replace(/!\[.*?\]\(.*?\)/g, "") // 去掉图片8 .replace(/\[([^\]]*)\]\(.*?\)/g, "$1") // 保留链接文字,去掉 URL9 .replace(/\n{3,}/g, "\n\n") // 压缩多余空行10 .trim();11
12 return cleaned.length > maxChars13 ? `${cleaned.slice(0, maxChars)}...` // 截断到 2600 字14 : cleaned;15}为什么要截断?因为摘要只需要理解文章大意,没必要把整篇长文都发给 API,2600 字足够捕捉核心内容,同时节省 token 开销。
这是整个功能里最值得讲的部分。提示词的目标是让 AI 生成的摘要像人写的,而不是像机器总结的:
1const SYSTEM_PROMPT = `你是一个以第一视角写作的个人博客作者。你的博客记录技术学习、日常生活和真实感悟。2
3你的任务是:读完一篇博客文章后,为它写一段友好、自然、像博客导语一样的"文章摘要"。4
5核心规则:61. 输出只要一段摘要文字,不要标题、不要列表、不要"本文""这篇文章""总之"之类的套话。72. 表达要自然、口语化,像一个真实的博主在跟读者打招呼或做开场铺垫,有一点"人味"。83. 不要堆砌概念、不要写得像说明书或提纲总结。94. 贴近原文真实内容,保留原作者的情绪和语气。105. 技术文章保持清晰但不要生硬,生活/感悟类文章语气柔和一些。116. 字数控制在 60~120 字左右,越短、越准越好,不要啰嗦。127. 纯正文内容输出(不带任何前缀或说明)。`;效果对比:
| AI 生成(优化后) | AI 生成(未优化) |
|---|---|
| 折腾了两天终于把Nginx反代配通了,中间踩了三个莫名其妙的坑,趁热记下来免得下次再掉进去 😤 | 本文主要介绍了Nginx反向代理的配置方法,包括常见的错误排查和解决方案。 |
1async function generateDescription(title: string, content: string): Promise<string | null> {2 const context = extractContext(content, MAX_CONTEXT_CHARS);3 const userMsg = `文章标题:${title}\n\n文章内容(节选):\n${context}`;4
5 for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {6 try {7 const resp = await fetch(`${QWEN_BASE_URL}/chat/completions`, {8 method: "POST",9 headers: {10 "Content-Type": "application/json",11 Authorization: `Bearer ${QWEN_API_KEY}`,12 },13 body: JSON.stringify({14 model: QWEN_MODEL,15 messages: [16 { role: "system", content: SYSTEM_PROMPT },17 { role: "user", content: userMsg },18 ],19 temperature: 0.75,20 max_tokens: 256,21 }),22 });23
24 if (!resp.ok) {25 // 失败时递增等待后重试26 if (attempt < MAX_RETRIES) {27 await sleep(1500 * (attempt + 1));28 continue;29 }30 return null;31 }32
33 const json = await resp.json();34 const text = json?.choices?.[0]?.message?.content?.trim() ?? "";35
36 // 清理 AI 可能加上的前缀37 const cleaned = text38 .replace(/^(摘要|简介|内容简介|文章摘要|本文|这篇文章|总的来说|总之|概括).{0,8}[::]\s*/i, "")39 .replace(/\s*---\s*$/, "")40 .trim();41
42 return cleaned || null;43 } catch (err) {44 if (attempt < MAX_RETRIES) {45 await sleep(1500 * (attempt + 1));46 continue;47 }48 return null;49 }50 }51 return null;52}几个设计细节:
1500ms * (attempt + 1),避免频繁请求触发限流生成的摘要需要写入文章的 YAML frontmatter:
1function writeFrontmatter(filePath: string, raw: string, description: string, source: "ai" | "manual"): void {2 let fm = raw;3 const hasDesc = /^description\s*:\s*/m.test(fm);4 const hasSource = /^descriptionSource\s*:\s*/m.test(fm);5
6 if (!hasDesc) {7 // 找到 frontmatter 的结束标记 ---,在它前面插入 description8 const closingIdx = fm.indexOf("---", 4);9 const beforeClose = fm.slice(0, closingIdx);10 const afterClose = fm.slice(closingIdx);11
12 const safeDesc = description.includes('"')13 ? `"${description.replace(/"/g, '\\"')}"`14 : `"${description}"`;15
16 fm = `${beforeClose.trimEnd()}\ndescription: ${safeDesc}\n\n${afterClose.trimStart()}`;17 }18
19 if (!hasSource) {20 // 同样方式插入 descriptionSource21 const closingIdx = fm.indexOf("---", 4);22 const beforeClose = fm.slice(0, closingIdx);23 const afterClose = fm.slice(closingIdx);24 fm = `${beforeClose.trimEnd()}\ndescriptionSource: ${source}\n\n${afterClose.trimStart()}`;25 }26
27 fs.writeFileSync(filePath, fm, "utf-8");28}写入后,文章的 frontmatter 会变成这样:
1---2title: 折腾Nginx反代记录3published: 2026-04-154description: "折腾了两天终于把Nginx反代配通了,中间踩了三个莫名其妙的坑……"5descriptionSource: ai6---1for (const item of missing) {2 const desc = await generateDescription(item.title, item.raw);3 if (!desc) {4 failed++;5 continue;6 }7
8 writeFrontmatter(item.filePath, item.raw, desc, "ai");9 success++;10
11 await sleep(600); // 每次请求间隔 600ms,避免限流12}每篇文章处理完后等待 600ms,对千问 API 表示友好。
组件位于 src/components/widget/AiSummary.astro,是一个纯 Astro 组件,没有框架运行时开销。
1interface Props {2 description: string;3 descriptionSource?: "manual" | "ai" | string;4}description — 摘要文本descriptionSource — 来源标记,"manual" 显示「人工编写」,其他值(包括 "ai")显示「AI 摘要」1<div id="ai-summary" class="ai-summary card-base rounded-xl mb-6 onload-animation">2 <div class="ai-summary-inner">3 <div class="ai-summary-header">4 <div class="ai-summary-icon">5 <Icon name={iconName} class="text-lg" />6 </div>7 <span class="ai-summary-label">{sourceLabel}</span>8 </div>9 <p id="ai-summary-text" class="ai-summary-text" data-full-text={description}>10 </p>11 </div>12</div>注意 <p> 标签本身是空的,摘要文本通过 data-full-text 属性传递给 JavaScript,由打字机动画逐字填充。
这是整个组件最精华的部分:
1(function typewriter() {2 const el = document.getElementById("ai-summary-text");3 if (!el) return;4
5 const fullText = el.getAttribute("data-full-text") || "";6 if (!fullText) return;7
8 // 无障碍:尊重"减少动态效果"设置9 const prefersReducedMotion = window.matchMedia(10 "(prefers-reduced-motion: reduce)"11 ).matches;12 if (prefersReducedMotion) {13 el.textContent = fullText; // 直接显示全文14 return;15 }16
17 let hasRun = false;18 const speed = 45; // 每个字符 45ms19
20 // IntersectionObserver:只在元素进入视口时触发一次21 const observer = new IntersectionObserver(22 (entries) => {23 for (const entry of entries) {24 if (entry.isIntersecting && !hasRun) {25 hasRun = true;26 observer.unobserve(el);27 startTyping();28 }29 }30 },31 { threshold: 0.3 }32 );33
34 observer.observe(el);35
36 function startTyping() {37 let i = 0;38 el.textContent = "";39
40 function tick() {41 if (i < fullText.length) {42 el.textContent += fullText.charAt(i);43 i++;44
45 // 根据标点符号调整停顿时间46 const char = fullText.charAt(i - 1);47 const delay =48 char === "。" || char === "!" || char === "?" || char === "…"49 ? speed * 3 // 句末标点:135ms50 : char === "," || char === "、"51 ? speed * 2 // 逗号、顿号:90ms52 : speed; // 普通字符:45ms53
54 setTimeout(tick, delay);55 }56 }57
58 tick();59 }60})();这段代码有几个值得注意的设计:
1. IntersectionObserver 懒触发
不是页面一加载就开始打字,而是等用户滚动到摘要区域才开始。threshold: 0.3 表示元素有 30% 可见时才触发。hasRun 标志确保只播放一次。
2. 标点停顿节奏
普通字符间隔 45ms,逗号/顿号 90ms(2倍),句号/感叹号/问号/省略号 135ms(3倍)。这个细节让打字效果更像真人在打字——人在打完一句话后会自然地停顿一下。
3. 无障碍支持
prefers-reduced-motion 是一个 CSS 媒体查询,用户在系统设置中开启「减少动态效果」后,动画会跳过,直接显示全文。
1.ai-summary {2 border: 1px solid color-mix(in srgb, var(--line-divider) 86%, transparent);3 background: color-mix(in srgb, var(--card-bg) 94%, transparent);4 overflow: hidden;5}6
7.ai-summary-icon {8 width: 1.75rem;9 height: 1.75rem;10 border-radius: 0.5rem;11 background: color-mix(in srgb, var(--primary) 14%, transparent);12 color: var(--primary);13}14
15.ai-summary-text {16 font-size: 0.925rem;17 line-height: 1.75;18 color: var(--deep-text);19 min-height: 1.75em; /* 预留空间,避免打字时布局跳动 */20}min-height: 1.75em 是个小细节——在打字动画开始前,摘要区域已经占好了空间,不会因为文字逐渐出现而导致页面布局抖动。
在 src/pages/posts/[...slug].astro 中:
1{2 entry.data.description && (3 <AiSummary4 description={entry.data.description}5 descriptionSource={entry.data.descriptionSource}6 />7 )8}只有当文章有 description 字段时才渲染摘要组件。没有 description 的文章不会显示摘要区域,不影响正常页面。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
description | string | 否 | 文章摘要,60~120字 |
descriptionSource | "manual" | "ai" | 否 | 标记摘要来源 |
在 src/content.config.ts 中,description 已纳入 Zod schema 验证:
1const postsCollection = defineCollection({2 schema: z.object({3 title: z.string(),4 published: z.date(),5 description: z.string().optional().default(""),6 // ... 其他字段7 }),8});descriptionSource 没有在 schema 中定义,但 Astro 会把 frontmatter 中的所有字段都传递给页面,所以 entry.data.descriptionSource 在模板中仍然可以正常访问。
11. 作者写文章 → frontmatter 中不写 description22. 运行 fill-descriptions.ts → 千问 API 生成摘要 → 写入 description + descriptionSource: ai33. pnpm build → Astro 构建 → 文章页读取 description44. 用户访问文章页 → AiSummary 组件渲染 → IntersectionObserver 监听55. 用户滚动到摘要区域 → 打字机动画开始 → 逐字显示摘要整个 AI 摘要功能只涉及两个文件,没有引入额外的 npm 依赖(脚本用原生 fetch 调用 API),没有运行时的 AI 调用(摘要在构建时就生成好了),前端组件也是纯 Astro + 原生 JS,没有框架运行时开销。
如果你也想在自己的博客里实现类似功能,核心步骤就是:
提示词的质量决定了摘要的「人味」程度,这是最值得花时间打磨的地方。
如果你喜欢,那么欢迎来到我的世界!
了解更多暂未播放



"所有的相遇都是久别重逢。"—— 未知


