const LLM_TOKEN_EXPIRE_MS = 23 * 60 * 60 * 1000;
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);
}
async function getLLMPassword() {
return new Promise((resolve) => {
chrome.storage.local.get(["llmPassword"], (res) => {
resolve(res.llmPassword || "");
});
});
}
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();
if (!forceRefresh && res.llmToken && res.llmTokenAt && now - res.llmTokenAt < LLM_TOKEN_EXPIRE_MS) {
resolve(res.llmToken);
} else {
try {
const pwd = await getLLMPassword();
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 });
resolve(data[tokenField]);
} else {
reject("LLM token获取失败");
}
} catch (e) {
reject("LLM token接口异常");
}
}
});
});
}
async function getLLMWorkspaceSlug(token, forceRefresh = false) {
const config = await loadMergedConfig();
const wsCfg = config.apiConfig.workspaces;
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();
if (!forceRefresh && res.llmWorkspaceSlug && res.llmWorkspaceAt && now - res.llmWorkspaceAt < LLM_TOKEN_EXPIRE_MS) {
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] || [];
if (list.length > 0 && list[0].slug) {
chrome.storage.local.set({ llmWorkspaceSlug: list[0].slug, llmWorkspaceAt: now });
resolve(list[0].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 = `我: ${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 = "LLM接口请求失败";
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("
");
} else {
answerStr += content;
}
// 渲染 markdown,代码块加复制按钮
let html = "";
try {
html = window.marked ? wrapHtmlWithCopyBtn(marked.parse(answerStr)) : answerStr.replace(/\n/g, "
");
} catch (e) {
html = answerStr.replace(/\n/g, "
");
}
msgBox.innerHTML =
(thinkStr ? `