2025-07-13 22:02:04 +08:00

617 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

async function resolveJumpLinks(titles) {
if (!titles || titles.length === 0) return {};
const config = await loadMergedConfig();
const filePathCfg = config.apiConfig.filePath;
const publicBase = config.apiConfig.materialPublicUrl.url;
const reqField = filePathCfg.requestParams[0].field;
const respField = filePathCfg.responseParams[0].field;
return new Promise((resolveOuter) => {
chrome.storage.local.get(["token"], async (res) => {
const token = res.token;
if (!token) return resolveOuter({});
try {
const body = {};
body[reqField] = titles;
const resp = await fetch(filePathCfg.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": token
},
body: JSON.stringify(body)
});
const data = await resp.json();
const fileMap = data[respField] || {};
const map = {};
for (const title of Object.keys(fileMap)) {
if (fileMap[title]) {
const cleanPath = fileMap[title].replace(/\.md$/, ".html");
map[title] = `${cleanPath}`;
}
}
resolveOuter(map);
} catch (e) {
resolveOuter({});
}
});
});
}
async function renderSources(sourceList, sources, jumpMap) {
sourceList.style.marginTop = "12px";
sourceList.style.borderTop = "1px solid #dbe3f3";
sourceList.style.paddingTop = "7px";
const titleBar = document.createElement("div");
titleBar.textContent = "📎 参考资料";
titleBar.style.fontWeight = "bold";
titleBar.style.marginBottom = "6px";
titleBar.style.fontSize = "13.5px";
titleBar.style.color = "#0d2fec";
sourceList.appendChild(titleBar);
// ul 包裹所有参考资料
const ul = document.createElement("ul");
ul.style.paddingLeft = "1.2em";
ul.style.margin = "0";
ul.style.listStyle = "disc";
ul.style.fontSize = "14px";
for (const src of sources) {
const url = jumpMap[src.title];
const li = document.createElement("li");
li.style.marginBottom = "3px";
li.style.lineHeight = "1.7";
let scoreText = "";
if (typeof src.score === "number") {
scoreText = `(相似度:${(src.score * 100).toFixed(2)}%`;
}
if (url) {
const link = document.createElement("a");
link.href = url;
link.textContent = src.title || "未命名文档";
link.target = "_blank";
link.style.color = "#2560de";
link.style.textDecoration = "none";
link.onmouseenter = () => (link.style.textDecoration = "underline");
link.onmouseleave = () => (link.style.textDecoration = "none");
li.appendChild(link);
if (scoreText) {
const scoreSpan = document.createElement("span");
scoreSpan.style.color = "#8990a1";
scoreSpan.style.fontSize = "12.5px";
scoreSpan.style.marginLeft = "6px";
scoreSpan.textContent = scoreText;
li.appendChild(scoreSpan);
}
} else {
li.textContent = (src.title || "未命名文档") + (scoreText ? " " + scoreText : "");
li.style.color = "#666";
}
ul.appendChild(li);
}
sourceList.appendChild(ul);
}
// === 复制代码块功能 ===
function wrapHtmlWithCopyBtn(html) {
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
wrapper.querySelectorAll('pre > code').forEach(codeEl => {
const pre = codeEl.parentElement;
if (pre.querySelector('.copy-btn')) return;
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.innerText = '复制';
pre.appendChild(btn);
pre.style.position = 'relative';
});
return wrapper.innerHTML;
}
function bindAllCopyButtons(container) {
(container || document).querySelectorAll('.llm-md .copy-btn').forEach(btn => {
if (btn.dataset.bind === "true") return;
btn.dataset.bind = "true";
btn.addEventListener('click', function (e) {
e.stopPropagation();
const codeEl = btn.parentElement.querySelector('code');
const codeText = codeEl ? (codeEl.innerText || codeEl.textContent) : "";
let success = false, error = null;
try {
const textarea = document.createElement("textarea");
textarea.value = codeText;
textarea.setAttribute('readonly', '');
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
success = document.execCommand('copy');
document.body.removeChild(textarea);
if (success) {
btn.innerText = '已复制!';
setTimeout(() => btn.innerText = '复制', 1200);
} else {
btn.innerText = '失败';
setTimeout(() => btn.innerText = '复制', 1200);
}
} catch (err) {
error = err;
btn.innerText = '失败';
setTimeout(() => btn.innerText = '复制', 1200);
}
if (!success) {
alert("复制失败: " + (error ? error : "未知错误"));
}
});
});
}
function insertCopyFullAnswerBtn(msgBox, answerStr) {
if (msgBox.querySelector('.copy-full-answer-btn')) return;
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-full-answer-btn';
copyBtn.innerText = '复制全部回答';
copyBtn.style.margin = "12px 0 0 0";
copyBtn.style.display = "block";
copyBtn.style.background = "#eef1f9";
copyBtn.style.color = "#2d53a7";
copyBtn.style.border = "1px solid #dde4ef";
copyBtn.style.borderRadius = "6px";
copyBtn.style.padding = "5px 18px";
copyBtn.style.fontSize = "13.2px";
copyBtn.style.cursor = "pointer";
copyBtn.style.float = "right";
copyBtn.style.position = "relative";
copyBtn.style.right = "0";
copyBtn.style.transition = "background 0.13s";
copyBtn.onmouseenter = () => (copyBtn.style.background = "#dbeafe");
copyBtn.onmouseleave = () => (copyBtn.style.background = "#eef1f9");
copyBtn.onclick = () => {
// 1. 回答正文
let answerText = (answerStr || "").trim();
// 2. 参考资料 markdown遍历 ul/li/a
let refArr = [];
const ul = msgBox.querySelector('.llm-sources ul');
if (ul) {
const seen = new Set();
ul.querySelectorAll('li').forEach(li => {
let link = li.querySelector('a');
let title = link ? link.textContent : li.textContent;
let url = link ? link.getAttribute('href') : "";
// 分值span
let scoreSpan = li.querySelector('span');
let scoreText = scoreSpan ? scoreSpan.textContent : "";
// 标识唯一性用 title+url
let key = title + '|' + url;
if (!seen.has(key)) {
refArr.push(`- [${title}](${url})${scoreText}`);
seen.add(key);
}
});
}
let refText = "";
if (refArr.length > 0) {
refText = "\n\n参考资料\n" + refArr.join("\n");
}
const textToCopy = answerText + refText;
try {
const textarea = document.createElement("textarea");
textarea.value = textToCopy;
textarea.setAttribute('readonly', '');
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
copyBtn.innerText = "已复制!";
setTimeout(() => copyBtn.innerText = "复制全部回答", 1200);
} catch (err) {
copyBtn.innerText = "复制失败";
setTimeout(() => copyBtn.innerText = "复制全部回答", 1200);
alert("复制失败:" + err);
}
};
msgBox.appendChild(copyBtn);
}
function promptLLMPasswordModal() {
return new Promise((resolve) => {
// 避免重复弹窗
if (document.getElementById("llm-password-modal")) return resolve("");
// 创建遮罩层
const mask = document.createElement("div");
mask.id = "llm-password-modal-mask";
mask.style = `
position: fixed; left:0; top:0; width:100vw; height:100vh; background:rgba(0,0,0,0.19);
z-index:9998;`;
// 创建弹窗
const modal = document.createElement("div");
modal.id = "llm-password-modal";
modal.style = `
position: fixed; top: 45%; left: 50%; transform: translate(-50%, -50%);
background: #fff; border-radius: 10px; box-shadow: 0 8px 38px #0001;
padding: 24px 32px 18px 32px; z-index:9999; min-width:260px; text-align:center;`;
modal.innerHTML = `
<div style="font-size:1rem;margin-bottom:13px;">LLM Token 已过期,请输入新的 LLM 密码:</div>
<input id="llm-password-input" type="password" style="width:95%;padding:7px 8px;font-size:1rem;border-radius:6px;border:1px solid #bbb;margin-bottom:10px;" autofocus />
<div>
<button id="llm-password-ok" style="margin-right:10px;padding:6px 18px;">确定</button>
<button id="llm-password-cancel" style="padding:6px 18px;">取消</button>
</div>
`;
document.body.appendChild(mask);
document.body.appendChild(modal);
function closeModal(val) {
document.body.removeChild(mask);
document.body.removeChild(modal);
resolve(val);
}
document.getElementById("llm-password-ok").onclick = () => {
const pwd = document.getElementById("llm-password-input").value;
closeModal(pwd ? pwd.trim() : "");
};
document.getElementById("llm-password-cancel").onclick = () => closeModal("");
document.getElementById("llm-password-input").onkeydown = (e) => {
if (e.key === "Enter") document.getElementById("llm-password-ok").click();
};
});
}
async function getLLMToken(forceRefresh = false) {
const config = await loadMergedConfig();
const auth = await loadUserToken();
if (!auth) {
handleLogout(auth);
return;
}
const userToken = auth.token;
const tokenCfg = config.apiConfig.llmToken;
const expireMs = config.llmTokenExpireHours * 60 * 60 * 1000;
// 检查缓存
return new Promise((resolve, reject) => {
chrome.storage.local.get(["llmToken", "llmTokenAt"], async (res) => {
try {
const now = Date.now();
if (!forceRefresh && res.llmToken && res.llmTokenAt && now - res.llmTokenAt < expireMs) {
return resolve(res.llmToken);
}
// 弹窗输入密码
let pwd = await promptLLMPasswordModal();
if (!pwd) return reject("未输入密码,无法获取 LLM Token");
// 构造请求
const body = {};
body[tokenCfg.requestParams[0].field] = pwd;
let response, text, data;
try {
response = await fetch(tokenCfg.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `${userToken}`
},
body: JSON.stringify(body)
});
text = await response.text();
} catch (e) {
return reject("LLM token接口网络异常: " + e);
}
try {
data = JSON.parse(text);
} catch (e) {
return reject("LLM token接口返回非JSON: " + text);
}
const tokenParam = tokenCfg.responseParams.find(p => p.label && p.label.includes("token"));
const tokenField = tokenParam ? tokenParam.field : "token";
if (data[tokenField]) {
chrome.storage.local.set({ llmToken: data[tokenField], llmTokenAt: now });
return resolve(data[tokenField]);
} else {
return reject("LLM token获取失败响应内容: " + text);
}
} catch (e) {
return reject("getLLMToken代码异常: " + e);
}
});
});
}
// =========== 智能助手核心 ============
let isUserAtBottom = true;
let scrollListenerInited = false;
async function fetchLLMAnswer(question, container, contentDiv) {
if (!question || !question.trim()) return;
// 历史:用户输入时追加
appendLlmHistory({ role: "user", text: question });
// 获取Token
let llmToken;
try {
llmToken = await getLLMToken();
} catch (e) {
alert("LLM Token 获取失败:" + e);
return;
}
const config = await loadMergedConfig();
const auth = await loadUserToken();
if (!auth) {
handleLogout(auth);
return;
}
const userToken = auth.token;
const url = config.apiConfig.llmChat.url;
// ... 渲染用户输入 ...
const userItem = document.createElement("div");
userItem.className = "llm-msg-item llm-user";
userItem.innerHTML = `<span style="color:#2a63c8;font-weight:bold;">我:</span> ${question}`;
contentDiv.appendChild(userItem);
container.classList.remove("hidden");
const msgBox = document.createElement("div");
msgBox.className = "llm-msg-item llm-assistant";
contentDiv.appendChild(msgBox);
// 只绑定一次滚动监听,且在首次流式渲染时保证已绑定
const resultContent = document.getElementById("llmResultContent");
if (resultContent && !scrollListenerInited) {
resultContent.addEventListener("scroll", function() {
isUserAtBottom = resultContent.scrollTop + resultContent.clientHeight >= resultContent.scrollHeight - 20;
});
scrollListenerInited = true;
}
let collectedSources = [];
let jumpMap = {};
// LLM 问答接口流式处理
try {
const body = { llmToken, question };
const res = await fetch(url, {
method: "POST",
headers: {
"Authorization": `${userToken}`,
"Accept": "text/event-stream",
"Content-Type": "application/json"
},
body: JSON.stringify(body)
});
if (!res.ok || !res.body) {
msgBox.innerHTML = "<span style='color:red;'>LLM接口请求失败</span>";
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "", done = false;
let thinkStr = "", answerStr = "", inThink = false;
while (!done) {
const { value, done: readerDone } = await reader.read();
done = readerDone;
if (value) {
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop(); // 留给下次未完整数据
for (let line of lines) {
line = line.trim();
if (!line.startsWith("data:")) continue;
const chunkStr = line.slice(5).trim();
if (!chunkStr) continue;
let chunk;
try {
chunk = JSON.parse(chunkStr);
} catch (err) {
console.warn("非法 JSON", chunkStr);
continue;
}
// 收集 sources
if (chunk.sources) {
collectedSources = chunk.sources;
}
// 只要有 textResponse 就累加
let content = chunk.textResponse ?? "";
if (content.includes("<think>")) {
inThink = true;
content = content.replace("<think>", "");
}
if (content.includes("</think>")) {
inThink = false;
content = content.replace("</think>", "");
}
if (inThink) {
thinkStr += content.replace(/\n/g, "<br>");
} else {
answerStr += content;
}
// 渲染
let html = "";
try {
html = window.marked ? wrapHtmlWithCopyBtn(marked.parse(answerStr)) : answerStr.replace(/\n/g, "<br>");
} catch (e) {
html = answerStr.replace(/\n/g, "<br>");
}
msgBox.innerHTML =
(thinkStr ? `<div class="llm-think">${thinkStr}</div>` : "") +
`<span class="assistant-label">智能助手:</span><div class="llm-md">${html}</div>`;
bindAllCopyButtons(msgBox);
insertCopyFullAnswerBtn(msgBox, answerStr);
// 只在用户停留底部时才自动滚到底
if (resultContent && isUserAtBottom) {
resultContent.scrollTop = resultContent.scrollHeight;
}
// 收尾渲染引用 & 持久化历史
if (chunk.close === true) {
if (collectedSources.length > 0) {
const titles = collectedSources.map(s => s.title).filter(Boolean);
jumpMap = await resolveJumpLinks(titles);
const sourceList = document.createElement("div");
sourceList.className = "llm-sources";
msgBox.appendChild(sourceList);
await renderSources(sourceList, collectedSources, jumpMap);
insertCopyFullAnswerBtn(msgBox, answerStr);
}
// 历史追加 assistant 回答
appendLlmHistory({
role: "assistant",
text: answerStr,
sources: collectedSources,
jumpMap: jumpMap
});
// 自动滚动到底部
if (resultContent) resultContent.scrollTop = resultContent.scrollHeight;
return;
}
}
}
}
} catch (e) {
msgBox.innerHTML += `<span style='color:red;'>LLM异常${e}</span>`;
console.error("LLM问答流式接口异常", e);
}
}
// 历史加载
async function renderLlmHistory(contentDiv) {
chrome.storage.local.get(["llmHistory"], (res) => {
const history = res.llmHistory || [];
contentDiv.innerHTML = "";
for (const item of history) {
if (item.role === 'user') {
const userItem = document.createElement("div");
userItem.className = "llm-msg-item llm-user";
userItem.innerHTML = `<span style="color:#2a63c8;font-weight:bold;">我:</span> ${item.text}`;
contentDiv.appendChild(userItem);
} else if (item.role === 'assistant') {
const msgBox = document.createElement("div");
msgBox.className = "llm-msg-item llm-assistant";
let html = "";
try {
html = window.marked ? wrapHtmlWithCopyBtn(marked.parse(item.text)) : item.text.replace(/\n/g, "<br>");
} catch (e) {
html = item.text.replace(/\n/g, "<br>");
}
msgBox.innerHTML = `<span class="assistant-label">智能助手:</span><div class="llm-md">${html}</div>`;
bindAllCopyButtons(msgBox);
insertCopyFullAnswerBtn(msgBox, item.text);
// 渲染参考资料
if (item.sources && item.sources.length) {
const sourceList = document.createElement("div");
sourceList.className = "llm-sources";
msgBox.appendChild(sourceList);
renderSources(sourceList, item.sources, item.jumpMap || {});
insertCopyFullAnswerBtn(msgBox, item.text);
}
contentDiv.appendChild(msgBox);
}
}
// 自动滚到底
contentDiv.scrollTop = contentDiv.scrollHeight;
});
}
// 对话历史追加
function appendLlmHistory(item) {
chrome.storage.local.get(["llmHistory"], (res) => {
const history = res.llmHistory || [];
history.push(item);
chrome.storage.local.set({ llmHistory: history });
});
}
// 清空历史按钮绑定
document.addEventListener("DOMContentLoaded", () => {
const clearBtn = document.getElementById("clearLlmHistoryBtn");
if (clearBtn) {
clearBtn.onclick = () => {
if (confirm("确定要清空智能助手历史记录吗?")) {
chrome.storage.local.set({ llmHistory: [] }, () => {
const contentDiv = document.getElementById("llmResultContent");
if (contentDiv) contentDiv.innerHTML = "";
});
}
}
}
// textarea 自动高度适应
const input = document.getElementById("llmInput");
const sendBtn = document.getElementById("llmSendBtn");
if (input && input.tagName.toLowerCase() === 'textarea') {
function autoResizeTextarea(el) {
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}
input.addEventListener("input", function() {
autoResizeTextarea(input);
});
autoResizeTextarea(input);
// 发送后清空并适应高度
sendBtn.addEventListener("click", () => {
setTimeout(() => {
input.value = "";
autoResizeTextarea(input);
}, 10); // 让你的业务代码先执行(如提取 input.value 后再清空)
});
// 支持Enter发送Ctrl+Enter/Shift+Enter换行
input.addEventListener("keydown", function(e) {
if (e.key === "Enter" && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
sendBtn.click();
}
// 其他情况浏览器原生处理
});
}
});
// 智能助手 tab 被激活时刷新历史
function tryRenderLlmHistoryOnTabSwitch() {
const tabAi = document.getElementById("tabAi");
tabAi && tabAi.addEventListener("click", () => {
const contentDiv = document.getElementById("llmResultContent");
renderLlmHistory(contentDiv);
});
}
tryRenderLlmHistoryOnTabSwitch();