// ==UserScript== // @name AI 管家 · 记忆助手 // @namespace ai-hub // @version 0.3.1 // @description 自动采集 ChatGPT / DeepSeek / 豆包网页版 的对话存入本机记忆库(127.0.0.1:8765),任意 AI 里都能搜索记忆并一键注入输入框。数据只发往本机。 // @author AI Hub // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @match https://chat.deepseek.com/* // @match https://www.doubao.com/* // @match https://doubao.com/* // @run-at document-idle // @noframes // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @connect 127.0.0.1 // @connect localhost // ==/UserScript== /* eslint-disable no-empty */ (function () { "use strict"; /* ============================================================ * 0. 基础配置 * ============================================================ */ var DEFAULT_API = "http://127.0.0.1:8765"; var API_BASE = DEFAULT_API; try { API_BASE = (GM_getValue("aihub_api", DEFAULT_API) || DEFAULT_API).replace(/\/+$/, ""); } catch (e) {} var QUEUE_KEY = "aihub_queue"; var AUTO_KEY = "aihub_auto"; var MAX_QUEUE = 300; function log() { var args = Array.prototype.slice.call(arguments); args.unshift("[AI 管家]"); console.log.apply(console, args); } function warn() { var args = Array.prototype.slice.call(arguments); args.unshift("[AI 管家]"); console.warn.apply(console, args); } function getValue(key, def) { try { return GM_getValue(key, def); } catch (e) { return def; } } function setValue(key, val) { try { GM_setValue(key, val); } catch (e) {} } /* ============================================================ * 1. 本地服务通信(GM_xmlhttpRequest 优先,fetch 兜底) * ============================================================ */ function request(opts) { // opts: {method, path, body, timeout} var url = API_BASE + opts.path; var method = opts.method || "GET"; var timeout = opts.timeout || 6000; var data = opts.body ? JSON.stringify(opts.body) : null; return new Promise(function (resolve) { var done = function (ok, payload) { resolve({ ok: ok, data: payload }); }; if (typeof GM_xmlhttpRequest === "function") { try { GM_xmlhttpRequest({ method: method, url: url, data: data, headers: data ? { "Content-Type": "application/json" } : undefined, timeout: timeout, onload: function (r) { var parsed = null; try { parsed = JSON.parse(r.responseText); } catch (e) { parsed = null; } done(r.status >= 200 && r.status < 300 && parsed !== null, parsed); }, onerror: function () { done(false, null); }, ontimeout: function () { done(false, null); }, onabort: function () { done(false, null); }, }); return; } catch (e) { warn("GM_xmlhttpRequest 异常,改用 fetch:", e); } } try { var init = { method: method }; if (data) { init.headers = { "Content-Type": "application/json" }; init.body = data; } fetch(url, init) .then(function (r) { return r.json(); }) .then(function (j) { done(true, j); }) .catch(function () { done(false, null); }); } catch (e) { done(false, null); } }); } function health() { return request({ path: "/api/health" }); } function postMemories(payload) { return request({ method: "POST", path: "/api/memories", body: payload }); } function searchMemories(q, limit) { return request({ path: "/api/memories?q=" + encodeURIComponent(q) + "&limit=" + (limit || 12) }); } function fetchStats() { return request({ path: "/api/stats" }); } /* ---- 离线队列:服务没开时先攒着,服务起来后自动补发 ---- */ function readQueue() { var q = getValue(QUEUE_KEY, []); return Array.isArray(q) ? q : []; } function pushQueue(item) { var q = readQueue(); q.push(item); while (q.length > MAX_QUEUE) q.shift(); setValue(QUEUE_KEY, q); } function clearQueue() { setValue(QUEUE_KEY, []); } var flushing = false; function flushQueue() { if (flushing) return; var q = readQueue(); if (!q.length) return; flushing = true; postMemories(q[0]).then(function (r) { flushing = false; if (r.ok && r.data && r.data.ok) { var rest = readQueue(); rest.shift(); setValue(QUEUE_KEY, rest); log("离线补发成功,剩余 " + rest.length + " 批"); if (rest.length) setTimeout(flushQueue, 400); } }); } function sendToServer(payload) { return postMemories(payload).then(function (r) { if (r.ok && r.data && r.data.ok) { if (r.data.added > 0) log("已存入 " + r.data.added + " 条新记忆 → " + payload.source); flushQueue(); return { ok: true, added: r.data.added || 0, deduped: r.data.deduped || 0 }; } pushQueue(payload); warn("本地服务未响应,已暂存到离线队列(共 " + readQueue().length + " 批)"); return { ok: false, added: 0, deduped: 0 }; }); } /* ============================================================ * 2. 站点适配器 * ============================================================ */ function cleanText(s) { return String(s || "") .replace(/\u00a0/g, " ") .replace(/\r\n?/g, "\n") .split("\n") .map(function (l) { return l.replace(/[ \t]+/g, " ").trim(); }) .filter(Boolean) .join("\n") .trim(); } function uniqMessages(msgs) { var seen = {}; var out = []; msgs.forEach(function (m) { var c = (m.content || "").trim(); if (!c) return; var k = m.role + "\u0000" + c; if (seen[k]) return; seen[k] = 1; out.push({ role: m.role, content: c }); }); return out; } /* ---- 通用兜底:按 DOM 顺序抓「消息块」,估算角色 ---- */ function genericExtract() { var out = []; var nodes = Array.prototype.slice.call( document.querySelectorAll( "[data-message-author-role],[data-testid*='message'],[class*='message-bubble'],[class*='markdown']" ) ); // 只保留最外层消息节点,避免父子重复 var top = nodes.filter(function (n) { return !nodes.some(function (o) { return o !== n && o.contains(n); }); }); top.forEach(function (n) { var text = cleanText(n.innerText); if (!text || text.length < 1) return; var role = n.getAttribute("data-message-author-role") || ""; if (!role) { var testid = (n.getAttribute("data-testid") || "").toLowerCase(); var cls = (n.className && String(n.className) || "").toLowerCase(); if (/assistant|receive|ai|bot/.test(testid) || /assistant|receive|markdown/.test(cls)) role = "assistant"; else if (/user|send/.test(testid) || /user|send/.test(cls)) role = "user"; else role = "unknown"; } if (role === "system") return; out.push({ role: role, content: text }); }); return uniqMessages(out); } function findByRoleAttr() { var out = []; Array.prototype.slice.call(document.querySelectorAll("[data-message-author-role]")).forEach(function (el) { var role = el.getAttribute("data-message-author-role"); if (!role || role === "system") return; var text = cleanText(el.innerText); if (text) out.push({ role: role, content: text }); }); return uniqMessages(out); } var ADAPTERS = { /* ---------------- ChatGPT ---------------- */ "chatgpt.com": { hosts: ["chatgpt.com", "chat.openai.com"], label: "ChatGPT", source: "gpt", extract: function () { var byRole = findByRoleAttr(); if (byRole.length) return byRole; return genericExtract(); }, getInputBox: function () { return document.querySelector("#prompt-textarea") || document.querySelector("div[contenteditable='true'].ProseMirror") || document.querySelector("div[contenteditable='true']") || document.querySelector("textarea"); }, }, /* ---------------- DeepSeek ---------------- */ "chat.deepseek.com": { hosts: ["chat.deepseek.com"], label: "DeepSeek", source: "deepseek", extract: function () { // 策略 1:官方 role 标记(部分版本有) var byRole = findByRoleAttr(); if (byRole.length) return byRole; // 策略 2:助手回答固定是 .ds-markdown;用户提问是它前面的兄弟块 var mds = Array.prototype.slice.call(document.querySelectorAll(".ds-markdown")); if (mds.length) { var out = []; mds.forEach(function (md) { var wrap = md; for (var i = 0; i < 4; i++) { var p = wrap.parentElement; if (!p || p === document.body) break; if (p.children.length > 1 || /message|chat|item/i.test(p.className || "")) break; wrap = p; } var prev = wrap.previousElementSibling; if (prev && !prev.querySelector(".ds-markdown")) { var ut = cleanText(prev.innerText); if (ut && ut.length < 5000 && !/^(深度思考|联网搜索|内容由 AI 生成)/.test(ut)) { out.push({ role: "user", content: ut }); } } else if (prev) { var ut2 = cleanText(prev.innerText); if (ut2) out.push({ role: "user", content: ut2 }); } out.push({ role: "assistant", content: cleanText(md.innerText) }); }); var res = uniqMessages(out); if (res.length) return res; } // 策略 3:通用兜底 return genericExtract(); }, getInputBox: function () { return document.querySelector("textarea#chat-input") || document.querySelector("textarea") || document.querySelector("div[contenteditable='true']"); }, }, /* ---------------- 豆包网页版 ---------------- */ "www.doubao.com": { hosts: ["www.doubao.com", "doubao.com"], label: "豆包网页版", source: "doubao-web", extract: function () { var byRole = findByRoleAttr(); if (byRole.length) return byRole; // 策略 0(桌面端实测有效,网页版同一套前端): // union_message 是消息外框,内部含 send_message 的是用户消息,否则是 AI 回复。 var unions = document.querySelectorAll("[data-testid='union_message']"); if (unions.length) { var u = []; Array.prototype.slice.call(unions).forEach(function (n) { var isUser = n.getAttribute("data-testid") === "send_message" || !!n.querySelector("[data-testid='send_message']"); var text = cleanText(n.innerText); if (text) u.push({ role: isUser ? "user" : "assistant", content: text }); }); var ur = uniqMessages(u); if (ur.length) return ur; } var out = []; // 豆包的正文容器(不同版本类名会变,这里做组合匹配) var nodes = Array.prototype.slice.call(document.querySelectorAll( "[data-testid='message_text_content'],[data-testid*='message_text'],[class*='message-content'],[class*='messageContent'],[class*='markdown']" )); var top = nodes.filter(function (n) { return !nodes.some(function (o) { return o !== n && o.contains(n); }); }); top.forEach(function (n) { var text = cleanText(n.innerText); if (!text) return; var role = "unknown"; var host = n; for (var i = 0; i < 6 && host; i++) { var tid = (host.getAttribute && host.getAttribute("data-testid") || "").toLowerCase(); var cls = (host.className && String(host.className) || "").toLowerCase(); var mark = tid + " " + cls; if (/receive|assistant|answer|bot/.test(mark)) { role = "assistant"; break; } if (/send|user|question/.test(mark)) { role = "user"; break; } host = host.parentElement; } if (role === "unknown") role = /markdown/.test(String(n.className || "")) ? "assistant" : "unknown"; out.push({ role: role, content: text }); }); var res = uniqMessages(out); return res.length ? res : genericExtract(); }, getInputBox: function () { return document.querySelector("textarea[data-testid='chat_input_input']") || document.querySelector("textarea") || document.querySelector("div[contenteditable='true']"); }, }, }; var SITE = null; var ADAPTER = null; (function pickSite() { var host = location.hostname; // 自测/调试用:可在页面里设 window.__AIHUB_SITE__ 强制指定适配器 try { if (window.__AIHUB_SITE__) host = String(window.__AIHUB_SITE__); } catch (e) {} Object.keys(ADAPTERS).forEach(function (k) { if (!ADAPTER) { var a = ADAPTERS[k]; if (a.hosts.indexOf(host) >= 0) { SITE = k; ADAPTER = a; } } }); })(); if (!ADAPTER) { warn("当前站点未适配:" + location.hostname + "(脚本继续运行,但不会采集)"); } /* ============================================================ * 3. 输入框注入 * ============================================================ */ function injectText(text) { if (!ADAPTER) return false; var box = null; try { box = ADAPTER.getInputBox(); } catch (e) { box = null; } if (!box) return false; try { box.focus(); if (box.tagName === "TEXTAREA" || box.tagName === "INPUT") { var setter = Object.getOwnPropertyDescriptor( box.tagName === "TEXTAREA" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, "value" ); if (setter && setter.set) setter.set.call(box, text); else box.value = text; box.dispatchEvent(new Event("input", { bubbles: true })); box.dispatchEvent(new Event("change", { bubbles: true })); return true; } // contenteditable(ChatGPT / ProseMirror 等) box.focus(); var sel = window.getSelection(); var range = document.createRange(); range.selectNodeContents(box); sel.removeAllRanges(); sel.addRange(range); var ok = false; try { ok = document.execCommand("insertText", false, text); } catch (e) { ok = false; } if (!ok) { box.textContent = text; box.dispatchEvent(new InputEvent("input", { bubbles: true, data: text, inputType: "insertText", })); } return true; } catch (e) { warn("注入失败:", e); return false; } } /* ============================================================ * 4. 采集引擎 * ============================================================ */ var autoCapture = !!getValue(AUTO_KEY, true); var lastKey = ""; var debounceTimer = null; var lastResult = { at: null, count: 0, added: 0, ok: null }; function snapshotKey(messages) { return messages.map(function (m) { return m.role + ":" + m.content.length + ":" + m.content.slice(0, 40); }).join("|"); } function captureOnce(force) { if (!ADAPTER) return Promise.resolve(0); var messages; try { messages = ADAPTER.extract(); } catch (e) { warn("采集异常:", e); return Promise.resolve(0); } if (!messages || !messages.length) return Promise.resolve(0); var key = snapshotKey(messages); if (!force && key === lastKey) return Promise.resolve(0); lastKey = key; return sendToServer({ source: ADAPTER.source, messages: messages }).then(function (r) { lastResult = { at: new Date(), count: messages.length, added: r.added, ok: r.ok }; return messages.length; }); } function scheduleCapture() { if (!autoCapture) return; clearTimeout(debounceTimer); debounceTimer = setTimeout(function () { captureOnce(false); }, 1800); } function startObserver() { if (!document.body) return; var observer = new MutationObserver(scheduleCapture); observer.observe(document.body, { childList: true, subtree: true, characterData: true }); } /* ============================================================ * 5. 页面浮窗 UI(Shadow DOM,避免被站点样式污染) * ============================================================ */ var ui = {}; var ROOT_ID = "aihub-root"; var CSS = [ ":host{all:initial}", "*{box-sizing:border-box;font-family:'Microsoft YaHei',system-ui,-apple-system,sans-serif}", "#fab{position:fixed;right:22px;bottom:110px;z-index:2147483000;width:46px;height:46px;border-radius:50%;", "border:none;cursor:grab;background:linear-gradient(135deg,#5b8cff,#3a6bff);color:#fff;font-size:16px;", "font-weight:700;box-shadow:0 6px 18px rgba(58,107,255,.45);display:flex;align-items:center;justify-content:center}", "#fab:active{cursor:grabbing}", "#fab .badge{position:absolute;top:-4px;right:-4px;min-width:16px;height:16px;border-radius:8px;", "background:#e5484d;color:#fff;font-size:10px;line-height:16px;text-align:center;padding:0 4px;display:none}", "#panel{position:fixed;right:22px;bottom:166px;z-index:2147483001;width:322px;max-height:70vh;", "background:#fff;color:#1f2329;border-radius:14px;box-shadow:0 10px 34px rgba(0,0,0,.2);", "display:none;flex-direction:column;overflow:hidden;border:1px solid #e5e6eb}", "#panel.open{display:flex}", ".head{padding:10px 12px;border-bottom:1px solid #f0f1f3;display:flex;justify-content:space-between;align-items:center}", ".head b{font-size:13px;color:#3a6bff}", ".status{font-size:11px;color:#8f959e;display:flex;align-items:center;gap:5px}", ".dot{width:7px;height:7px;border-radius:50%;background:#c9cdd4;display:inline-block}", ".dot.ok{background:#4caf7d}.dot.bad{background:#e05b5b}", ".body{padding:10px 12px 6px;display:flex;gap:6px}", "input.q{flex:1;min-width:0;padding:7px 9px;font-size:13px;border:1px solid #d9dbe0;border-radius:7px;outline:none;color:#1f2329;background:#fff}", "input.q:focus{border-color:#5b8cff}", "button{cursor:pointer;font-size:12px;border-radius:7px;border:none;padding:7px 10px;white-space:nowrap;", "background:#5b8cff;color:#fff}", "button.ghost{background:#f2f3f5;color:#4e5969}", "button:disabled{opacity:.5;cursor:default}", ".list{margin:4px 12px 10px;overflow-y:auto;max-height:34vh}", ".item{padding:8px 10px;border:1px solid #eef0f2;border-radius:9px;margin-bottom:6px;cursor:pointer;", "font-size:12px;line-height:1.5;word-break:break-word}", ".item:hover{border-color:#5b8cff;background:#f6f9ff}", ".item .tag{font-size:10px;color:#8f959e;margin-right:6px}", ".empty{color:#8f959e;text-align:center;padding:14px 0;font-size:12px}", ".foot{padding:8px 12px;border-top:1px solid #f0f1f3;font-size:11px;color:#8f959e;", "display:flex;justify-content:space-between;align-items:center;gap:6px;flex-wrap:wrap}", ".foot label{display:flex;align-items:center;gap:4px;cursor:pointer}", ".foot a{color:#3a6bff;text-decoration:none}", ".tools{display:flex;gap:6px;padding:0 12px 10px;flex-wrap:wrap}", ".tools button{font-size:11px;padding:5px 8px}", ".toast{margin:0 12px 10px;font-size:11px;color:#4e5969;background:#f7f8fa;border-radius:7px;padding:6px 9px;", "line-height:1.5;word-break:break-all;display:none}", ".toast.show{display:block}", ".syncbar{margin:0 12px 10px;padding:9px 10px;background:#f6f9ff;border:1px solid #dbe6ff;border-radius:9px}", ".syncstatus{font-size:11px;color:#4e5969;line-height:1.6;margin-bottom:7px}", ".syncrow{display:flex;gap:6px}", ".syncrow button{font-size:11px;padding:5px 10px}", ].join(""); function el(tag, cls, text) { var n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; } function buildUI() { if (document.getElementById(ROOT_ID)) return; var host = document.createElement("div"); host.id = ROOT_ID; host.style.cssText = "all:initial"; var shadow = host.attachShadow({ mode: "open" }); var style = document.createElement("style"); style.textContent = CSS; shadow.appendChild(style); var fab = el("button"); fab.id = "fab"; fab.title = "AI 管家 · 跨 AI 记忆"; fab.textContent = "AI"; var badge = el("span", "badge"); fab.appendChild(badge); var panel = el("div"); panel.id = "panel"; var head = el("div", "head"); var title = el("b", null, "AI 管家 · 记忆"); var status = el("span", "status"); var dot = el("span", "dot"); dot.id = "aihub-dot"; var statusText = el("span", null, "检测中…"); statusText.id = "aihub-status"; status.appendChild(dot); status.appendChild(statusText); head.appendChild(title); head.appendChild(status); var body = el("div", "body"); var qInput = el("input", "q"); qInput.id = "aihub-q"; qInput.placeholder = "搜记忆,点结果注入输入框"; var searchBtn = el("button", null, "搜索"); searchBtn.id = "aihub-search"; body.appendChild(qInput); body.appendChild(searchBtn); var list = el("div", "list"); list.id = "aihub-list"; var empty = el("div", "empty", "输入关键词后点「搜索」,或先随便聊聊让记忆积累起来"); list.appendChild(empty); var tools = el("div", "tools"); var capNow = el("button", "ghost", "立即采集本页"); capNow.id = "aihub-cap-now"; var capSel = el("button", "ghost", "采集选中文字"); capSel.id = "aihub-cap-sel"; var dashboard = el("button", "ghost", "打开管理面板"); dashboard.id = "aihub-dash"; var syncBtn = el("button", "ghost", "同步全部历史"); syncBtn.id = "aihub-sync"; tools.appendChild(capNow); tools.appendChild(capSel); tools.appendChild(syncBtn); tools.appendChild(dashboard); var syncBar = el("div", "syncbar"); syncBar.style.display = "none"; var syncStatusEl = el("div", "syncstatus", "把你在「" + (ADAPTER ? ADAPTER.label : "本页") + "」聊过的历史对话全部拉进记忆库。" + "同步过程中请不要关闭本标签页。"); var syncStart = el("button", null, "开始同步"); syncStart.id = "aihub-sync-start"; var syncStop = el("button", "ghost", "停止"); syncStop.id = "aihub-sync-stop"; syncStop.disabled = true; var syncRow = el("div", "syncrow"); syncRow.appendChild(syncStart); syncRow.appendChild(syncStop); syncBar.appendChild(syncStatusEl); syncBar.appendChild(syncRow); var toast = el("div", "toast"); toast.id = "aihub-toast"; var foot = el("div", "foot"); var autoLabel = el("label"); var autoBox = el("input"); autoBox.type = "checkbox"; autoBox.id = "aihub-auto"; autoLabel.appendChild(autoBox); autoLabel.appendChild(document.createTextNode(" 自动采集")); var siteSpan = el("span", null, ADAPTER ? ADAPTER.label : "未适配站点"); var installLink = el("a"); installLink.href = API_BASE + "/install"; installLink.target = "_blank"; installLink.textContent = "安装/更新脚本"; foot.appendChild(autoLabel); foot.appendChild(siteSpan); foot.appendChild(installLink); panel.appendChild(head); panel.appendChild(body); panel.appendChild(list); panel.appendChild(tools); panel.appendChild(syncBar); panel.appendChild(toast); panel.appendChild(foot); shadow.appendChild(fab); shadow.appendChild(panel); document.documentElement.appendChild(host); ui = { host: host, shadow: shadow, fab: fab, panel: panel, dot: dot, statusText: statusText, qInput: qInput, searchBtn: searchBtn, list: list, toast: toast, autoBox: autoBox, badge: badge, capNow: capNow, capSel: capSel, syncBar: syncBar, syncStatus: syncStatusEl, syncStart: syncStart, syncStop: syncStop, }; /* ---- 交互 ---- */ autoBox.checked = autoCapture; autoBox.addEventListener("change", function () { autoCapture = autoBox.checked; setValue(AUTO_KEY, autoCapture); showToast(autoCapture ? "自动采集已开启" : "自动采集已关闭"); }); fab.addEventListener("click", function () { if (fab.dataset.dragged === "1") return; // 刚刚是拖动,不当作点击 var open = panel.classList.toggle("open"); if (open) { refreshHealth(); qInput.focus(); } }); searchBtn.addEventListener("click", doSearch); qInput.addEventListener("keydown", function (e) { if (e.key === "Enter") doSearch(); }); capNow.addEventListener("click", function () { capNow.disabled = true; captureOnce(true).then(function (n) { capNow.disabled = false; if (!n) { showToast("本页没抓到消息。可选中文字后用「采集选中文字」,或到控制台看 [AI 管家] 日志。"); return; } showToast("已采集 " + n + " 条消息" + (lastResult.ok ? ",新增 " + lastResult.added + " 条" : ";本地服务未启动,已暂存待补发")); refreshHealth(); }); }); capSel.addEventListener("click", function () { var text = cleanText(window.getSelection && window.getSelection().toString()); if (!text) { showToast("先在页面上选中一段文字,再点这个按钮。"); return; } sendToServer({ source: (ADAPTER ? ADAPTER.source : "web") + "-selected", messages: [{ role: "note", content: text }] }) .then(function (r) { showToast(r.ok ? "已存入记忆库" : "本地服务未启动,已暂存待补发"); }); }); dashboard.addEventListener("click", function () { window.open(API_BASE + "/", "_blank"); }); syncBtn.addEventListener("click", function () { var showing = syncBar.style.display !== "none"; syncBar.style.display = showing ? "none" : "block"; panel.classList.add("open"); if (!showing && !ADAPTER) { syncStatusEl.textContent = "当前站点未适配,无法同步。支持的站点:ChatGPT / DeepSeek / 豆包网页版。"; } }); syncStart.addEventListener("click", function () { syncAllHistory(); }); syncStop.addEventListener("click", function () { SYNC.cancel = true; syncStatusEl.textContent = "正在停止…(等当前这个会话处理完)"; }); makeDraggable(fab); refreshHealth(); setInterval(refreshHealth, 15000); flushQueue(); setInterval(flushQueue, 30000); } function showToast(msg) { if (!ui.toast) return; ui.toast.textContent = msg; ui.toast.classList.add("show"); clearTimeout(showToast._t); showToast._t = setTimeout(function () { ui.toast.classList.remove("show"); }, 4200); } function refreshHealth() { health().then(function (r) { if (!ui.dot) return; var pending = readQueue().length; if (r.ok && r.data && r.data.ok) { ui.dot.className = "dot ok"; ui.statusText.textContent = "已连接 · v" + (r.data.version || "?") + (pending ? " · 待补发 " + pending : ""); ui.badge.style.display = "none"; } else { ui.dot.className = "dot bad"; ui.statusText.textContent = "服务未启动" + (pending ? " · 待补发 " + pending : ""); if (pending) { ui.badge.textContent = String(pending); ui.badge.style.display = "block"; } } }); } function doSearch() { var q = ui.qInput.value.trim(); if (!q) return; ui.list.innerHTML = ""; ui.list.appendChild(el("div", "empty", "搜索中…")); searchMemories(q).then(function (r) { var rows = (r.data && r.data.memories) || []; ui.list.innerHTML = ""; if (!rows.length) { ui.list.appendChild(el("div", "empty", r.ok ? "没有匹配的记忆" : "本地服务未启动,无法检索")); return; } rows.forEach(function (m) { var item = el("div", "item"); var tag = el("span", "tag", (m.source || "?") + "/" + (m.role || "?")); item.appendChild(tag); var text = String(m.content || ""); item.appendChild(document.createTextNode(text.length > 110 ? text.slice(0, 110) + "…" : text)); item.title = "点击注入到当前输入框\n\n" + text; item.addEventListener("click", function () { if (injectText(text)) { ui.panel.classList.remove("open"); showToast("已注入到输入框,确认后按回车发送"); } else { showToast("没找到输入框,请手动复制:" + text.slice(0, 40) + "…"); } }); ui.list.appendChild(item); }); }); } function makeDraggable(node) { var dragging = false, moved = false, sx = 0, sy = 0, ox = 0, oy = 0; node.addEventListener("mousedown", function (e) { dragging = true; moved = false; var rect = node.getBoundingClientRect(); sx = e.clientX; sy = e.clientY; ox = rect.left; oy = rect.top; node.style.right = "auto"; node.style.bottom = "auto"; node.style.left = ox + "px"; node.style.top = oy + "px"; e.preventDefault(); }); document.addEventListener("mousemove", function (e) { if (!dragging) return; moved = true; node.style.left = (ox + e.clientX - sx) + "px"; node.style.top = (oy + e.clientY - sy) + "px"; }); document.addEventListener("mouseup", function () { if (!dragging) return; dragging = false; if (moved) { node.dataset.dragged = "1"; setTimeout(function () { node.dataset.dragged = ""; }, 300); } }); node.addEventListener("click", function (e) { if (node.dataset.dragged === "1") e.stopImmediatePropagation(); }, true); } /* ============================================================ * 6. 历史全量同步(把「之前所有的对话」拉进记忆库) * ============================================================ */ function wait(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } /* 站内请求:同源,带 cookie,所以能拿到你自己账号的历史 */ function siteFetch(path, opts) { opts = opts || {}; var init = { credentials: "include", headers: opts.headers || {} }; if (opts.method) init.method = opts.method; if (opts.body) { init.method = init.method || "POST"; init.headers = Object.assign({ "Content-Type": "application/json" }, init.headers); init.body = JSON.stringify(opts.body); } return fetch(path, init).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status + " " + path); return r.json(); }); } /* ---- ChatGPT:官方接口(快),失败退回页面模拟 ---- */ var CHATGPT = { async list() { var token = null; try { var s = await siteFetch("/api/auth/session"); token = s && s.accessToken; } catch (e) {} var headers = token ? { Authorization: "Bearer " + token } : {}; var out = []; for (var offset = 0; offset < 5000; offset += 28) { var j = await siteFetch("/backend-api/conversations?offset=" + offset + "&limit=28&order=updated", { headers: headers }); var items = (j && j.items) || []; for (var i = 0; i < items.length; i++) { var it = items[i]; out.push({ id: it.id, title: it.title, updated_at: it.update_time ? new Date(it.update_time * 1000).toISOString() : null, }); } if (items.length < 28) break; } return out; }, async fetch(id) { var token = null; try { var s = await siteFetch("/api/auth/session"); token = s && s.accessToken; } catch (e) {} var headers = token ? { Authorization: "Bearer " + token } : {}; var j = await siteFetch("/backend-api/conversation/" + id, { headers: headers }); return chatgptLinear(j); }, }; /* ChatGPT 的 mapping 是棵树,按 current_node 回溯才是真实对话顺序 */ function chatgptLinear(conv) { var mapping = (conv && conv.mapping) || {}; var nodeId = conv && conv.current_node; if (!mapping[nodeId]) { nodeId = null; var best = -1; Object.keys(mapping).forEach(function (nid) { var depth = 0, cur = mapping[nid]; while (cur && cur.parent && mapping[cur.parent] && depth < 500) { cur = mapping[cur.parent]; depth++; } if (depth > best) { best = depth; nodeId = nid; } }); } var chain = [], seen = {}; while (nodeId && mapping[nodeId] && !seen[nodeId]) { seen[nodeId] = 1; chain.push(mapping[nodeId]); nodeId = mapping[nodeId].parent; } chain.reverse(); var out = []; chain.forEach(function (node) { var msg = node && node.message; if (!msg) return; var role = (msg.author && msg.author.role) || "unknown"; if (role === "system" || role === "tool") return; var text = chatgptText(msg.content); if (text) out.push({ role: role, content: text }); }); return out; } function chatgptText(content) { if (!content || typeof content !== "object") return ""; var parts = content.parts; if (Array.isArray(parts)) { var buf = []; parts.forEach(function (p) { if (typeof p === "string") buf.push(p); else if (p && typeof p === "object" && p.text) buf.push(String(p.text)); }); var t = cleanText(buf.join("\n")); if (t) return t; } if (typeof content.text === "string") return cleanText(content.text); return ""; } /* ---- 页面模拟式同步(不依赖任何私有接口,兜底但通用) ---- 每个站点给一组候选选择器(按优先级,命中即停)。 注意:新版豆包(桌面端 & 网页版同一套前端)的会话项是 div[data-conversation-id],不再是 a[href="/chat/"] —— href 版只匹配到「定时任务/云盘/新对话」这些导航链接。 所以属性直取优先,href 解析兜底。 */ var DOM_HISTORY = { "chatgpt.com": { urlPattern: /^\/c\/(.+)$/, links: ['a[href^="/c/"]'], }, "chat.deepseek.com": { urlPattern: /\/a\/chat\/s\/([^/?#]+)/, links: ['a[href*="/a/chat/s/"]'], }, "www.doubao.com": { urlPattern: /\/chat\/([0-9a-zA-Z_-]+)/, links: [ '[data-testid="conversation-list-v2-item"][data-conversation-id]', '[data-conversation-id]', 'a[href*="/chat/"]', ], attr: "data-conversation-id", msgSel: '[data-testid="union_message"]', msgUserSel: '[data-testid="send_message"]', virtual: true, // 消息列表虚拟滚动,直接 extract 会被截断 }, }; /* 按候选选择器收集节点(数组优先,字符串兼容旧写法) */ function domQueryAll(cfg) { var sels = Array.isArray(cfg.links) ? cfg.links : [cfg.links]; for (var i = 0; i < sels.length; i++) { var nodes = document.querySelectorAll(sels[i]); if (nodes.length) return nodes; } return []; } /* 从会话项里抠标题:优先 data-testid/class 含 title 的子节点,再退回整块文本首行 */ function domTitleOf(node) { var t = node.querySelector && node.querySelector( '[data-testid*="title"],[data-testid*="name"],[class*="title"],[class*="name"]'); var s = cleanText((t && t.innerText) || node.innerText || node.textContent || ""); if (!s) return ""; return s.split("\n")[0].replace(/\s+/g, " ").trim(); } function domIdOf(node, cfg) { if (cfg.attr) { var v = node.getAttribute && node.getAttribute(cfg.attr); if (v) return v; } var href = (node.getAttribute && node.getAttribute("href")) || ""; var m = href.match(cfg.urlPattern); return m ? m[1] : null; } function domCollectLinks() { var cfg = DOM_HISTORY[SITE]; if (!cfg) return []; var out = [], seen = {}; var nodes = domQueryAll(cfg); for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; var id = domIdOf(node, cfg); if (!id || seen[id]) continue; var title = domTitleOf(node); if (!title) continue; seen[id] = 1; out.push({ id: id, title: title.slice(0, 120), el: node, url: node.href || ("/chat/" + id), }); } return out; } function domScrollSidebar() { var cfg = DOM_HISTORY[SITE]; if (!cfg) return; var nodes = domQueryAll(cfg); if (!nodes.length) return; var node = nodes[nodes.length - 1]; // 找到最近的可滚动祖先 while (node && node !== document.body) { var st = window.getComputedStyle(node); if ((st.overflowY === "auto" || st.overflowY === "scroll") && node.scrollHeight > node.clientHeight + 10) { node.scrollTop = node.scrollHeight; return; } node = node.parentElement; } window.scrollTo(0, document.body.scrollHeight); } async function domListAll(progress) { var items = [], seen = {}; var stable = 0; for (var round = 0; round < 60; round++) { var before = items.length; domCollectLinks().forEach(function (it) { if (!seen[it.id]) { seen[it.id] = 1; items.push(it); } }); if (progress) progress("正在加载会话列表… 已发现 " + items.length + " 个"); domScrollSidebar(); await wait(800); stable = (items.length === before) ? stable + 1 : 0; if (stable >= 3 && round >= 2) break; } return items; } /* 找到消息区所在的可滚动容器 */ function domMessageScroller(sel) { var nodes = document.querySelectorAll(sel); if (!nodes.length) return null; var node = nodes[0]; while (node && node !== document.body) { var st = window.getComputedStyle(node); if ((st.overflowY === "auto" || st.overflowY === "scroll") && node.scrollHeight > node.clientHeight + 10) return node; node = node.parentElement; } return null; } /* 豆包的消息列表是虚拟滚动:先等消息区渲染,再反复滚到顶把历史消息全逼出来。 不做这一步,长会话只能抓到当前视口里的几条。 返回 true 表示消息区确实出现过(本次已把能逼的都逼出来了)。 */ async function domLoadAllMessages(cfg) { if (!cfg || !cfg.virtual || !cfg.msgSel) return false; var appeared = false; for (var w = 0; w < 15; w++) { // 最多等 ~6s if (document.querySelectorAll(cfg.msgSel).length) { appeared = true; break; } await wait(400); } if (!appeared) return false; // 选择器没命中,交给通用 extract var last = -1, stable = 0; for (var j = 0; j < 80; j++) { var sc = domMessageScroller(cfg.msgSel); if (!sc) break; sc.scrollTop = 0; await wait(400); var n = document.querySelectorAll(cfg.msgSel).length; if (n === last) stable++; else stable = 0; last = n; if (stable >= 4) break; } var sc2 = domMessageScroller(cfg.msgSel); if (sc2) sc2.scrollTop = sc2.scrollHeight; await wait(250); return true; } /* 打开某个会话,等到消息区刷新出内容 */ async function domOpenAndExtract(item, prevKey) { var cfg = DOM_HISTORY[SITE] || {}; try { item.el.click(); } catch (e) { location.href = item.url; return null; // 触发整页跳转,本轮到此为止 } // SPA 路由切换:等 URL 里出现目标 id,最多 8s for (var k = 0; k < 16; k++) { if (location.href.indexOf(item.id) >= 0) break; await wait(500); } var loaded = false; for (var i = 0; i < 24; i++) { await wait(500); // 虚拟滚动只需要成功逼一次;失败则下一轮继续试 if (cfg.virtual && !loaded) { loaded = await domLoadAllMessages(cfg); } var msgs; try { msgs = ADAPTER.extract(); } catch (e) { msgs = null; } if (msgs && msgs.length) { var key = snapshotKey(msgs); if (key !== prevKey) return msgs; } } return null; } async function importConversation(convId, title, messages) { var r = await request({ method: "POST", path: "/api/import/conversation", body: { source: ADAPTER.source, conv_id: String(convId), title: title, messages: messages }, timeout: 30000, }); return (r.ok && r.data && r.data.ok) ? r.data : null; } /* 同步前/同步失败时的自检:报告选择器命中情况,便于快速定位是「没登录」 还是「页面改版导致选择器失效」。 */ function domDiagnose() { var cfg = DOM_HISTORY[SITE]; if (!cfg) return "当前站点未适配(" + SITE + ")。"; var sels = Array.isArray(cfg.links) ? cfg.links : [cfg.links]; var parts = []; for (var i = 0; i < sels.length; i++) { var n = 0; try { n = document.querySelectorAll(sels[i]).length; } catch (e) {} parts.push(sels[i] + "=" + n); } var msgN = 0; if (cfg.msgSel) { try { msgN = document.querySelectorAll(cfg.msgSel).length; } catch (e) {} } return "诊断[站点 " + SITE + "|会话候选 " + parts.join(" ") + "|消息节点 " + msgN + "] 请确认已登录,且左侧历史列表已展开。"; } var SYNC = { running: false, cancel: false }; function syncStatus(text) { if (ui.syncStatus) ui.syncStatus.textContent = text; } async function syncAllHistory() { if (SYNC.running || !ADAPTER) return; SYNC.running = true; SYNC.cancel = false; if (ui.syncStart) ui.syncStart.disabled = true; if (ui.syncStop) ui.syncStop.disabled = false; var stat = { total: 0, done: 0, added: 0, adopted: 0, failed: 0 }; try { syncStatus("正在读取会话列表…"); log("同步开始:" + domDiagnose()); var list = null; // 1) 先试站内接口 if (SITE === "chatgpt.com" && CHATGPT) { try { list = await CHATGPT.list(); log("接口列出会话 " + (list ? list.length : 0) + " 个"); } catch (e) { warn("官方接口不可用,改用页面模拟:" + e.message); list = null; } } // 2) 退回页面模拟(自动滚动侧边栏收集全部会话) if (!list || !list.length) { list = await domListAll(syncStatus); } if (!list.length) { var diag = domDiagnose(); syncStatus("没有找到历史会话。" + diag); log("同步中止:" + diag); SYNC.running = false; if (ui.syncStart) ui.syncStart.disabled = false; return; } stat.total = list.length; log("开始同步 " + list.length + " 个历史会话"); var prevKey = ""; for (var i = 0; i < list.length; i++) { if (SYNC.cancel) { syncStatus("已停止(完成 " + stat.done + "/" + stat.total + ")"); break; } var item = list[i]; syncStatus("同步中 " + (i + 1) + "/" + stat.total + ":" + (item.title || item.id).slice(0, 26)); var msgs = null; if (SITE === "chatgpt.com" && item.id && !item.el) { try { msgs = await CHATGPT.fetch(item.id); } catch (e) { msgs = null; } } if ((!msgs || !msgs.length) && item.el) { msgs = await domOpenAndExtract(item, prevKey); } if (msgs && msgs.length) { prevKey = snapshotKey(msgs); var res = await importConversation(item.id, item.title, msgs); if (res) { stat.added += res.added || 0; stat.adopted += res.adopted || 0; stat.done++; } else { stat.failed++; } } else { stat.failed++; } if (i % 5 === 4) { flushQueue(); } } syncStatus(SYNC.cancel ? "已停止:入库 " + stat.done + " 个会话,新增 " + stat.added + " 条记忆" : "完成:共 " + stat.total + " 个会话,入库 " + stat.done + " 个,新增 " + stat.added + " 条记忆" + (stat.adopted ? ",认领 " + stat.adopted + " 条" : "") + (stat.failed ? ",失败 " + stat.failed + " 个" : "")); log("历史同步结束", stat); refreshHealth(); } catch (e) { syncStatus("同步出错:" + (e && e.message ? e.message : e)); warn(e); } SYNC.running = false; if (ui.syncStart) ui.syncStart.disabled = false; if (ui.syncStop) ui.syncStop.disabled = true; } /* ============================================================ * 7. 启动 * ============================================================ */ function boot() { buildUI(); startObserver(); setTimeout(function () { captureOnce(false); }, 3500); // 单页应用切会话时,body 可能被重建,兜底重建 UI setInterval(function () { if (!document.getElementById(ROOT_ID)) buildUI(); }, 8000); log("已加载 v0.3.0 · 站点:" + (ADAPTER ? ADAPTER.label : location.hostname)); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot); } else { boot(); } if (typeof GM_registerMenuCommand === "function") { try { GM_registerMenuCommand("AI 管家:立即采集本页", function () { captureOnce(true); }); GM_registerMenuCommand("AI 管家:打开管理面板", function () { window.open(API_BASE + "/", "_blank"); }); } catch (e) {} } })();