// ==UserScript==
// @name 闲鱼 AI 智能客服
// @namespace https://goofish.com/
// @version 1.0.0
// @description 基于 OpenAI API 的智能自动回复,检测聊天列表未读角标触发,支持自定义 prompt、查看聊天记录、暂停功能
// @author You
// @match https://www.goofish.com/im*
// @run-at document-idle
// @grant GM_xmlhttpRequest
// @connect *
// ==/UserScript==
(function () {
"use strict";
/* ═══════════════════════════════════════════
配置 Key(LocalStorage)
═══════════════════════════════════════════ */
const KEY_ENABLED = "xianyu_ai_enabled";
const KEY_API_KEY = "xianyu_ai_api_key";
const KEY_API_BASE = "xianyu_ai_api_base";
const KEY_MODEL = "xianyu_ai_model";
const KEY_PROMPT = "xianyu_ai_sys_prompt";
const KEY_REPLY_SUFFIX = "xianyu_ai_reply_suffix";
const KEY_REPLIED = "xianyu_ai_replied";
/* ═══════════════════════════════════════════
默认值
═══════════════════════════════════════════ */
const DEFAULT_BASE = "https://api.openai.com/v1";
const DEFAULT_MODEL = "gpt-4o-mini";
const DEFAULT_PROMPT = `你是一位专业的闲鱼卖家客服助手。请根据买家的问题给出简洁、友好、专业的回复。
注意:
- 回复要简短自然,不超过 100 字
- 用中文回复
- 不要每次都重复相同的问候语
- 如果买家询问发货/退款/价格,请给出合理解答`;
const DEFAULT_REPLY_SUFFIX = "(由机器人回复)";
const SCAN_INTERVAL = 15_000; // 两次扫描间隔(毫秒)
/* ═══════════════════════════════════════════
工具函数
═══════════════════════════════════════════ */
const store = {
get: (key) => localStorage.getItem(key),
set: (key, val) => localStorage.setItem(key, String(val)),
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function getConversationNameFromItem(item) {
if (!item) return "";
const nameEl = [...item.querySelectorAll("div, span")].find(
(node) =>
node.style.overflow === "hidden" &&
node.style.textOverflow === "ellipsis" &&
node.style.whiteSpace === "nowrap",
);
return nameEl?.textContent?.trim() || "";
}
function findVisibleConversationItem(username) {
const items = document.querySelectorAll('[class*="conversation-item--"]');
for (const item of items) {
if (getConversationNameFromItem(item) === username) {
return item;
}
}
return null;
}
function getConversationScrollContainer() {
const preferred = [
"#conv-list-scrollable .rc-virtual-list-holder",
"#conv-list-scrollable",
".rc-virtual-list-holder",
".rc-virtual-list",
];
for (const selector of preferred) {
const node = document.querySelector(selector);
if (node && node.scrollHeight > node.clientHeight + 20) {
return node;
}
}
const seed =
document.querySelector('[class*="conversation-item-active--"]') ||
document.querySelector('[class*="conversation-item--"]');
let current = seed?.parentElement || null;
while (current && current !== document.body) {
const style = window.getComputedStyle(current);
const overflowY = style.overflowY;
const isVirtualHolder =
current.classList?.contains("rc-virtual-list-holder") ||
current.closest("#conv-list-scrollable") !== null;
if (
current.scrollHeight > current.clientHeight + 20 &&
(
overflowY === "auto" ||
overflowY === "scroll" ||
overflowY === "overlay" ||
isVirtualHolder
)
) {
return current;
}
current = current.parentElement;
}
return null;
}
/** 等待指定选择器出现,最多等 timeout ms */
function waitFor(selector, root = document, timeout = 8000) {
return new Promise((resolve, reject) => {
const el = root.querySelector(selector);
if (el) return resolve(el);
const obs = new MutationObserver(() => {
const found = root.querySelector(selector);
if (found) {
obs.disconnect();
resolve(found);
}
});
obs.observe(root.nodeType === Node.DOCUMENT_NODE ? root.body : root, {
childList: true,
subtree: true,
});
setTimeout(() => {
obs.disconnect();
reject(new Error(`waitFor 超时: ${selector}`));
}, timeout);
});
}
/** 简单 HTML 转义,防止 XSS */
function esc(str) {
return String(str ?? "")
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(//g, ">");
}
/* ═══════════════════════════════════════════
已回复记录(持久化)
═══════════════════════════════════════════ */
function loadReplied() {
try {
return JSON.parse(localStorage.getItem(KEY_REPLIED) || "[]");
} catch {
return [];
}
}
function saveReplied(list) {
localStorage.setItem(KEY_REPLIED, JSON.stringify(list));
}
/** 新增或更新一条回复记录(同一用户去重,最新时间置顶,累计未读条数) */
function addReplied(username) {
const list = loadReplied();
const idx = list.findIndex((r) => r.username === username);
const time = new Date(Date.now() + 8 * 3600_000)
.toISOString()
.replace("T", " ")
.slice(0, 19);
if (idx !== -1) {
const existing = list.splice(idx, 1)[0];
existing.time = time;
existing.unread = (existing.unread || 0) + 1;
list.unshift(existing);
} else {
list.unshift({ username, time, unread: 1 });
}
if (list.length > 200) list.length = 200;
saveReplied(list);
updateRepliedUI();
}
function markRepliedRead(username) {
const list = loadReplied().map((r) =>
r.username === username ? { ...r, unread: 0 } : r,
);
saveReplied(list);
updateRepliedUI();
}
/** 在聊天列表中找到对应用户名的会话并点击(兼容虚拟列表) */
async function navigateToConversation(username) {
if (!username) return;
const clickItem = (item) => {
item.scrollIntoView({ block: "center" });
item.click();
};
const visibleItem = findVisibleConversationItem(username);
if (visibleItem) {
clickItem(visibleItem);
log(`🔎 已定位会话:${username}`);
return;
}
const scroller = getConversationScrollContainer();
if (!scroller) {
log(`⚠️ 未找到聊天列表滚动容器:${username}`);
return;
}
log(`🔎 开始查找会话:${username}`);
setStatus(`正在查找会话:${username}`);
const initialTop = scroller.scrollTop;
const step = Math.max(280, Math.floor(scroller.clientHeight * 0.85));
let attempts = 0;
let stableRounds = 0;
let lastTop = -1;
scroller.scrollTop = 0;
await sleep(450);
while (attempts < 80) {
const item = findVisibleConversationItem(username);
if (item) {
clickItem(item);
log(`✅ 已跳转到会话:${username}`);
setStatus(`已定位会话:${username}`);
return;
}
const maxTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
const currentTop = scroller.scrollTop;
if (currentTop >= maxTop - 4) {
break;
}
const nextTop = Math.min(maxTop, currentTop + step);
if (Math.abs(nextTop - lastTop) < 4) {
stableRounds += 1;
} else {
stableRounds = 0;
}
if (stableRounds >= 3) {
break;
}
lastTop = nextTop;
scroller.scrollTo({ top: nextTop, behavior: "auto" });
attempts += 1;
await sleep(320);
}
scroller.scrollTo({ top: initialTop, behavior: "auto" });
log(`⚠️ 未找到会话:${username}`);
setStatus(`未找到会话:${username}`);
}
function updateRepliedUI() {
const el = document.getElementById("xy-replied-list");
const countEl = document.getElementById("xy-replied-count");
const unreadEl = document.getElementById("xy-replied-unread");
if (!el) return;
const list = loadReplied();
// 兼容旧 read:boolean 格式,统一到 unread 数字
const getUnread = (r) =>
r.unread !== undefined ? r.unread : r.read === false ? 1 : 0;
const totalMsgs = list.reduce((s, r) => s + getUnread(r), 0);
const unreadPeople = list.filter((r) => getUnread(r) > 0).length;
if (countEl) countEl.textContent = list.length;
if (unreadEl) {
unreadEl.textContent =
unreadPeople > 0
? `人工未读 ${unreadPeople} 人 · 共 ${totalMsgs} 条`
: "人工未读 0 条";
unreadEl.style.color = unreadPeople > 0 ? "#ff4d4f" : "#bbb";
}
if (!list.length) {
el.innerHTML =
'
暂无回复记录
有自动回复后会显示在这里
';
return;
}
el.innerHTML = list
.map((r) => {
const n = getUnread(r);
const isUnread = n > 0;
const timeText = r.time ? r.time.slice(5, 16) : "--";
return `
${isUnread ? `人工未读 ${n}` : "已读"}
最近回复 ${esc(timeText)}
${
isUnread
? ``
: ""
}
`;
})
.join("");
// 跳转事件
el.querySelectorAll(".xy-goto-user").forEach((button) => {
button.addEventListener("click", async () => {
await navigateToConversation(button.dataset.username);
});
});
// 标记已读事件
el.querySelectorAll(".xy-mark-read").forEach((btn) => {
btn.addEventListener("click", () =>
markRepliedRead(btn.dataset.username),
);
});
}
/* ═══════════════════════════════════════════
1. 扫描未读会话
依据:列表项内存在 sup.ant-badge-count[data-show="true"]
═══════════════════════════════════════════ */
function findUnreadConversations() {
const results = [];
const items = document.querySelectorAll('[class*="conversation-item--"]');
for (const item of items) {
// 角标存在且 data-show="true" 表示有未读消息
const badge = item.querySelector('sup.ant-badge-count[data-show="true"]');
if (!badge) continue;
// 取用户名(依赖 overflow:hidden + text-overflow:ellipsis + white-space:nowrap 样式)
const username = getConversationNameFromItem(item) || "未知用户";
results.push({ item, username });
}
return results;
}
/* ═══════════════════════════════════════════
2. 提取当前打开对话的聊天记录
direction: ltr → 买家(user)
direction: rtl → 我(assistant)
═══════════════════════════════════════════ */
function extractChatHistory() {
const messages = [];
const listItems = document.querySelectorAll("li.ant-list-item");
for (const li of listItems) {
const style = li.getAttribute("style") || "";
const isMe = style.includes("direction: rtl");
const isBuyer = style.includes("direction: ltr");
if (!isMe && !isBuyer) continue; // 系统消息/卡片,跳过
const textEl = li.querySelector('[class*="message-text--"]');
if (!textEl) continue;
const text = textEl.textContent?.trim();
if (!text) continue;
messages.push({ role: isMe ? "assistant" : "user", content: text });
}
// 最多保留最近 20 条,避免超出 token 限制
return messages.slice(-20);
}
/* ═══════════════════════════════════════════
3. 调用 OpenAI Chat Completions API
═══════════════════════════════════════════ */
function callOpenAI(history) {
const apiKey = store.get(KEY_API_KEY) || "";
if (!apiKey)
return Promise.reject(new Error("API Key 未设置,请在面板中填写"));
const baseUrl = (store.get(KEY_API_BASE) || DEFAULT_BASE).replace(
/\/$/,
"",
);
const model = store.get(KEY_MODEL) || DEFAULT_MODEL;
const now = new Date(Date.now() + 8 * 3600_000);
const nowStr =
now.toISOString().replace("T", " ").slice(0, 19) + " (UTC+8)";
const sysPrompt =
(store.get(KEY_PROMPT) || DEFAULT_PROMPT) + `\n\n当前时间:${nowStr}`;
const payload = {
model,
messages: [{ role: "system", content: sysPrompt }, ...history],
max_tokens: 400,
temperature: 0.7,
};
const url = `${baseUrl}/chat/completions`;
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
};
// 优先用 GM_xmlhttpRequest(可绕过 CORS 和内容安全策略)
if (typeof GM_xmlhttpRequest === "function") {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url,
headers,
data: JSON.stringify(payload),
timeout: 30_000,
onload(res) {
try {
const data = JSON.parse(res.responseText);
if (data.error) return reject(new Error(data.error.message));
const content = data.choices?.[0]?.message?.content?.trim();
if (!content) return reject(new Error("API 返回空内容"));
resolve(content);
} catch (e) {
reject(
new Error("解析响应失败: " + res.responseText.slice(0, 200)),
);
}
},
onerror: () => reject(new Error("网络请求失败")),
ontimeout: () => reject(new Error("请求超时(30s)")),
});
});
}
// 降级到 fetch(可能受 CORS 限制)
return fetch(url, {
method: "POST",
headers,
body: JSON.stringify(payload),
})
.then((r) => r.json())
.then((data) => {
if (data.error) throw new Error(data.error.message);
const content = data.choices?.[0]?.message?.content?.trim();
if (!content) throw new Error("API 返回空内容");
return content;
});
}
/* ═══════════════════════════════════════════
4. 向当前对话发送消息
targetUsername: 发送前校验当前激活会话是否仍是目标用户,
防止会话切换导致消息发错房间。
═══════════════════════════════════════════ */
async function sendMessage(text, targetUsername) {
text = text.trim();
if (!text) return;
const replySuffix =
(store.get(KEY_REPLY_SUFFIX) ?? DEFAULT_REPLY_SUFFIX).trim();
if (replySuffix) {
text += `\n\n${replySuffix}`;
}
// 校验当前激活会话仍是目标用户(允许为空则跳过校验)
if (targetUsername) {
const activeNow = getActiveUsername();
if (activeNow && activeNow !== targetUsername) {
throw new Error(
`会话已切换(当前: ${activeNow},目标: ${targetUsername}),放弃发送`,
);
}
}
const textarea = await waitFor("textarea.ant-input", document, 6000);
textarea.focus();
// 通过原生 setter 触发 React 受控组件
const nativeSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
if (nativeSetter) nativeSetter.call(textarea, text);
else textarea.value = text;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
await sleep(500);
// 发送前再次校验(AI 调用期间可能已切换会话)
if (targetUsername) {
const activeNow = getActiveUsername();
if (activeNow && activeNow !== targetUsername) {
// 清空输入框,防止残留内容
const setter2 = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
if (setter2) setter2.call(textarea, "");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
throw new Error(
`发送前会话已切换(当前: ${activeNow},目标: ${targetUsername}),已取消`,
);
}
}
// 等待发送按钮可用(最多 4.5 秒)
let sendBtn = null;
for (let i = 0; i < 15; i++) {
sendBtn = document.querySelector(
'[class*="sendbox-bottom"] button:not([disabled])',
);
if (sendBtn) break;
await sleep(300);
}
if (sendBtn) {
sendBtn.click();
} else {
// 备用:回车发送
textarea.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
keyCode: 13,
bubbles: true,
}),
);
}
await sleep(1200);
}
/* ═══════════════════════════════════════════
5a. 当前激活会话监听(无角标时兜底)
当某个会话已被选中时,新消息不会出现角标,
改用对话内容指纹(最后一条买家消息)跟踪变化。
═══════════════════════════════════════════ */
// 记录上次已处理的"最后一条买家消息"指纹(内容 + 消息总条数)
let lastActiveKey = "";
// 防止对同一条消息重复触发 AI 调用
let activeReplying = false;
/**
* 全局串行发送锁:确保同一时刻只有一个"AI 调用 + 发送"流程在运行,
* 彻底杜绝两条路径并发写输入框导致消息串联的问题。
*/
let replyQueue = Promise.resolve();
function enqueueReply(fn) {
replyQueue = replyQueue.then(() => fn().catch(() => {}));
return replyQueue;
}
/** Observer 触发的 debounce 定时器 */
let activeCheckTimer = null;
function scheduleActiveCheck() {
clearTimeout(activeCheckTimer);
activeCheckTimer = setTimeout(() => {
enqueueReply(() => checkActiveConversation());
}, 800);
}
/** 获取当前激活会话的用户名(带 conversation-item-active 类的列表项) */
function getActiveUsername() {
const activeItem = document.querySelector(
'[class*="conversation-item-active--"]',
);
if (!activeItem) return null;
return getConversationNameFromItem(activeItem) || "当前会话";
}
/**
* 检查当前打开的会话是否有买家新消息未回复。
* 若检测到新消息指纹与上次不同,且最后一条是买家消息,则触发 AI 回复。
*/
async function checkActiveConversation() {
if (activeReplying) return;
if (store.get(KEY_ENABLED) === "false") return;
if (!store.get(KEY_API_KEY)) return;
const history = extractChatHistory();
if (!history.length) return;
const last = history[history.length - 1];
if (last.role !== "user") return; // 最后是我发的,无需处理
// 指纹:最后一条买家消息内容 + 消息总数,任一变化即视为新消息
const key = `${history.length}::${last.content}`;
if (key === lastActiveKey) return; // 已处理过该消息
lastActiveKey = key;
activeReplying = true;
const username = getActiveUsername() || "当前会话";
log(`📩 当前会话收到新消息(${username}),调用 AI…`);
setStatus("AI 生成中…");
try {
const reply = await callOpenAI(history);
log(`💬 ${reply.length > 35 ? reply.slice(0, 35) + "…" : reply}`);
await sendMessage(reply, username);
log(`✅ 已回复当前会话:${username}`);
addReplied(username);
} catch (e) {
log(`❌ 当前会话回复失败:${e.message}`);
// 失败时重置指纹,下次扫描可重试
lastActiveKey = "";
} finally {
activeReplying = false;
setStatus(`上次扫描:${new Date().toLocaleTimeString()}`);
}
}
/** 启动对当前聊天消息列表的 MutationObserver,实时感知新消息 */
function watchActiveChatMessages() {
let currentListEl = null;
let listObserver = null;
function attachToList(listEl) {
if (listEl === currentListEl) return;
listObserver?.disconnect();
currentListEl = listEl;
listObserver = new MutationObserver(() => {
// 新 li 节点加入时触发检查,debounce 800ms 等 React 渲染稳定
scheduleActiveCheck();
});
listObserver.observe(listEl, { childList: true, subtree: true });
}
// 外层观察器:等待 .ant-list-items 容器出现(会话切换时容器会重建)
new MutationObserver(() => {
const listEl = document.querySelector("ul.ant-list-items");
if (listEl) attachToList(listEl);
}).observe(document.body, { childList: true, subtree: true });
// 立即尝试挂载(页面已加载的情况)
const listEl = document.querySelector("ul.ant-list-items");
if (listEl) attachToList(listEl);
}
/* ═══════════════════════════════════════════
5. 主扫描循环
═══════════════════════════════════════════ */
let scanning = false;
async function runScan() {
if (scanning) return;
if (store.get(KEY_ENABLED) === "false") return;
if (!store.get(KEY_API_KEY)) {
setStatus("⚠️ 请先在 API 面板填写 API Key");
return;
}
scanning = true;
setStatus("检测中…");
try {
// 先处理当前激活会话(无角标场景兜底),纳入串行队列
await new Promise((resolve) =>
enqueueReply(async () => {
await checkActiveConversation();
resolve();
}),
);
const unread = findUnreadConversations();
if (!unread.length) {
setStatus(`无未读 · ${new Date().toLocaleTimeString()}`);
return;
}
log(`发现 ${unread.length} 个未读会话`);
for (const { item, username } of unread) {
log(`打开会话:${username}`);
// 切换会话前重置激活会话指纹,避免残留状态误判
lastActiveKey = "";
item.click();
// 等待消息列表渲染
try {
await waitFor('[class*="message-text--"]', document, 5000);
} catch {
log(`⚠️ 消息加载超时,跳过:${username}`);
continue;
}
await sleep(800); // 等剩余消息渲染
// 提取聊天记录
const history = extractChatHistory();
if (!history.length) {
log(`⚠️ 无法获取聊天记录:${username}`);
continue;
}
// 最后一条必须是买家消息,否则已回复过,跳过
const lastMsg = history[history.length - 1];
if (lastMsg?.role !== "user") {
log(`最新消息是我发的,已跳过:${username}`);
continue;
}
// 立即锁定指纹 + 标记正在回复,防止 Observer 路径在 OpenAI 调用期间并发触发
lastActiveKey = `${history.length}::${lastMsg.content}`;
activeReplying = true;
log(`🤖 调用 AI 生成回复…`);
setStatus("AI 生成中…");
let reply;
try {
reply = await callOpenAI(history);
} catch (e) {
activeReplying = false;
lastActiveKey = ""; // 失败时重置,允许下轮重试
log(`❌ API 错误:${e.message}`);
setStatus(`API 错误 · ${new Date().toLocaleTimeString()}`);
continue;
}
log(`💬 ${reply.length > 35 ? reply.slice(0, 35) + "…" : reply}`);
try {
await sendMessage(reply, username);
log(`✅ 已发送给 ${username}`);
addReplied(username);
} catch (e) {
log(`❌ 发送失败:${e.message}`);
lastActiveKey = ""; // 发送失败时重置,允许下轮重试
} finally {
activeReplying = false;
}
await sleep(1000);
}
} catch (e) {
log(`扫描异常:${e.message}`);
} finally {
scanning = false;
setStatus(`上次扫描:${new Date().toLocaleTimeString()}`);
}
}
/* ═══════════════════════════════════════════
6. 控制面板 UI
═══════════════════════════════════════════ */
function createPanel() {
if (document.getElementById("xianyu-ai-panel")) return;
const enabled = store.get(KEY_ENABLED) !== "false";
const apiKey = store.get(KEY_API_KEY) || "";
const apiBase = store.get(KEY_API_BASE) || DEFAULT_BASE;
const model = store.get(KEY_MODEL) || DEFAULT_MODEL;
const prompt = store.get(KEY_PROMPT) || DEFAULT_PROMPT;
const replySuffix = store.get(KEY_REPLY_SUFFIX) ?? DEFAULT_REPLY_SUFFIX;
const panel = document.createElement("div");
panel.id = "xianyu-ai-panel";
panel.style.cssText = `
position: fixed; bottom: 24px; right: 24px; width: 330px;
background: #fff; border: 1px solid #dde3ee;
border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,.16);
z-index: 2147483647;
font-family: -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
font-size: 13px; user-select: none; overflow: hidden;
`;
panel.innerHTML = `
🤖 闲鱼 AI 智能客服
`;
document.body.appendChild(panel);
/* ── 拖拽 ── */
const hdr = document.getElementById("xy-hdr");
hdr.addEventListener("mousedown", (e) => {
if (e.target.closest("label, input")) return;
e.preventDefault();
const dx = e.clientX - panel.offsetLeft;
const dy = e.clientY - panel.offsetTop;
const onMove = (e) => {
panel.style.left = `${e.clientX - dx}px`;
panel.style.top = `${e.clientY - dy}px`;
panel.style.bottom = "auto";
panel.style.right = "auto";
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
});
/* ── 标签切换 ── */
panel.querySelectorAll(".xy-tab").forEach((btn) => {
btn.addEventListener("click", () => {
panel.querySelectorAll(".xy-tab").forEach((t) => {
t.style.fontWeight = "400";
t.style.color = "#999";
t.style.borderBottom = "2px solid transparent";
});
panel.querySelectorAll(".xy-panel").forEach((p) => {
p.style.display = "none";
});
btn.style.fontWeight = "600";
btn.style.color = "#6C63FF";
btn.style.borderBottom = "2px solid #6C63FF";
document.getElementById(`xy-panel-${btn.dataset.tab}`).style.display =
"block";
});
});
/* ── 启用 / 暂停 ── */
document.getElementById("xy-enabled").addEventListener("change", (e) => {
store.set(KEY_ENABLED, e.target.checked);
document.getElementById("xy-enabled-label").textContent = e.target.checked
? "运行中"
: "已暂停";
log(e.target.checked ? "▶ 智能回复已恢复" : "⏸ 智能回复已暂停");
});
/* ── 保存 API 设置 ── */
document.getElementById("xy-save-api").addEventListener("click", () => {
const key = document.getElementById("xy-api-key").value.trim();
const base = document.getElementById("xy-api-base").value.trim();
const mdl = document.getElementById("xy-model").value.trim();
if (!key) {
log("❌ API Key 不能为空");
return;
}
store.set(KEY_API_KEY, key);
store.set(KEY_API_BASE, base || DEFAULT_BASE);
store.set(KEY_MODEL, mdl || DEFAULT_MODEL);
log("✅ API 设置已保存");
panel.querySelector('[data-tab="log"]').click();
});
/* ── 保存 Prompt ── */
document.getElementById("xy-save-prompt").addEventListener("click", () => {
const p = document.getElementById("xy-prompt").value;
const suffix = document.getElementById("xy-reply-suffix").value;
store.set(KEY_PROMPT, p);
store.set(KEY_REPLY_SUFFIX, suffix);
log("✅ Prompt 和回复小尾巴已保存");
panel.querySelector('[data-tab="log"]').click();
});
/* ── 立即扫描 ── */
document.getElementById("xy-scan-now").addEventListener("click", () => {
panel.querySelector('[data-tab="log"]').click();
runScan();
});
/* ── 清空回复记录 ── */
document
.getElementById("xy-clear-replied")
.addEventListener("click", () => {
saveReplied([]);
updateRepliedUI();
});
// 初始化已回复列表
updateRepliedUI();
}
/* ═══════════════════════════════════════════
日志 & 状态
═══════════════════════════════════════════ */
function setStatus(text) {
const el = document.getElementById("xy-status");
if (el) el.textContent = text;
}
const logLines = [];
function log(msg) {
console.log("[闲鱼AI]", msg);
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
logLines.unshift(`${time} ${esc(msg)}`);
if (logLines.length > 50) logLines.length = 50;
const el = document.getElementById("xy-log");
if (el) el.innerHTML = logLines.map((l) => `${l}
`).join("");
}
/* ═══════════════════════════════════════════
入口
═══════════════════════════════════════════ */
async function init() {
await sleep(3200); // 等页面稳定
createPanel();
watchActiveChatMessages(); // 启动激活会话实时监听
log("脚本已启动");
setInterval(() => {
if (store.get(KEY_ENABLED) !== "false") runScan();
}, SCAN_INTERVAL);
}
if (document.readyState === "complete") {
init();
} else {
window.addEventListener("load", init);
}
})();