2025-07-08 14:31:26 +08:00

480 lines
18 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) {
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 : "";
refArr.push(`- [${title}](${url})${scoreText}`);
});
}
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);
}
});
});
}
async function fetchLLMAnswer(question, container, contentDiv) {
if (!question || !question.trim()) return;
// 获取Token关键是加try-catch
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);
contentDiv.scrollTop = contentDiv.scrollHeight;
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);
contentDiv.scrollTop = contentDiv.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);
}
return;
}
}
}
}
} catch (e) {
msgBox.innerHTML += `<span style='color:red;'>LLM异常${e}</span>`;
console.error("LLM问答流式接口异常", e);
}
}