使用 Ollama 为 Hexo 博客部署 AI 文章摘要

使用 Ollama 为 Hexo 博客部署 AI 文章摘要

架构概述

1
2
3
4
5
6
7
8
┌─────────────────┐      ┌─────────────────┐      ┌─────────────────┐
│ 用户浏览器 │ ──→ │ Hexo 服务器 │ ──→ │ Ollama 服务器 │
│ │ │ (Nginx 代理) │ │ (192.168.0.2)│
│ blog.mingliang │ │ 192.168.1.2 │ │ :11434 │
│ star.com │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
↑ ↑
└──────────── 仅允许 192.168.1.2 访问 ──────┘

Ollama 服务端部署

1. 拉起ollama的容器并设置环境变量防止CORS跨域访问

1
2
3
4
5
6
7
docker run -d \
-v ollama:/root/.ollama \
-p 11434:11434 \
-e OLLAMA_ORIGINS="https://blog.eucalyptus.cc,https://eucalyptus.cc,http://localhost,https://localhost" \
-e OLLAMA_HOST="0.0.0.0:11434" \
--name ollama \
ollama/ollama

2. 为ollama下载qwen2.5:0.5b模型

1
docker exec ollama ollama pull qwen2.5:0.5b

3. 安全加固:配置防火墙

在阿里云安全组中配置:

  • 入方向 11434 端口:授权对象 192.168.1.2
  • 删除所有 0.0.0.0/0 访问 11434 的规则

Hexo 服务器配置反向代理

在 Hexo 服务器(192.168.1.2)的 Nginx 中配置:

1
2
3
代理目录:/
目标:url地址 http://192.168.0.2:11434
发送域名:$http_host

为何需要反向代理?

  • 浏览器直接请求 Ollama 存在 CORS 限制
  • 通过同域名代理,前端无跨域问题
  • 隐藏真实 Ollama IP,增强安全性

Hexo 主题集成

1. 添加挂载点

编辑 /www/wwwroot/myblog/themes/anzhiyu/layout/post.pug,插入:

1
2
3
4
5
6
7
8
...

//- AI 摘要区域(由 ai-summary.js 动态渲染)
#ai-summary.anzhiyu-ai-summary

!=page.content

...

2. 前端核心代码

ai-summary.js —— 纯前端 Ollama 调用,支持打字机效果和本地缓存:

  • 自动提取文章正文,过滤代码块、图片等干扰内容
  • 调用 Ollama /api/generate 接口生成摘要
  • localStorage 缓存:同篇文章 7 天内不再重复请求
  • 打字机效果:逐字输出,增强交互体验
  • 适配 PJAX/Turbolinks 无刷新页面切换
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
/**
* AI 摘要 - Ollama 直连版
*/
(function() {
'use strict';

const CONFIG = {
apiUrl: 'https://ollama.eucalyptus.cc/api/generate',
model: 'qwen2.5:3b',
typeSpeed: 35,
maxSummaryLength: 500,
retryCount: 1
};

const FRONTEND_CACHE_KEY = 'ai_summary_frontend_v7';
const FRONTEND_CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000;

let isProcessing = false;
let typeWriterTimer = null;
let abortController = null;

function waitForElement(selector, timeout = 3000) {
return new Promise((resolve, reject) => {
const element = document.querySelector(selector);
if (element) return resolve(element);
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) { observer.disconnect(); resolve(el); }
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => { observer.disconnect(); reject(new Error(`Timeout waiting for ${selector}`)); }, timeout);
});
}

function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

function typeWriter(element, text, speed = CONFIG.typeSpeed) {
return new Promise((resolve) => {
if (typeWriterTimer) { clearInterval(typeWriterTimer); typeWriterTimer = null; }
let index = 0;
element.textContent = '';
typeWriterTimer = setInterval(() => {
if (index < text.length) {
element.textContent += text.charAt(index);
index++;
} else {
clearInterval(typeWriterTimer);
typeWriterTimer = null;
resolve();
}
}, speed);
});
}

function getFrontendCache(path) {
try {
const data = localStorage.getItem(FRONTEND_CACHE_KEY);
if (!data) return null;
const cache = JSON.parse(data);
const item = cache[path];
if (!item) return null;
if (Date.now() - item.time > FRONTEND_CACHE_MAX_AGE) {
delete cache[path];
localStorage.setItem(FRONTEND_CACHE_KEY, JSON.stringify(cache));
return null;
}
return item.summary;
} catch {
return null;
}
}

function setFrontendCache(path, summary) {
try {
const data = localStorage.getItem(FRONTEND_CACHE_KEY);
const cache = data ? JSON.parse(data) : {};
cache[path] = { summary, time: Date.now() };
localStorage.setItem(FRONTEND_CACHE_KEY, JSON.stringify(cache));
} catch {}
}

