@@ -14,10 +14,59 @@ function initializePage() {
1414 initClipboard ( ) ;
1515 fetchDLS ( ) ;
1616 initRankPage ( ) ;
17+ initAIReview ( ) ;
18+ initTOCSidebar ( ) ;
1719 quicklink . listen ( { priority : true } ) ;
1820 endLoading ( ) ;
1921}
2022
23+ /**
24+ * 初始化 TOC 侧边栏
25+ */
26+ function initTOCSidebar ( ) {
27+ const trigger = document . getElementById ( "toc-trigger" ) ;
28+ const overlay = document . getElementById ( "toc-overlay" ) ;
29+ const sidebar = document . getElementById ( "toc-sidebar" ) ;
30+ const closeBtn = document . getElementById ( "toc-close" ) ;
31+
32+ if ( ! trigger || ! sidebar ) return ;
33+
34+ // 延迟添加 ready 类,避免页面加载时闪现
35+ requestAnimationFrame ( ( ) => {
36+ sidebar . classList . add ( "ready" ) ;
37+ } ) ;
38+
39+ const openTOC = ( ) => {
40+ sidebar . classList . add ( "active" ) ;
41+ overlay . classList . add ( "active" ) ;
42+ document . body . style . overflow = "hidden" ;
43+ } ;
44+
45+ const closeTOC = ( ) => {
46+ sidebar . classList . remove ( "active" ) ;
47+ overlay . classList . remove ( "active" ) ;
48+ document . body . style . overflow = "" ;
49+ } ;
50+
51+ trigger . addEventListener ( "click" , openTOC ) ;
52+ overlay . addEventListener ( "click" , closeTOC ) ;
53+ closeBtn . addEventListener ( "click" , closeTOC ) ;
54+
55+ // ESC 键关闭
56+ document . addEventListener ( "keydown" , ( e ) => {
57+ if ( e . key === "Escape" && sidebar . classList . contains ( "active" ) ) {
58+ closeTOC ( ) ;
59+ }
60+ } ) ;
61+
62+ // 点击 TOC 链接后自动关闭
63+ sidebar . querySelectorAll ( "a" ) . forEach ( ( link ) => {
64+ link . addEventListener ( "click" , ( ) => {
65+ closeTOC ( ) ;
66+ } ) ;
67+ } ) ;
68+ }
69+
2170document . addEventListener ( "DOMContentLoaded" , initializePage ) ;
2271
2372swup . hooks . on ( "animation:out:start" , ( ) => {
@@ -1637,3 +1686,281 @@ function initRankPage() {
16371686 // 初始加载
16381687 fetchRankData ( ) ;
16391688}
1689+
1690+ // ==========================================
1691+ // AI 感想生成功能
1692+ // ==========================================
1693+
1694+ /**
1695+ * 初始化 AI 感想功能
1696+ */
1697+ function initAIReview ( ) {
1698+ // 检查是否在文章页面
1699+ const reviewSection = document . getElementById ( "ai-review" ) ;
1700+ if ( ! reviewSection ) {
1701+ return ;
1702+ }
1703+
1704+ const generateBtn = document . getElementById ( "generate-review" ) ;
1705+ const regenerateBtn = document . getElementById ( "regenerate-review" ) ;
1706+ const reviewContent = document . getElementById ( "ai-review-content" ) ;
1707+
1708+ // 获取文章信息
1709+ const articleTitle =
1710+ document . querySelector ( "article h1" ) ?. textContent . trim ( ) || "未知作品" ;
1711+ const articleContent =
1712+ document . querySelector ( ".content[data-pagefind-body]" ) ?. textContent . trim ( ) ||
1713+ "" ;
1714+
1715+ // 隐藏生成按钮,显示重新生成按钮
1716+ if ( generateBtn ) {
1717+ generateBtn . style . display = "none" ;
1718+ }
1719+ if ( regenerateBtn ) {
1720+ regenerateBtn . style . display = "inline-flex" ;
1721+ }
1722+
1723+ // 重新生成按钮点击事件
1724+ if ( regenerateBtn ) {
1725+ regenerateBtn . addEventListener ( "click" , ( ) => {
1726+ generateReview ( articleTitle , articleContent , reviewContent ) ;
1727+ } ) ;
1728+ }
1729+
1730+ // 检查是否有缓存的感想
1731+ const cachedReview = getCachedReview ( articleTitle ) ;
1732+ if ( cachedReview ) {
1733+ // 有缓存,直接显示
1734+ reviewContent . innerHTML = cachedReview ;
1735+ } else {
1736+ // 没有缓存,自动生成
1737+ generateReview ( articleTitle , articleContent , reviewContent ) ;
1738+ }
1739+ }
1740+
1741+ /**
1742+ * 生成 AI 感想
1743+ * @param {string } title - 文章标题
1744+ * @param {string } content - 文章内容
1745+ * @param {HTMLElement } container - 显示容器
1746+ */
1747+ async function generateReview ( title , content , container ) {
1748+ // 显示加载状态
1749+ container . innerHTML = `
1750+ <div class="ai-review-loading">
1751+ <i class="fas fa-spinner fa-spin"></i>
1752+ <p>Asuna 正在思考中...</p>
1753+ </div>
1754+ ` ;
1755+
1756+ try {
1757+ // 构造 prompt
1758+ const prompt = buildPrompt ( title , content ) ;
1759+
1760+ // 调用 Ollama API
1761+ const review = await callOllamaAPI ( prompt , container ) ;
1762+
1763+ if ( review ) {
1764+ // 显示生成的感想
1765+ container . innerHTML = `
1766+ <div class="ai-review-text">
1767+ ${ formatReview ( review ) }
1768+ </div>
1769+ ` ;
1770+
1771+ // 缓存感想
1772+ cacheReview ( title , container . innerHTML ) ;
1773+ }
1774+ } catch ( error ) {
1775+ console . error ( "生成感想失败:" , error ) ;
1776+ container . innerHTML = `
1777+ <div class="ai-review-error">
1778+ <i class="fas fa-exclamation-triangle"></i>
1779+ <p>生成感想时出错了,请稍后重试~</p>
1780+ <small>${ error . message } </small>
1781+ </div>
1782+ ` ;
1783+ }
1784+ }
1785+
1786+ /**
1787+ * 构建 prompt
1788+ * @param {string } title - 文章标题
1789+ * @param {string } content - 文章内容
1790+ * @returns {string } prompt
1791+ */
1792+ function buildPrompt ( title , content ) {
1793+ // 提取文章摘要(限制长度)
1794+ const summary = content . substring ( 0 , 1000 ) ;
1795+
1796+ return `你是刀剑神域(SAO)中的角色结城明日奈(Asuna,亚丝娜)。你是一位温柔、善良且坚强的女性玩家,同时也是一位资深的 Galgame(美少女游戏)爱好者。
1797+
1798+ 现在请你从玩家的角度,对这部名为《${ title } 》的作品分享你的游玩感想。
1799+
1800+ 作品信息:
1801+ ${ summary }
1802+
1803+ 要求:
1804+ 1. 以 Asuna 的第一人称视角书写(使用"我")
1805+ 2. 语气要温柔、亲切,带有一些 Asuna 的性格特点
1806+ 3. 分享你对作品剧情、角色、画面、音乐等方面的感受
1807+ 4. 可以提及一些让你印象深刻的场景或台词
1808+ 5. 表达你对作品的整体评价和推荐程度
1809+ 6. 使用优美的中文,可以适当使用一些情感化的表达
1810+ 7. 不要使用 Markdown 格式,直接输出纯文本
1811+
1812+ 请开始分享你的感想:` ;
1813+ }
1814+
1815+ /**
1816+ * 调用 Ollama API(流式输出)
1817+ * @param {string } prompt - 提示词
1818+ * @param {HTMLElement } container - 显示容器
1819+ * @returns {Promise<string> } 生成的感想
1820+ */
1821+ async function callOllamaAPI ( prompt , container ) {
1822+ // Ollama API 配置
1823+ const OLLAMA_URL = "https://ai.saop.cc/api/generate" ;
1824+ const MODEL = "qwen3:8b" ; // 可以根据需要更改模型
1825+
1826+ try {
1827+ const response = await fetch ( OLLAMA_URL , {
1828+ method : "POST" ,
1829+ headers : {
1830+ "Content-Type" : "application/json" ,
1831+ } ,
1832+ // credentials: 'include', // 如果需要发送 cookies
1833+ body : JSON . stringify ( {
1834+ model : MODEL ,
1835+ prompt : prompt ,
1836+ stream : true ,
1837+ options : {
1838+ temperature : 0.8 ,
1839+ top_p : 0.9 ,
1840+ top_k : 40 ,
1841+ } ,
1842+ } ) ,
1843+ } ) ;
1844+
1845+ if ( ! response . ok ) {
1846+ throw new Error ( `HTTP error! status: ${ response . status } ` ) ;
1847+ }
1848+
1849+ // 处理流式响应
1850+ const reader = response . body . getReader ( ) ;
1851+ const decoder = new TextDecoder ( ) ;
1852+ let fullText = "" ;
1853+ let buffer = "" ;
1854+
1855+ // 显示流式输出容器
1856+ container . innerHTML = `
1857+ <div class="ai-review-text ai-review-streaming">
1858+ <div class="streaming-text"></div>
1859+ <span class="streaming-cursor">▊</span>
1860+ </div>
1861+ ` ;
1862+
1863+ const streamingText = container . querySelector ( ".streaming-text" ) ;
1864+
1865+ while ( true ) {
1866+ const { done, value } = await reader . read ( ) ;
1867+
1868+ if ( done ) {
1869+ break ;
1870+ }
1871+
1872+ // 解码并处理数据
1873+ buffer += decoder . decode ( value , { stream : true } ) ;
1874+ const lines = buffer . split ( "\n" ) ;
1875+ buffer = lines . pop ( ) || "" ;
1876+
1877+ for ( const line of lines ) {
1878+ if ( line . trim ( ) ) {
1879+ try {
1880+ const data = JSON . parse ( line ) ;
1881+ if ( data . response ) {
1882+ fullText += data . response ;
1883+ streamingText . textContent = fullText ;
1884+
1885+ // 自动滚动到底部
1886+ container . scrollTop = container . scrollHeight ;
1887+ }
1888+ } catch ( e ) {
1889+ console . warn ( "解析 JSON 失败:" , e ) ;
1890+ }
1891+ }
1892+ }
1893+ }
1894+
1895+ // 移除光标
1896+ const cursor = container . querySelector ( ".streaming-cursor" ) ;
1897+ if ( cursor ) {
1898+ cursor . remove ( ) ;
1899+ }
1900+
1901+ return fullText ;
1902+ } catch ( error ) {
1903+ if ( error . message . includes ( "Failed to fetch" ) ) {
1904+ throw new Error (
1905+ "无法连接到 Ollama 服务,请确保 Ollama 正在运行(http://localhost:11434)"
1906+ ) ;
1907+ }
1908+ throw error ;
1909+ }
1910+ }
1911+
1912+ /**
1913+ * 格式化感想文本
1914+ * @param {string } text - 原始文本
1915+ * @returns {string } 格式化后的 HTML
1916+ */
1917+ function formatReview ( text ) {
1918+ // 将文本按段落分割
1919+ const paragraphs = text
1920+ . split ( / \n \n + / )
1921+ . map ( ( p ) => p . trim ( ) )
1922+ . filter ( ( p ) => p . length > 0 ) ;
1923+
1924+ // 转换为 HTML 段落
1925+ return paragraphs . map ( ( p ) => `<p>${ escapeHtml ( p ) } </p>` ) . join ( "" ) ;
1926+ }
1927+
1928+ /**
1929+ * 转义 HTML 特殊字符
1930+ * @param {string } text - 原始文本
1931+ * @returns {string } 转义后的文本
1932+ */
1933+ function escapeHtml ( text ) {
1934+ const div = document . createElement ( "div" ) ;
1935+ div . textContent = text ;
1936+ return div . innerHTML ;
1937+ }
1938+
1939+ /**
1940+ * 缓存感想到 localStorage
1941+ * @param {string } title - 文章标题
1942+ * @param {string } content - 感想内容
1943+ */
1944+ function cacheReview ( title , content ) {
1945+ try {
1946+ const cacheKey = `ai-review-${ encodeURIComponent ( title ) } ` ;
1947+ localStorage . setItem ( cacheKey , content ) ;
1948+ } catch ( e ) {
1949+ console . warn ( "缓存感想失败:" , e ) ;
1950+ }
1951+ }
1952+
1953+ /**
1954+ * 从 localStorage 获取缓存的感想
1955+ * @param {string } title - 文章标题
1956+ * @returns {string|null } 缓存的感想内容
1957+ */
1958+ function getCachedReview ( title ) {
1959+ try {
1960+ const cacheKey = `ai-review-${ encodeURIComponent ( title ) } ` ;
1961+ return localStorage . getItem ( cacheKey ) ;
1962+ } catch ( e ) {
1963+ console . warn ( "读取缓存失败:" , e ) ;
1964+ return null ;
1965+ }
1966+ }
0 commit comments