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("")) { inThink = true; content = content.replace("", ""); } if (content.includes("")) { inThink = false; content = content.replace("", ""); } if (inThink) { thinkStr += content.replace(/\n/g, "
"); } 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 ? `
${thinkStr}
` : "") + `智能助手:
${html}
`; 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 += `LLM异常:${e}`; } }