function getArticleText() {
const selectors = [
'#article-container .post-content',
'#article-container #post',
'.post-content',
'article .entry-content',
'#post-content',
'.article-content',
'.markdown-body',
'#content article'
];

let article = null;
for (const sel of selectors) {
article = document.querySelector(sel);
if (article && article.innerText.trim().length > 200) break;
}

if (!article) {
console.warn('[AI Summary] Article content not found.');
return '';
}

const clone = article.cloneNode(true);

const removeSelectors = [
'h1', 'h2.post-title', '.post-title', '.page-title',
'.post-meta', '.meta', '.entry-meta', '.article-meta',
'.post-date', '.post-time', '.published', '.updated',
'.author', '.byline', '.post-author',
'.post-tags', '.tags', '.tag-cloud', '.post-categories',
'.categories', '.post-category', '.article-tags',
'nav', '.nav', '.breadcrumb', '.crumbs',
'aside', '.sidebar', '.widget', '.toc', '#toc',
'header', '.post-header', '.entry-header', '.article-header',
'footer', '.post-footer', '.entry-footer', '.article-footer',
'#post-comment', '.comments', '.comment-area',
'.share', '.social-share', '.reward', '.donate',
'.post-copyright', '.copyright', '.license', '.declaration',
'.relatedPosts', '.related-posts', '.related', '.recommend',
'.anzhiyu-ai-summary', '#ai-summary',
'script', 'style', 'pre', 'code', 'table',
'img', 'figure', 'figcaption', 'svg',
'blockquote',
'.ads-wrap', '.ad', '.advertisement',
'.highlight', '.mermaid', '.katex', '.mathjax',
'.pagination', '.pager', '.page-nav',
'.notice', '.alert', '.tips', '.warning'
];

removeSelectors.forEach(sel => {
clone.querySelectorAll(sel).forEach(el => el.remove());
});

let text = clone.innerText
.replace(/\s+/g, ' ')
.replace(/[*#\-_`~\[\]()>|]/g, ' ')
.replace(/\b[A-Z]{5,}\b/g, ' ')
.replace(/\d{4}[年/-]\d{1,2}[月/-]\d{1,2}[日]?/g, ' ')
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9,。!?;:""''()【】《》.,;:!?'"()\[\]<>、\-—\n]/g, ' ')
.replace(/\s+/g, ' ')
.trim();

if (text.length < 100) {
console.warn('[AI Summary] Content too short, trying fallback...');
const paragraphs = Array.from(document.querySelectorAll('p'));
const longestP = paragraphs
.filter(p => {
const t = p.innerText.trim();
return t.length > 50 && !t.includes('标签') && !t.includes('分类') && !t.includes('作者');
})
.sort((a, b) => b.innerText.length - a.innerText.length)
.slice(0, 10);

if (longestP.length > 0) {
text = longestP.map(p => p.innerText).join(' ');
text = text.replace(/\s+/g, ' ').trim();
}
}

return text;
}

async function callSummaryAPI(text, retry = 0) {
if (abortController) abortController.abort();
abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 120000);

try {
// ========== 优化后的提示词 ==========
const prompt = `你是一个专业的博客文章摘要生成助手。

请阅读以下文章,用一段话概括其核心内容。

要求:
- 自然流畅,像人写的读后感
- 开头用"本文介绍了"或"本文主要介绍了"
- 不要分点,不要列条目
- 不要复述标题、作者、日期、标签等元信息
- 不要输出 markdown 格式(如 **、-、# 等)
- 不要包含"摘要:""总结:""综上所述"等前缀
- 直接输出正文

文章内容:
"""
${text.slice(0, 4000)}
"""

概括:`;
// ====================================

const response = await fetch(CONFIG.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: CONFIG.model,
prompt: prompt,
stream: false,
options: {
temperature: 0.3,
num_predict: 800
}
}),
signal: abortController.signal
});
clearTimeout(timeoutId);

if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}

const data = await response.json();

let summary = data.response || '';
if (!summary) throw new Error('Empty response');

