AI 摘要实现

2231 字
11 分钟
AI 摘要实现
AI 摘要

AI 摘要提前在构建阶段生成并存入文章元数据,前端页面读取后带动画展示,页面访问无需实时调用 AI,无额外接口开销。

架构概览#

Firefly 博客的 AI 摘要功能分为两部分:

┌─────────────────────────────────────────────────────┐
│ 构建时 (Build Time) │
│ │
│ scripts/fill-descriptions/index.ts │
│ ├── 扫描 src/content/posts/ 下所有 .md/.mdx │
│ ├── 跳过已有 description 的文章 │
│ ├── 调用千问 API 生成摘要 │
│ └── 写回 frontmatter (description + descriptionSource) │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 运行时 (Runtime) │
│ │
│ src/components/widget/AiSummary.astro │
│ ├── 读取 description 和 descriptionSource │
│ ├── IntersectionObserver 监听滚动进入视口 │
│ ├── 逐字打字机动画,标点处自动停顿 │
│ └── astro:page-load 支持 Swup 站内导航 │
└─────────────────────────────────────────────────────┘

文件目录#

文件路径作用
src/components/widget/AiSummary.astro摘要卡片 + 打字机动画
src/pages/posts/[...slug].astro文章页条件渲染摘要组件
src/content.config.tsdescription / descriptionSource 字段校验
scripts/fill-descriptions/index.ts千问 API 批量补全缺失摘要
.env存放 QWEN_API_KEY勿提交 Git

配置 API 密钥#

在项目根目录创建 .env 文件:

# 千问 API 配置
QWEN_API_KEY=你的API密钥
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
QWEN_MODEL=qwen-plus
注意

📌 将 .env 添加到 .gitignore,避免泄露密钥!

密钥在 阿里云 DashScope 控制台 申请。脚本通过 import.meta.url 定位项目根目录并读取 .env 文件(兼容 Windows \r\n 换行),也可临时用环境变量:

Terminal window
QWEN_API_KEY=sk-xxx pnpm fill-descriptions

可选环境变量:

变量默认值说明
QWEN_API_KEY(必填)千问 API 密钥
QWEN_MODELqwen-plus文本模型;不要用 qwen-math-turbo 等数学/代码模型
QWEN_BASE_URLDashScope 兼容端点使用自定义 MaaS 端点时再改

构建时脚本详解#

脚本路径:scripts/fill-descriptions/index.ts

核心配置#

const QWEN_BASE_URL = process.env.QWEN_BASE_URL || "https://dashscope.aliyuncs.com/compatible-mode/v1";
const QWEN_MODEL = process.env.QWEN_MODEL || "qwen-plus";
const QWEN_API_KEY = process.env.QWEN_API_KEY || "";
const POSTS_DIR = path.join(PROJECT_ROOT, "src/content/posts");

脚本做了什么#

  • 递归扫描 src/content/posts/ 下所有 .md / .mdx
  • 仅在 frontmatter 区块检测是否存在 description,不会误判正文代码块内的示例 description:
  • 提取文章标题 + 正文前2600字作为生成上下文
  • 调用千问 API,使用优化提示词自动生成文章摘要
  • 校验摘要输出质量(长度校验、过滤**等无效内容),不合格自动重试生成
  • 重写文件 frontmatter,写入 descriptiondescriptionSource: ai
  • 仅修改 frontmatter 区域,文章正文内容完全不改动

扫描与过滤#

/**
* 判断文章是否已有 description(只检查 frontmatter 区块内,且值不能为空)
*/
function hasDescription(raw: string): boolean {
const parsed = parseFrontmatter(raw);
if (!parsed) return false;
const descMatch = parsed.frontmatter.match(/^description\s*:\s*(.+)$/m);
if (!descMatch) return false;
let value = descMatch[1].trim();
// 去掉引号
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
return value.length > 0;
}
注意

⚠️ 新手陷阱:若用整篇文件匹配 description:,在正文里展示 frontmatter 示例的文章会被误判为”已有描述”。务必限定在 frontmatter 区块内检测!

上下文提取#

