2025-06-01 23:31:40 +08:00

416 lines
16 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.

const LLM_TOKEN_API = "http://llm.wisdompulse.cn/api/request-token";
const LLM_WORKSPACES_API = "http://llm.wisdompulse.cn/api/workspaces";
const LLM_STREAM_API_BASE = "http://llm.wisdompulse.cn/api/workspace";
const LLM_TOKEN_EXPIRE_MS = 23 * 60 * 60 * 1000;
async function resolveJumpLinks(titles) {
if (!titles || titles.length === 0) return {};
return new Promise((resolveOuter) => {
chrome.storage.local.get(["token"], async (res) => {
const token = res.token;
if (!token) {
console.warn("resolveJumpLinks 缺少 token跳过");
return resolveOuter({});
}
try {
const res = await fetch("http://app.wisdompulse.cn/search/file-path", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": token
},
body: JSON.stringify({ fileNames: titles })
});
const data = await res.json();
const map = {};
for (const title of Object.keys(data)) {
if (data[title]) {
const cleanPath = data[title].replace(/\.md$/, ".html");
map[title] = `http://share.wisdompulse.cn/public/${cleanPath}`;
}
}
resolveOuter(map);
} catch (e) {
console.warn("resolveJumpLinks 接口异常:", 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);
}
async function getLLMPassword() {
return new Promise((resolve) => {
chrome.storage.local.get(["llmPassword"], (res) => {
resolve(res.llmPassword || "");
});
});
}
async function getLLMToken(forceRefresh = false) {
return new Promise((resolve, reject) => {
chrome.storage.local.get(["llmToken", "llmTokenAt"], async (res) => {
const now = Date.now();
if (!forceRefresh && res.llmToken && res.llmTokenAt && now - res.llmTokenAt < LLM_TOKEN_EXPIRE_MS) {
log("LLM token 命中缓存");
resolve(res.llmToken);
} else {
try {
const pwd = await getLLMPassword();
const response = await fetch(LLM_TOKEN_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pwd }),
});
const data = await response.json();
if (data.token) {
chrome.storage.local.set({ llmToken: data.token, llmTokenAt: now });
resolve(data.token);
} else {
reject("LLM token获取失败");
}
} catch (e) {
reject("LLM token接口异常");
}
}
});
});
}
async function getLLMWorkspaceSlug(token, forceRefresh = false) {
return new Promise((resolve, reject) => {
chrome.storage.local.get(["llmWorkspaceSlug", "llmWorkspaceAt"], async (res) => {
const now = Date.now();
if (!forceRefresh && res.llmWorkspaceSlug && res.llmWorkspaceAt && now - res.llmWorkspaceAt < LLM_TOKEN_EXPIRE_MS) {
resolve(res.llmWorkspaceSlug);
} else {
try {
const response = await fetch(LLM_WORKSPACES_API, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
const data = await response.json();
if (data.workspaces && data.workspaces.length > 0 && data.workspaces[0].slug) {
chrome.storage.local.set({ llmWorkspaceSlug: data.workspaces[0].slug, llmWorkspaceAt: now });
resolve(data.workspaces[0].slug);
} else {
reject("未获取到LLM工作区slug");
}
} catch (e) {
reject("LLM工作区接口异常");
}
}
});
});
}
// 代码块复制按钮插入
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) {
log("[复制代码] 成功,内容:", codeText);
} else {
console.error("[复制代码] 失败", error, "内容:", codeText);
alert("复制失败: " + (error ? error : "未知错误"));
}
});
});
}
// 复制全部回答按钮从HTML解析ul/li/a结构
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);
}
// 主体流式渲染 fetchLLMAnswer
async function fetchLLMAnswer(question, container, contentDiv) {
if (!question || !question.trim()) return;
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 token = await getLLMToken();
const workspace = await getLLMWorkspaceSlug(token);
const url = `${LLM_STREAM_API_BASE}/${workspace}/stream-chat`;
const res = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ message: question, attachments: [] })
});
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 data = line.slice(6);
const chunk = JSON.parse(data);
// 收集 sources去重按 id
if (chunk.sources && Array.isArray(chunk.sources)) {
for (const src of chunk.sources) {
if (!collectedSources.find(s => s.id === src.id)) {
collectedSources.push(src);
}
}
}
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 原文
}
// 渲染 markdown代码块加复制按钮
let html = "";
try {
html = window.marked ? 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">${wrapHtmlWithCopyBtn(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";
sourceList.style.marginTop = "10px";
sourceList.style.fontSize = "13px";
sourceList.style.color = "#555";
msgBox.appendChild(sourceList);
await renderSources(sourceList, collectedSources, jumpMap);
insertCopyFullAnswerBtn(msgBox, answerStr);
}
} catch (e) {
msgBox.innerHTML += `<span style='color:red;'>LLM异常${e}</span>`;
}
}