// ========== 清理输出 ==========
summary = summary
// 去掉纯前缀词(不要删"本文介绍了")
.replace(/^\s*(摘要|总结|概括)[::]\s*/i, '')
.replace(/^\s*["']?|["']?\s*$/g, '')
// 去掉 markdown
.replace(/\*\*/g, '')
.replace(/^\s*[-*]\s+/gm, '')
.replace(/^#{1,6}\s+/gm, '')
// 统一"本文介绍了"格式
.replace(/这篇文章主要(介绍|讲述|讨论|分析)了/g, '本文介绍了')
.replace(/本文主要(介绍|讲述|讨论|分析)了/g, '本文介绍了')
.replace(/该文章(介绍|讲述|讨论|分析)了/g, '本文介绍了')
.replace(/此文章(介绍|讲述|讨论|分析)了/g, '本文介绍了')
// 去掉常见废话
.replace(/综上所述[,。]/g, '')
.replace(/总之[,。]/g, '')
.replace(/总而言之[,。]/g, '')
.trim();

// 确保以"本文介绍了"开头
if (!/^本文(主要)?介绍了/.test(summary)) {
summary = '本文介绍了' + summary.replace(/^[,。!?\s]+/, '');
}

// 句子太多时取前3句,避免太长
const sentences = summary.split(/[。!?.!?]/).filter(s => s.trim());
if (sentences.length > 3) {
summary = sentences.slice(0, 3).join('。') + '。';
}

// 显示长度截断(语义完整)
if (summary.length > CONFIG.maxSummaryLength) {
summary = summary.slice(0, CONFIG.maxSummaryLength);
const lastPunct = Math.max(
summary.lastIndexOf('。'),
summary.lastIndexOf('!'),
summary.lastIndexOf('?')
);
if (lastPunct > CONFIG.maxSummaryLength * 0.7) {
summary = summary.slice(0, lastPunct + 1);
} else {
summary = summary.replace(/[^。!?]*$/, '') + '。';
}
}

if (!/[。!?.!?]$/.test(summary)) {
summary += '。';
}

return summary;

} catch (err) {
clearTimeout(timeoutId);
if (retry < CONFIG.retryCount) {
console.log(`[AI Summary] Retrying... (${retry + 1}/${CONFIG.retryCount})`);
await new Promise(r => setTimeout(r, 2000));
return callSummaryAPI(text, retry + 1);
}
throw err;
}
}

function renderContainer(targetContainer, isLoading = false, content = '') {
if (!targetContainer) return null;

const loadingHtml = `
<span class="ai-loading-inline">
<svg class="ai-loading-svg" width="16" height="16" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="20" fill="none" stroke="currentColor" stroke-width="3"
stroke-linecap="round" stroke-dasharray="80" stroke-dashoffset="60">
<animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="1s" repeatCount="indefinite"/>
</circle>
</svg>
<span class="ai-loading-dots">AI 正在分析文章</span>
</span>
`;

targetContainer.innerHTML = `
<div class="ai-content">
<div class="ai-header">
<i class="eucalyptus icon-bilibili"></i><span class="ai-titles">AI 摘要</span><span class="ai-badge">AGENT</span>
</div>
<div class="ai-text-box">
<p id="ai-summary-text">${isLoading ? loadingHtml : escapeHtml(content)}</p>
</div>
</div>
`;
return targetContainer.querySelector('#ai-summary-text');
}

function renderError(targetContainer, message) {
if (!targetContainer) return;
targetContainer.innerHTML = `
<div class="ai-content">
<div class="ai-header">
<i class="eucalyptus icon-bilibili"></i><span class="ai-titles">AI 摘要</span><span class="ai-badge">AGENT</span>
</div>
<div class="ai-text-box">
<p style="color: #999;">${escapeHtml(message)}</p>
</div>
</div>
`;
}

function bootstrap() {
if (abortController) {
abortController.abort();
abortController = null;
}
if (typeWriterTimer) {
clearInterval(typeWriterTimer);
typeWriterTimer = null;
}
isProcessing = false;

if (document.getElementById('ai-summary')) {
setTimeout(initAISummary, 100);
}
}

async function initAISummary() {
if (isProcessing) return;
isProcessing = true;

try {
const container = await waitForElement('#ai-summary');
const text = getArticleText();

if (!text || text.length < 50) {
renderContainer(container, false, '本文篇幅较短,暂无详细摘要。');
isProcessing = false;
return;
}

const cacheKey = window.location.pathname;

const frontendCached = getFrontendCache(cacheKey);
if (frontendCached) {
const textEl = renderContainer(container, false, '');
await typeWriter(textEl, frontendCached);
isProcessing = false;
return;
}

renderContainer(container, true);

let summary;
try {
summary = await callSummaryAPI(text);
}
catch (err) {
console.error('[AI Summary] API failed:', err);
renderError(container, `AI 服务暂时不可用:${err.message}`);
isProcessing = false;
return;
}

setFrontendCache(cacheKey, summary);

const textEl = renderContainer(container, false, '');
await typeWriter(textEl, summary);

} catch (err) {
console.error('[AI Summary] Init Error:', err);
const container = document.getElementById('ai-summary');
if (container) renderError(container, '初始化失败');
} finally {
isProcessing = false;
}
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap);
} else {
bootstrap();
}

document.addEventListener('pjax:complete', bootstrap);
document.addEventListener('turbolinks:load', bootstrap);

let lastUrl = location.pathname;
new MutationObserver(() => {
const url = location.pathname;
if (url !== lastUrl) {
lastUrl = url;
bootstrap();
}
}).observe(document, { subtree: true, childList: true });

})();

