diff --git a/blog.config.js b/blog.config.js
index 3b827a95..161f7715 100644
--- a/blog.config.js
+++ b/blog.config.js
@@ -270,6 +270,16 @@ const BLOG = {
// 星空雨特效 黑夜模式才会生效
STARRY_SKY: process.env.NEXT_PUBLIC_STARRY_SKY || false, // 开关
+ // AI 文章摘要生成
+ AI_SUMMARY_API:
+ process.env.AI_SUMMARY_API||
+ '',
+ AI_SUMMARY_KEY:
+ process.env.AI_SUMMARY_KEY ||
+ '',
+ AI_SUMMARY_WORD_LIMIT: process.env.AI_SUMMARY_WORD_LIMIT || 1000,
+
+
// ********挂件组件相关********
// AI 文章摘要生成 @see https://docs_s.tianli0.top/
TianliGPT_CSS:
diff --git a/components/AISummary.js b/components/AISummary.js
new file mode 100644
index 00000000..055b4fc5
--- /dev/null
+++ b/components/AISummary.js
@@ -0,0 +1,94 @@
+import styles from './AISummary.module.css'
+import { useEffect, useState } from 'react'
+
+const AISummary = ({ aiSummary }) => {
+ const [summary, setSummary] = useState('生成中...')
+
+ useEffect(() => {
+ showAiSummaryAnimation(aiSummary, setSummary)
+ }, [])
+
+ return (
+
+
+
+
+
+ {summary}
+ {summary !== aiSummary && (
+
+ )}
+
+
+
+
+ )
+}
+
+const showAiSummaryAnimation = (rawSummary, setSummary) => {
+ if (!rawSummary) return
+ let currentIndex = 0
+ const typingDelay = 20
+ const punctuationDelayMultiplier = 6
+ let animationRunning = true
+ let lastUpdateTime = performance.now()
+ const animate = () => {
+ if (currentIndex < rawSummary.length && animationRunning) {
+ const currentTime = performance.now()
+ const timeDiff = currentTime - lastUpdateTime
+
+ const letter = rawSummary.slice(currentIndex, currentIndex + 1)
+ const isPunctuation = /[,。!、?,.!?]/.test(letter)
+ const delay = isPunctuation
+ ? typingDelay * punctuationDelayMultiplier
+ : typingDelay
+
+ if (timeDiff >= delay) {
+ setSummary(rawSummary.slice(0, currentIndex + 1))
+ lastUpdateTime = currentTime
+ currentIndex++
+
+ if (currentIndex < rawSummary.length) {
+ setSummary(rawSummary.slice(0, currentIndex))
+ } else {
+ setSummary(rawSummary)
+ observer.disconnect()
+ }
+ }
+ requestAnimationFrame(animate)
+ }
+ }
+ animate(rawSummary)
+ const observer = new IntersectionObserver(
+ entries => {
+ animationRunning = entries[0].isIntersecting
+ if (animationRunning && currentIndex === 0) {
+ setTimeout(() => {
+ requestAnimationFrame(animate)
+ }, 200)
+ }
+ },
+ { threshold: 0 }
+ )
+ let post_ai = document.querySelector('.post-ai')
+ if (post_ai) {
+ observer.observe(post_ai)
+ }
+}
+
+export default AISummary
diff --git a/components/AISummary.module.css b/components/AISummary.module.css
new file mode 100644
index 00000000..b7e89f2a
--- /dev/null
+++ b/components/AISummary.module.css
@@ -0,0 +1,53 @@
+.post-ai {
+ font-family: 'Noto Sans SC', sans-serif;
+ margin-bottom: 20px;
+}
+.ai-container {
+ background: linear-gradient(135deg, #f9f9f9 0%, #f5f5f5 100%);
+ border: 1px solid #e8e8e8;
+ border-radius: 10px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ overflow: hidden;
+}
+.ai-header {
+ background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
+ color: white;
+ padding: 12px 20px;
+ display: flex;
+ align-items: center;
+}
+.ai-icon {
+ margin-right: 10px;
+}
+.ai-title {
+ font-size: 18px;
+ font-weight: bold;
+ flex-grow: 1;
+}
+.ai-tag {
+ background-color: rgba(255, 255, 255, 0.2);
+ padding: 3px 8px;
+ border-radius: 12px;
+ font-size: 12px;
+}
+.ai-content {
+ padding: 20px;
+}
+.ai-explanation {
+ font-size: 16px;
+ line-height: 1.6;
+ color: #333;
+}
+.blinking-cursor {
+ display: inline-block;
+ width: 2px;
+ height: 20px;
+ background-color: #333;
+ animation: blink 0.7s infinite;
+ margin-left: 5px;
+}
+@keyframes blink {
+ 0% { opacity: 0; }
+ 50% { opacity: 1; }
+ 100% { opacity: 0; }
+}
diff --git a/lib/plugins/aiSummary.js b/lib/plugins/aiSummary.js
new file mode 100644
index 00000000..5b9c1d4e
--- /dev/null
+++ b/lib/plugins/aiSummary.js
@@ -0,0 +1,31 @@
+/**
+ * get Ai summary
+ * @returns {Promise}
+ * @param aiSummaryAPI
+ * @param aiSummaryKey
+ * @param truncatedText
+ */
+export async function getAiSummary(aiSummaryAPI, aiSummaryKey, truncatedText) {
+ try {
+ const response = await fetch(aiSummaryAPI, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ token: aiSummaryKey,
+ content: truncatedText
+ })
+ })
+
+ if (response.ok) {
+ const data = await response.json()
+ return data.summary
+ } else {
+ throw new Error('Response not ok')
+ }
+ } catch (error) {
+ console.error('ChucklePostAI:请求失败', error)
+ return '获取文章摘要失败,请稍后再试。'
+ }
+}
diff --git a/pages/[prefix]/[slug]/index.js b/pages/[prefix]/[slug]/index.js
index cdf99afc..6d30e227 100644
--- a/pages/[prefix]/[slug]/index.js
+++ b/pages/[prefix]/[slug]/index.js
@@ -6,6 +6,8 @@ import { uploadDataToAlgolia } from '@/lib/plugins/algolia'
import { checkSlugHasOneSlash, getRecommendPost } from '@/lib/utils/post'
import { idToUuid } from 'notion-utils'
import Slug from '..'
+import { getPageContentText } from '@/pages/search/[keyword]'
+import { getAiSummary } from '@/lib/plugins/aiSummary'
/**
* 根据notion的slug访问页面
@@ -94,6 +96,26 @@ export async function getStaticProps({ params: { prefix, slug }, locale }) {
key => props.post.blockMap.block[key]?.value?.parent_id === props.post.id
)
props.post.toc = getPageTableOfContents(props.post, props.post.blockMap)
+
+ const aiSummaryAPI = siteConfig('AI_SUMMARY_API')
+ if (aiSummaryAPI) {
+ const aiSummaryKey = siteConfig('AI_SUMMARY_KEY')
+ const wordLimit = siteConfig('AI_SUMMARY_WORD_LIMIT', '1000')
+ const post = props.post
+ let content = ''
+ for (let heading of post.toc) {
+ content += heading.text + ' '
+ }
+ content += getPageContentText(post, post.blockMap)
+ const combinedText = post.title + ' ' + content
+ const truncatedText = combinedText.slice(0, wordLimit)
+
+ props.post.aiSummary = await getAiSummary(
+ aiSummaryAPI,
+ aiSummaryKey,
+ truncatedText
+ )
+ }
}
// 生成全文索引 && JSON.parse(BLOG.ALGOLIA_RECREATE_DATA)
diff --git a/themes/heo/index.js b/themes/heo/index.js
index 93a7dabb..6fa85552 100644
--- a/themes/heo/index.js
+++ b/themes/heo/index.js
@@ -42,6 +42,7 @@ import SearchNav from './components/SearchNav'
import SideRight from './components/SideRight'
import CONFIG from './config'
import { Style } from './style'
+import AISummary from '@/components/AISummary'
/**
* 基础布局 采用上中下布局,移动端使用顶部侧边导航栏
@@ -306,6 +307,7 @@ const LayoutSlug = props => {