Skip to content

Commit cbfb4e6

Browse files
authored
Merge pull request #117 from AdingApkgg/next
Next
2 parents da062eb + fdb7f7e commit cbfb4e6

File tree

5 files changed

+880
-6
lines changed

5 files changed

+880
-6
lines changed

assets/js/main.js

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
2170
document.addEventListener("DOMContentLoaded", initializePage);
2271

2372
swup.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

Comments
 (0)