2025-06-03 23:31:12 +08:00

481 lines
19 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] = `${publicBase}/${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;
// 创建遮罩层
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 tokenCfg = config.apiConfig.llmToken;
return new Promise((resolve, reject) => {
chrome.storage.local.get(["llmToken", "llmTokenAt"], async (res) => {
const now = Date.now();
const mergedConfig = await loadMergedConfig();
const expireMs = mergedConfig.llmTokenExpireHours * 60 * 60 * 1000;
if (!forceRefresh && res.llmToken && res.llmTokenAt && now - res.llmTokenAt < expireMs) {
log("已从缓存中获取到llmToken. expireMs: " + expireMs);
resolve(res.llmToken);
} else {
try {
// 这里不再用 getLLMPassword(),而是直接弹窗
let pwd = await promptLLMPasswordModal();
if (!pwd) {
reject("未输入密码,无法获取 LLM Token");
return;
}
// 可选:此处不自动存储 llmPassword提升安全性
const body = {};
body[tokenCfg.requestParams[0].field] = pwd;
const response = await fetch(tokenCfg.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await response.json();
const tokenField = tokenCfg.responseParams.find(p => p.label.includes("Token")).field;
if (data[tokenField]) {
chrome.storage.local.set({ llmToken: data[tokenField], llmTokenAt: now });
log("请求llmToken成功");
resolve(data[tokenField]);
} else {
log("LLM token获取失败");
reject("LLM token获取失败");
}
} catch (e) {
reject("LLM token接口异常");
}
}
});
});
}
async function getLLMWorkspaceSlug(token, forceRefresh = false) {
const config = await loadMergedConfig();
const wsCfg = config.apiConfig.workspaces;
const llmWorkspaceName = config.llmWorkspaceName;
const workspacesField = wsCfg.responseParams.find(p => p.field)?.field;
return new Promise((resolve, reject) => {
chrome.storage.local.get(["llmWorkspaceSlug", "llmWorkspaceAt"], async (res) => {
const now = Date.now();
const mergedConfig = await loadMergedConfig();
const expireMs = mergedConfig.llmTokenExpireHours * 60 * 60 * 1000;
if (!forceRefresh && res.llmWorkspaceSlug && res.llmWorkspaceAt && now - res.llmWorkspaceAt < expireMs) {
log("获取llmWorkspaceSlug. expireMs: " + expireMs)
resolve(res.llmWorkspaceSlug);
} else {
try {
const response = await fetch(wsCfg.url, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
const data = await response.json();
const list = data[workspacesField] || [];
let target = list.find(ws => ws.name === llmWorkspaceName);
// 若找不到,默认用第一个
if (!target && list.length > 0) target = list[0];
if (target && target.slug) {
chrome.storage.local.set({ llmWorkspaceSlug: target.slug, llmWorkspaceAt: now });
log("获取llmWorkspaceSlug: " + JSON.stringify(target));
resolve(target.slug);
} else {
reject("未获取到LLM工作区slug");
}
} catch (e) {
reject("LLM工作区接口异常");
}
}
});
});
}
async function fetchLLMAnswer(question, container, contentDiv) {
if (!question || !question.trim()) return;
const config = await loadMergedConfig();
const llmChatCfg = config.apiConfig.llmChat;
const llmToken = await getLLMToken();
const workspaceSlug = await getLLMWorkspaceSlug(llmToken);
const url = llmChatCfg.url.replace("${workId}", workspaceSlug);
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 = {};
try {
const body = {};
body[llmChatCfg.requestParams[0].field] = question;
body[llmChatCfg.requestParams[1].field] = [];
const res = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${llmToken}`,
"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");
buffer = lines.pop();
for (let line of lines) {
if (line.startsWith("data: ")) {
const chunk = JSON.parse(line.slice(6));
if (chunk.sources) {
for (const s of chunk.sources) {
if (!collectedSources.find(x => x.id === s.id)) {
collectedSources.push(s);
}
}
}
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;
}
// 渲染 markdown代码块加复制按钮
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 (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);
}
} catch (e) {
msgBox.innerHTML += `<span style='color:red;'>LLM异常${e}</span>`;
}
}