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 ( +
+
+
+
+ + + +
+
AI智能摘要
+
GPT
+
+
+
+ {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 => {
+ {post && }