ai-summary.css —— 固定配色方案,支持明暗模式切换:

  • 白天模式:浅灰背景 + 白色正文框
  • 暗色模式:深色背景 + 深色正文框
  • 机器人图标 + 状态指示器(绿色呼吸灯)
  • 移动端自适应
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
/* ============================================
AI 摘要样式 - 固定配色方案
============================================ */

/* ========== 白天模式 ========== */
.anzhiyu-ai-summary {
position: relative;
margin: 20px 0;
padding: 0;
border-radius: 12px;
background: #f5f5f7;
color: #1d1d1f;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
border: 1px solid #e8e8ed;
transition: all 0.3s ease;
}

.anzhiyu-ai-summary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
border-color: #d2d2d7;
}

/* 内容区域 */
.ai-content {
padding: 20px 24px;
}

/* 头部区域 */
.ai-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e8e8ed;
}

/* 机器人图标 */
.ai-robot {
flex-shrink: 0;
vertical-align: middle;
}

/* 左侧标签 - AI 摘要 */
.ai-titles {
flex-shrink: 0;
font-size: 16px;
font-weight: 800;
color: #1d1d1f;
letter-spacing: 0.5px;
white-space: nowrap;
background: none;
padding: 0;
}

/* 右侧标签 - Eucalyptus AGENT */
.ai-badge {
margin-left: auto;
padding: 0;
font-size: 12px;
font-weight: 500;
color: #86868b;
letter-spacing: 0.8px;
white-space: nowrap;
background: none;
border-radius: 0;
position: relative;
}

/* 小圆点装饰 */
.ai-badge::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #34c759;
margin-right: 6px;
vertical-align: middle;
animation: pulse 2s ease-in-out infinite;
}

@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.8); }
}

/* 摘要文本框 */
.ai-text-box {
padding: 16px 20px;
background: #ffffff;
border: 1px solid #e8e8ed;
border-radius: 10px;
font-size: 15px;
line-height: 1.8;
color: #3a3a3c;
text-align: justify;
}

.ai-text-box p {
margin: 0;
}

/* ============================================
加载状态
============================================ */
.ai-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
color: #1d1d1f;
font-size: 14px;
}

.ai-loading i {
animation: spin 1s linear infinite;
}

@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

/* ============================================
暗色模式
============================================ */
[data-theme="dark"] .anzhiyu-ai-summary {
background: #1c1c1e;
color: #ffffff;
border-color: #2c2c2e;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}

[data-theme="dark"] .anzhiyu-ai-summary:hover {
border-color: #3a3a3c;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}

[data-theme="dark"] .ai-header {
border-bottom-color: #2c2c2e;
}

[data-theme="dark"] .ai-titles {
color: #ffffff;
background: none;
}

[data-theme="dark"] .ai-badge {
color: #8e8e93;
background: none;
}

[data-theme="dark"] .ai-badge::before {
background: #30d158;
}

[data-theme="dark"] .ai-text-box {
background: #2c2c2e;
border-color: #3a3a3c;
color: #ffffff;
}

[data-theme="dark"] .ai-loading {
color: #ffffff;
}

.ai-loading-inline {
display: flex;
align-items: center;
gap: 8px;
color: #86868b;
font-size: 14px;
}

.ai-loading-inline i {
animation: spin 1s linear infinite;
}

[data-theme="dark"] .ai-loading-inline {
color: #8e8e93;
}


/* AI 摘要加载动画 */
.ai-loading-inline {
display: inline-flex;
align-items: center;
gap: 8px;
color: #999;
font-size: 14px;
}

.ai-loading-svg {
animation: ai-loading-rotate 1s linear infinite;
color: #999;
}

@keyframes ai-loading-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

/* 文字后面的点动画 */
.ai-loading-dots::after {
content: '';
animation: ai-loading-dots 1.5s steps(4, end) infinite;
}

@keyframes ai-loading-dots {
0% { content: ''; }
25% { content: '.'; }
50% { content: '..'; }
75% { content: '...'; }
100% { content: ''; }
}

.ai-header .icon-bilibili {
font-size: 20px;
margin-right: 8px;
vertical-align: middle;
}

/* ============================================
移动端适配
============================================ */
@media (max-width: 768px) {
.anzhiyu-ai-summary {
margin: 16px -16px;
border-radius: 0;
border-left: none;
border-right: none;
}

.ai-content {
padding: 16px 20px;
}

.ai-badge {
display: none;
}

.ai-text-box {
padding: 12px 14px;
}
}


3. 引入资源文件

在主题布局中引入 CSS 和 JS:

1
2
3
4
5
6
7
8
inject:
head:
# 自定义css
- <link rel="stylesheet" href="/cdn/css/ai-summary.css">

bottom:
# 自定义js
- <script defer src="/cdn/js/ai-summary.js"></script>