function extractContext(body: string, maxChars: number): string {
const cleaned = body
.replace(/^---[\s\S]*?---\n?/, "") // 去掉 frontmatter
.replace(/#{1,6}\s+/g, "") // 去掉标题标记
.replace(/```[\s\S]*?```/g, "[代码块]") // 代码块替换
.replace(/`[^`]+`/g, "[代码]") // 行内代码替换
.replace(/!\[.*?\]\(.*?\)/g, "") // 去掉图片
.replace(/\[([^\]]*)\]\(.*?\)/g, "$1") // 保留链接文字
.replace(/\n{3,}/g, "\n\n") // 压缩空行
.trim();
return cleaned.length > maxChars
? `${cleaned.slice(0, maxChars)}...`
: cleaned;
}

提示词设计#

const SYSTEM_PROMPT = `你是一个以第一视角写作的个人博客作者。你的博客记录技术学习、日常生活和真实感悟。
你的任务是:读完一篇博客文章后,为它写一段友好、自然、像博客导语一样的"文章摘要"。
核心规则:
1. 输出只要一段摘要文字,不要标题、不要列表、不要"本文""这篇文章""总之"之类的套话。
2. 表达要自然、口语化,像一个真实的博主在跟读者打招呼或做开场铺垫,有一点"人味"。
3. 不要堆砌概念、不要写得像说明书或提纲总结。
4. 贴近原文真实内容,保留原作者的情绪和语气。
5. 技术文章保持清晰但不要生硬,生活/感悟类文章语气柔和一些。
6. 字数控制在 60~120 字左右,越短、越准越好,不要啰嗦。
7. 纯正文内容输出(不带任何前缀或说明)。`;

API 调用与重试#

const resp = await fetch(`${QWEN_BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${QWEN_API_KEY}`,
},
body: JSON.stringify({
model: QWEN_MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMsg },
],
temperature: 0.75,
max_tokens: 256,
}),
});

运行脚本#

完整脚本获取:fill-descriptions/index.ts

Terminal window
pnpm fill-descriptions

构建前自动补全(可选)#

希望每次部署前自动补全新文章摘要,可在 package.json 的 build 前加上脚本:

Terminal window
{
"scripts": {
"build": "pnpm fill-descriptions && node scripts/generate-icons.js && npx tsx scripts/generate-lqips.ts && astro build && pagefind --site dist"
}
}

注意:每次 build 都会扫描并可能调用 API,会消耗额度。文章量大时建议只在本地或 CI 需要时手动跑 pnpm fill-descriptions

运行时组件详解#

组件路径:src/components/widget/AiSummary.astro

完整组件获取:AiSummary.astro

Props 定义#

interface Props {
description: string;
descriptionSource?: "manual" | "ai" | string;
}

模板设计#

<div class="ai-summary-wrapper rounded-xl mb-6">
<div class="ai-summary">
<div class="ai-summary-inner">
<div class="ai-summary-header">
<div class="ai-summary-icon">
<Icon name={iconName} class="text-base" />
</div>
<span class="ai-summary-label">{sourceLabel}</span>
</div>
<p
class="ai-summary-text js-ai-summary-text"
data-full-text={description}
></p>
</div>
</div>
</div>
提示

⚠️使用 class 而非固定 id,避免 Swup 多页残留冲突;用 data-typing-init 防止重复初始化。

打字机动画核心#

(function initAiSummaryTypewriter() {
function run() {
const el = document.querySelector(".js-ai-summary-text");
if (!el || el.dataset.typingInit === "1") return;
el.dataset.typingInit = "1";
const fullText = el.getAttribute("data-full-text") || "";
// 无障碍:减少动态效果
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
el.textContent = fullText;
return;
}
let hasRun = false;
const speed = 45;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !hasRun) {
hasRun = true;
observer.unobserve(el);
startTyping(el, fullText, speed);
}
}
},
{ threshold: 0.3 },
);
observer.observe(el);
}
run();
document.addEventListener("astro:page-load", run);
})();

标点停顿设计#

字符延迟说明
普通字45ms基础速度
,、;:90ms(2×)短停顿
。!?…135ms(3×)长停顿

文章页集成#

src/pages/posts/[...slug].astro 中添加: 组件获取:[…slug].astro

import AiSummary from "@/components/widget/AiSummary.astro";
<!-- AI 摘要 - 打字机动画 -->
{
entry.data.description && (
<AiSummary
description={entry.data.description}
descriptionSource={entry.data.descriptionSource}
/>
)
}

Content Schema 配置#

src/content.config.ts 中添加:

type PostData = {
descriptionSource?: "manual" | "ai";
}
const postsCollection: ContentCollection<PostData> = defineCollection({
schema: z.object({
descriptionSource: z.enum(["manual", "ai"]).optional(),
}),
});

优化技巧#

参数作用推荐值说明
temperature控制随机性0.75略有个性,避免死板
max_tokens最大输出长度256摘要短,足够
top_p核采样0.9控制生成多样性

输入优化#

  • 去掉无关的格式标记
  • 代码块用占位符替代(节省 token)
  • 控制输入长度(推荐 2000-3000 字)

陷阱清单#

陷阱说明规避方法
API Key 泄露将密钥硬编码到代码中使用 .env 文件,加入 .gitignore
用整篇文件匹配 description正文代码块里的示例 description 会被误识别为已存在摘要只在 frontmatter 区块内检测字段
Swup 兼容页面切换后打字机动画无法重新触发,组件失效组件监听 astro:page-load,站内切页后打字机仍会正常初始化
模型选择选用数学、代码专用模型生成文章摘要,输出内容质量差、不符合文案需求默认 qwen-plus;数学/代码专用模型不适合写摘要,脚本启动时会给出警告
教程类文章教程正文代码块中存在 description: 示例文本,旧脚本会误判文章已有摘要,跳过生成当前版本仅在 frontmatter 区域检测字段,不受正文代码示例干扰,可正常补全摘要
请求无间隔循环批量调用千问API,请求频率过高触发平台限流,脚本中断报错添加 600ms 以上的请求间隔
输入内容过长全文无限制传入API,消耗大量token,同时超长文本会降低AI摘要精准度正文内容截断到 2000-3000 字后再传入模型
忽略编码问题Windows系统读写md文件时默认编码非UTF-8,中文摘要出现乱码读写文件时强制指定 UTF-8 编码
未校验输出AI返回空内容、仅符号、无意义文本等无效摘要,直接写入文件使用 sanitizeDescription 函数校验、清洗AI输出内容
提示词过于复杂提示词冗余、逻辑混乱,AI无法准确理解摘要生成要求,产出效果差提示词清晰简洁,分点明确告知AI生成规则与要求
使用固定 id多页面共用相同DOM id,Swup页面缓存残留,打字机组件渲染冲突使用 class 选择器替代固定id
空字符串误判frontmatter 内 description: "" 空字符串被判定为已有摘要,跳过自动补全使用 hasDescription 函数精准判断字段是否存在有效文本

快速检查清单#

  • AiSummary.astro 组件文件已创建
  • [...slug].astro 文章页面已引入摘要组件
  • content/content.config.ts 配置文件包含 descriptionSource 字段校验
  • .env 环境变量文件已配置 QWEN_API_KEY
  • 执行过命令 pnpm fill-descriptions 批量生成摘要,或手动填写过 description
  • 本地启动 pnpm dev,打开带有摘要的文章页面,滚动至摘要区域,验证打字机动画正常展示

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
AI 摘要实现
https://seasir.top/posts/ai-summary-tutorial/
作者
Hyde
发布于
2026-07-27
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
实现今日一言功能
Firefly基于 Astro 框架搭建了今日一言小组件,配置数据源、开发页面组件并完成全局注册与布局设置,组件会按日期每日展示不同名言,全端样式与交互效果均已适配完成。
2
集成朋友圈与笔记本功能
Firefly最近折腾博客,参考大佬方案把朋友圈和笔记本功能安排上了。这篇算是我的实操复盘,补充了不少配置细节。从前端组件到后台管理,一步步带你用 GitHub Gist 零成本搞定这两个模块,想给博客加点料的朋友直接来抄作业啦。
3
隐藏封面图
Firefly最近在折腾博客的封面图显示逻辑,加了个「隐藏封面图」的开关功能——从类型定义、配置项、工具函数到 UI 控件和样式,一步步把整个流程补全了。代码分散在多个文件里,但核心就一件事:让用户能一键收起或展开文章封面,既保持页面简洁,又不丢失视觉层次。顺手还做了多语言支持,中文、英文切换也一起配上了。
4
归档统计
Firefly最近在折腾博客的归档统计功能,从获取归档列表开始,一路写到更新内容的工具函数、类型定义,再到多语言配置的拆分与管理——每一处改动都为了让归档页更清晰、更易维护,也顺便理清了自己代码里的逻辑脉络。
5
给博客增加单个和全部分类页面
Firefly最近给博客加了个小功能:点击分类名就能跳转到该分类下的所有文章,还能一键查看全部分类列表——整个过程其实挺顺的,从创建页面、写 URL 工具函数,到更新导航栏和多语言支持,一步步配下来,连分类链接都自动带上了本地化路径,现在逛自己博客像逛图书馆一样清爽 😄
随机文章随机推荐

评论区

Profile Image of the Author
Hyde
Hello, I'm Hyde.
📢 欢迎来访者
👋🏻 Hi,我是Hyde,欢迎您!
分类
标签
最新动态
站点统计
文章
12
分类
2
标签
5
总字数
6,975
运行时长
0
最后活动
0 天前
站点信息
构建平台
EdgeOne
博客版本
Firefly v6.15.0
文章许可
CC BY-NC-SA 4.0
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
--
--%
本年还剩 -- 天
--%
本月还剩 -- 天
--%
本周还剩 -- 天
--%
今日还剩 -- 小时

距离 --

--

--

我和宝宝在一起已经
---------TSH❤️CXY---------
---------TSH
❤️
CXY---------
0000000
✨ 今日一言
"暂无名言"
——
天气预报
统计
✨️ 复制成功,转载请标注本文地址