// ==UserScript== // @name AI 会话面板(智能猫) // @namespace https://scriptcat.org/ // @version 1.0.0 // @description 单文件 ScriptCat AI Agent 面板,支持页面内唤起、发送上下文、复制结果和本地配置 // @author wuxia // @match http://*/* // @match https://*/* // @grant GM_registerMenuCommand // @grant GM_getValue // @grant GM_setValue // @grant GM_setClipboard // @grant CAT.agent.conversation // @icon https://scriptcat.org/_next/image?url=%2Fassets%2Flogo.png&w=64&q=75 // @run-at document-idle // ==/UserScript== (function () { "use strict"; const APP = { id: "smartcat", name: "智能猫", englishName: "SmartCat", version: "0.1.0", }; const DEFAULT_CONFIG = { shortcut: getDefaultShortcut(), }; const STORAGE_KEYS = { draft: "smartcat.draft.v1", recent: "smartcat.recent.v1", }; const MAX_RECENT_MESSAGES = 8; const MAX_ATTACHMENT_CHARS = 12000; const MAX_PAGE_TEXT_CHARS = 20000; const MAX_NODE_TEXT_CHARS = 12000; const AT_SUGGESTIONS = [ { token: "@file", label: "@file", description: "引用一个本地文本文件" }, { token: "@selection", label: "@selection", description: "选择页面节点作为上下文" }, { token: "@page", label: "@page", description: "引用当前页面正文文本" }, { token: "@img", label: "@img", description: "选择页面图片作为上下文" }, ]; const TEXT = { placeholder: "像命令行一样提问,输入 @ 引用文件/页面,Ctrl/⌘+Enter 发送", agentUnavailable: "当前 ScriptCat 环境不可用 CAT.agent.conversation,无法调用内置 Agent。", emptyInput: "请输入内容后再发送。", loading: "正在请求 Agent...", copied: "已复制当前回复。", clearedData: "已清除草稿和最近消息。", noRecent: "暂无最近消息。", }; const state = { config: { ...DEFAULT_CONFIG }, panel: null, elements: {}, lastMessage: "", lastResponseText: "", sending: false, abortController: null, conversation: null, attachment: null, selectedNode: null, selectedImage: null, nodePicker: null, drag: null, }; // --------------------------------------------------------------------------- // Storage // --------------------------------------------------------------------------- async function getStoredValue(key, fallbackValue) { try { if (typeof GM_getValue === "function") { const value = await GM_getValue(key, fallbackValue); return value === undefined ? fallbackValue : value; } } catch (error) { console.warn("[SmartCat] GM_getValue failed, using localStorage", error); } try { const raw = localStorage.getItem(key); return raw === null ? fallbackValue : JSON.parse(raw); } catch (error) { console.warn("[SmartCat] localStorage read failed", error); return fallbackValue; } } async function setStoredValue(key, value) { try { if (typeof GM_setValue === "function") { await GM_setValue(key, value); return; } } catch (error) { console.warn("[SmartCat] GM_setValue failed, using localStorage", error); } try { localStorage.setItem(key, JSON.stringify(value)); } catch (error) { console.warn("[SmartCat] localStorage write failed", error); } } function getDefaultShortcut() { return isMacPlatform() ? "Command+K" : "Alt+K"; } function isMacPlatform() { const platform = navigator.userAgentData && navigator.userAgentData.platform ? navigator.userAgentData.platform : navigator.platform || ""; return /mac/i.test(platform); } async function loadDraft() { return String(await getStoredValue(STORAGE_KEYS.draft, "")); } async function saveDraft(value) { await setStoredValue(STORAGE_KEYS.draft, String(value || "")); } async function clearDraft() { await saveDraft(""); } async function loadRecentMessages() { const recent = await getStoredValue(STORAGE_KEYS.recent, []); return Array.isArray(recent) ? recent : []; } async function addRecentMessage(message, responseText) { const recent = await loadRecentMessages(); recent.unshift({ message, responseText, url: location.href, title: document.title, createdAt: new Date().toISOString(), }); await setStoredValue(STORAGE_KEYS.recent, recent.slice(0, MAX_RECENT_MESSAGES)); } async function clearLocalData() { await setStoredValue(STORAGE_KEYS.draft, ""); await setStoredValue(STORAGE_KEYS.recent, []); } // --------------------------------------------------------------------------- // UI // --------------------------------------------------------------------------- function ensurePanel() { if (state.panel && document.body.contains(state.panel)) { return state.panel; } injectStyles(); const root = document.createElement("section"); root.id = "smartcat-panel"; root.setAttribute("aria-label", `${APP.name} AI Agent`); root.innerHTML = ` `; state.elements = { root, input: root.querySelector(".smartcat-input"), response: root.querySelector(".smartcat-response"), configSummary: root.querySelector(".smartcat-config-summary"), header: root.querySelector(".smartcat-header"), atHints: root.querySelector(".smartcat-at-hints"), fileInput: root.querySelector(".smartcat-file-input"), sendButton: root.querySelector('[data-action="send"]'), retryButton: root.querySelector('[data-action="retry"]'), }; state.elements.input.placeholder = TEXT.placeholder; state.elements.input.addEventListener("input", () => { saveDraft(state.elements.input.value); updateAtHints(); }); state.elements.input.addEventListener("keydown", (event) => { if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { event.preventDefault(); sendCurrentInput(); } else if (event.key === "Tab" && !state.elements.atHints.hidden) { event.preventDefault(); const suggestions = getCurrentAtSuggestions(); if (suggestions.length) { applyAtSuggestion(suggestions[0].token); } } }); state.elements.fileInput.addEventListener("change", handleFilePicked); state.elements.header.addEventListener("pointerdown", startPanelDrag); root.addEventListener("click", handlePanelClick); root.addEventListener("keydown", (event) => { if (event.key === "Escape") { hidePanel(); } }); document.body.appendChild(root); state.panel = root; updateConfigSummary(); return root; } function injectStyles() { if (document.getElementById("smartcat-style")) return; const style = document.createElement("style"); style.id = "smartcat-style"; style.textContent = ` #smartcat-panel { position: fixed !important; left: 50% !important; top: 50% !important; transform: translate(-50%, -50%) !important; z-index: 2147483647 !important; width: min(680px, calc(100vw - 32px)) !important; color: #111827 !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important; display: none !important; user-select: none !important; } #smartcat-panel.smartcat-open { display: block !important; } #smartcat-panel * { box-sizing: border-box !important; letter-spacing: 0 !important; } #smartcat-panel .smartcat-card { background: #ffffff !important; border: 1px solid #d7dde8 !important; border-radius: 8px !important; box-shadow: 0 18px 60px rgba(15, 23, 42, 0.22) !important; overflow: hidden !important; } #smartcat-panel .smartcat-header { display: flex !important; align-items: center !important; justify-content: space-between !important; padding: 12px 14px !important; border-bottom: 1px solid #e5e7eb !important; background: #f8fafc !important; cursor: grab !important; touch-action: none !important; } #smartcat-panel.smartcat-dragging .smartcat-header { cursor: grabbing !important; } #smartcat-panel .smartcat-title { font-size: 15px !important; line-height: 20px !important; font-weight: 700 !important; } #smartcat-panel .smartcat-subtitle, #smartcat-panel .smartcat-config-summary { color: #64748b !important; font-size: 12px !important; line-height: 18px !important; } #smartcat-panel .smartcat-icon-button { width: 30px !important; height: 30px !important; border: 1px solid #d1d5db !important; border-radius: 6px !important; background: #ffffff !important; color: #475569 !important; cursor: pointer !important; font-size: 18px !important; line-height: 1 !important; } #smartcat-panel .smartcat-body { padding: 12px !important; } #smartcat-panel .smartcat-input { width: 100% !important; min-height: 96px !important; resize: vertical !important; border: 1px solid #cbd5e1 !important; border-radius: 6px !important; padding: 10px !important; color: #0f172a !important; background: #ffffff !important; font-size: 14px !important; line-height: 1.5 !important; outline: none !important; user-select: text !important; } #smartcat-panel .smartcat-input:focus { border-color: #2563eb !important; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14) !important; } #smartcat-panel .smartcat-toolbar { display: flex !important; gap: 6px !important; flex-wrap: wrap !important; margin: 10px 0 8px !important; } #smartcat-panel .smartcat-at-hints { display: flex !important; flex-wrap: wrap !important; gap: 6px !important; padding: 8px 0 0 !important; } #smartcat-panel .smartcat-at-hints[hidden] { display: none !important; } #smartcat-panel .smartcat-at-hint { border: 1px solid #cbd5e1 !important; border-radius: 6px !important; background: #f8fafc !important; color: #334155 !important; cursor: pointer !important; font-size: 12px !important; line-height: 16px !important; padding: 5px 7px !important; } #smartcat-panel .smartcat-at-hint strong { color: #0f172a !important; font-weight: 700 !important; } #smartcat-panel .smartcat-file-input { display: none !important; visibility: hidden !important; position: absolute !important; width: 0 !important; height: 0 !important; opacity: 0 !important; pointer-events: none !important; } #smartcat-panel button { border: 1px solid #cbd5e1 !important; border-radius: 6px !important; background: #f8fafc !important; color: #1f2937 !important; cursor: pointer !important; font-size: 13px !important; line-height: 18px !important; padding: 6px 10px !important; } #smartcat-panel button:hover:not(:disabled) { background: #eef2ff !important; border-color: #93c5fd !important; } #smartcat-panel button:disabled { cursor: not-allowed !important; opacity: 0.55 !important; } #smartcat-panel .smartcat-response { width: 100% !important; min-height: 120px !important; max-height: 360px !important; overflow: auto !important; margin: 8px 0 0 !important; border: 1px solid #e2e8f0 !important; border-radius: 6px !important; padding: 10px !important; background: #0f172a !important; color: #e5e7eb !important; white-space: pre-wrap !important; word-break: break-word !important; font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important; user-select: text !important; } .smartcat-node-picker-highlight { position: fixed !important; z-index: 2147483646 !important; pointer-events: none !important; border: 2px solid #2563eb !important; background: rgba(37, 99, 235, 0.14) !important; box-shadow: 0 0 0 99999px rgba(15, 23, 42, 0.18) !important; border-radius: 4px !important; } .smartcat-node-picker-label { position: fixed !important; z-index: 2147483647 !important; pointer-events: none !important; max-width: min(520px, calc(100vw - 24px)) !important; padding: 7px 10px !important; border-radius: 6px !important; background: #111827 !important; color: #f8fafc !important; font: 12px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.25) !important; } @media (max-width: 560px) { #smartcat-panel { width: calc(100vw - 24px) !important; } } `; document.documentElement.appendChild(style); } async function openPanel() { const panel = ensurePanel(); panel.classList.add("smartcat-open"); state.elements.input.value = await loadDraft(); updateConfigSummary(); updateAtHints(); setTimeout(() => state.elements.input.focus(), 0); } function hidePanel() { if (!state.panel) return; saveDraft(state.elements.input.value); state.panel.classList.remove("smartcat-open"); } function updateConfigSummary() { if (!state.elements.configSummary) return; const agentMode = nativeAgentAvailable() ? "ScriptCat Agent" : "Agent 不可用"; const attachment = state.attachment ? ` · 已引用 ${state.attachment.name}` : ""; const selectedNode = state.selectedNode ? ` · 已选择 <${state.selectedNode.tag}>` : ""; const selectedImage = state.selectedImage ? " · 已选择图片" : ""; state.elements.configSummary.textContent = `快捷键 ${state.config.shortcut} · ${agentMode}${attachment}${selectedNode}${selectedImage}`; } async function handlePanelClick(event) { if (event.target && event.target.dataset && event.target.dataset.atToken) { applyAtSuggestion(event.target.dataset.atToken); return; } const action = event.target && event.target.dataset ? event.target.dataset.action : ""; if (!action) return; if (action === "close") hidePanel(); if (action === "send") await sendCurrentInput(); if (action === "retry") await retryLastMessage(); if (action === "copy") await copyCurrentResponse(); if (action === "clear") setResponse(""); if (action === "file") openFilePicker(); if (action === "recent") await showRecentMessages(); if (action === "clear-data") await clearDataFromPanel(); } function setResponse(text, options = {}) { state.lastResponseText = String(text || ""); if (state.elements.response) { state.elements.response.textContent = state.lastResponseText; } if (state.elements.retryButton) { state.elements.retryButton.disabled = !options.canRetry || !state.lastMessage; } } function setLoading(isLoading) { state.sending = isLoading; if (state.elements.sendButton) { state.elements.sendButton.disabled = isLoading; state.elements.sendButton.textContent = isLoading ? "发送中" : "发送"; } } async function clearDataFromPanel() { if (!confirm("清除草稿、最近消息和文件引用?")) return; await clearLocalData(); state.attachment = null; state.selectedNode = null; state.selectedImage = null; state.elements.input.value = ""; if (state.elements.fileInput) state.elements.fileInput.value = ""; updateConfigSummary(); setResponse(TEXT.clearedData); } // --------------------------------------------------------------------------- // @ mentions and files // --------------------------------------------------------------------------- function updateAtHints() { if (!state.elements.atHints || !state.elements.input) return; const token = getCurrentAtToken(state.elements.input.value, state.elements.input.selectionStart || 0); if (!token) { state.elements.atHints.hidden = true; state.elements.atHints.textContent = ""; return; } const suggestions = getCurrentAtSuggestions(); if (!suggestions.length) { state.elements.atHints.hidden = true; state.elements.atHints.textContent = ""; return; } state.elements.atHints.hidden = false; state.elements.atHints.innerHTML = suggestions .map((item) => ( `` )) .join(""); } function getCurrentAtToken(value, cursor) { const beforeCursor = value.slice(0, cursor); const match = beforeCursor.match(/(^|\s)(@[a-zA-Z\u4e00-\u9fa5]*)$/); return match ? match[2] : ""; } function getCurrentAtSuggestions() { if (!state.elements.input) return []; const token = getCurrentAtToken( state.elements.input.value, state.elements.input.selectionStart || 0, ); const normalized = token.toLowerCase(); return AT_SUGGESTIONS.filter((item) => item.token.startsWith(normalized)); } function applyAtSuggestion(token) { const input = state.elements.input; if (!input) return; const cursor = input.selectionStart || 0; const beforeCursor = input.value.slice(0, cursor); const afterCursor = input.value.slice(cursor); const match = beforeCursor.match(/(^|\s)(@[a-zA-Z\u4e00-\u9fa5]*)$/); if (!match) return; const prefix = beforeCursor.slice(0, beforeCursor.length - match[2].length); input.value = `${prefix}${token} ${afterCursor}`; const nextCursor = `${prefix}${token} `.length; input.setSelectionRange(nextCursor, nextCursor); saveDraft(input.value); updateAtHints(); if (token === "@file") openFilePicker(); if (token === "@selection") startNodePicker("node"); if (token === "@img") startNodePicker("image"); } function openFilePicker() { if (state.elements.fileInput) { state.elements.fileInput.click(); } } async function handleFilePicked(event) { const file = event.target.files && event.target.files[0]; if (!file) return; try { const text = await readFileAsText(file); state.attachment = { name: file.name, type: file.type || "text/plain", size: file.size, text: text.slice(0, MAX_ATTACHMENT_CHARS), truncated: text.length > MAX_ATTACHMENT_CHARS, }; ensureAtFileToken(); updateConfigSummary(); setResponse(`已引用文件:${file.name}${state.attachment.truncated ? "(内容已截断)" : ""}`); } catch (error) { setResponse(`读取文件失败:${error.message}`); } } function readFileAsText(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = () => reject(reader.error || new Error("未知文件读取错误")); reader.readAsText(file); }); } function ensureAtFileToken() { const input = state.elements.input; if (!input || input.value.includes("@file")) return; input.value = input.value ? `@file ${input.value}` : "@file "; input.focus(); input.setSelectionRange(input.value.length, input.value.length); saveDraft(input.value); } // --------------------------------------------------------------------------- // Node picker // --------------------------------------------------------------------------- function startNodePicker(mode = "node") { if (state.nodePicker) return; hidePanel(); const highlight = document.createElement("div"); highlight.className = "smartcat-node-picker-highlight"; highlight.hidden = true; const label = document.createElement("div"); label.className = "smartcat-node-picker-label"; label.textContent = mode === "image" ? "选择一张页面图片作为 @img 上下文,点击确认,Esc 取消" : "选择一个页面区域作为 @selection 上下文,点击确认,Esc 取消"; document.documentElement.appendChild(highlight); document.documentElement.appendChild(label); state.nodePicker = { mode, highlight, label, current: null, }; positionNodePickerLabel(12, 12); document.addEventListener("mousemove", handleNodePickerMove, true); document.addEventListener("click", handleNodePickerClick, true); document.addEventListener("keydown", handleNodePickerKeydown, true); } function stopNodePicker(options = {}) { if (!state.nodePicker) return; document.removeEventListener("mousemove", handleNodePickerMove, true); document.removeEventListener("click", handleNodePickerClick, true); document.removeEventListener("keydown", handleNodePickerKeydown, true); state.nodePicker.highlight.remove(); state.nodePicker.label.remove(); state.nodePicker = null; if (options.reopen !== false) { openPanel(); } } function handleNodePickerMove(event) { if (!state.nodePicker) return; const target = getPickableElement(event.target, state.nodePicker.mode); if (!target) { state.nodePicker.current = null; state.nodePicker.highlight.hidden = true; state.nodePicker.label.textContent = state.nodePicker.mode === "image" ? "移动到页面图片上,点击选择图片,Esc 取消" : "移动到页面内容上,点击选择节点,Esc 取消"; positionNodePickerLabel(event.clientX + 12, event.clientY + 12); return; } state.nodePicker.current = target; updateNodePickerHighlight(target); if (state.nodePicker.mode === "image") { const image = getImageInfo(target); state.nodePicker.label.textContent = `选择图片 · ${image.width || "?"}x${image.height || "?"} · 点击确认,Esc 取消`; } else { const textLength = getElementText(target).length; state.nodePicker.label.textContent = `选择 <${target.tagName.toLowerCase()}> · ${textLength} 字符 · 点击确认,Esc 取消`; } positionNodePickerLabel(event.clientX + 12, event.clientY + 12); } function handleNodePickerClick(event) { if (!state.nodePicker) return; event.preventDefault(); event.stopPropagation(); const target = getPickableElement(event.target, state.nodePicker.mode) || state.nodePicker.current; if (!target) return; if (state.nodePicker.mode === "image") { state.selectedImage = getImageInfo(target); stopNodePicker(); updateConfigSummary(); setResponse(`已选择图片作为 @img 上下文:${state.selectedImage.src}`); return; } const text = getElementText(target); state.selectedNode = { tag: target.tagName.toLowerCase(), text: text.slice(0, MAX_NODE_TEXT_CHARS), truncated: text.length > MAX_NODE_TEXT_CHARS, }; stopNodePicker(); updateConfigSummary(); setResponse( `已选择 <${state.selectedNode.tag}> 节点作为 @selection 上下文` + `${state.selectedNode.truncated ? "(内容已截断)" : ""}`, ); } function handleNodePickerKeydown(event) { if (event.key !== "Escape") return; event.preventDefault(); event.stopPropagation(); stopNodePicker(); } function getPickableElement(target, mode = "node") { if (!(target instanceof Element)) return null; if (target.closest("#smartcat-panel")) return null; if (target.closest(".smartcat-node-picker-highlight, .smartcat-node-picker-label")) return null; if (mode === "image") { const image = target.closest("img"); if (image && (image.currentSrc || image.src)) return image; return null; } return target.closest("article, main, section, div, p, li, table, pre, blockquote, body"); } function updateNodePickerHighlight(element) { const rect = element.getBoundingClientRect(); const highlight = state.nodePicker.highlight; highlight.hidden = false; highlight.style.setProperty("left", `${rect.left}px`, "important"); highlight.style.setProperty("top", `${rect.top}px`, "important"); highlight.style.setProperty("width", `${rect.width}px`, "important"); highlight.style.setProperty("height", `${rect.height}px`, "important"); } function positionNodePickerLabel(left, top) { const label = state.nodePicker.label; const maxLeft = Math.max(8, window.innerWidth - label.offsetWidth - 8); const maxTop = Math.max(8, window.innerHeight - label.offsetHeight - 8); label.style.setProperty("left", `${clamp(left, 8, maxLeft)}px`, "important"); label.style.setProperty("top", `${clamp(top, 8, maxTop)}px`, "important"); } function getElementText(element) { const clone = element.cloneNode(true); clone .querySelectorAll("script, style, noscript, template, svg, canvas, iframe, object, embed") .forEach((node) => node.remove()); return (clone.innerText || clone.textContent || "") .replace(/\u00a0/g, " ") .replace(/[ \t\r\f\v]+/g, " ") .replace(/\n\s*\n\s*\n+/g, "\n\n") .split("\n") .map((line) => line.trim()) .filter(Boolean) .join("\n") .trim(); } function getImageInfo(image) { const rect = image.getBoundingClientRect(); return { src: image.currentSrc || image.src || "", alt: image.alt || "", title: image.title || "", width: image.naturalWidth || Math.round(rect.width), height: image.naturalHeight || Math.round(rect.height), displayWidth: Math.round(rect.width), displayHeight: Math.round(rect.height), }; } // --------------------------------------------------------------------------- // Agent // --------------------------------------------------------------------------- async function sendCurrentInput() { const input = state.elements.input; const message = input ? input.value.trim() : ""; if (!message) { setResponse(TEXT.emptyInput); return; } if (message.includes("@selection") && !state.selectedNode) { startNodePicker("node"); return; } if (message.includes("@img") && !state.selectedImage) { startNodePicker("image"); return; } await sendMessage(message); } async function retryLastMessage() { if (!state.lastMessage) return; await sendMessage(state.lastMessage); } async function sendMessage(message) { if (!nativeAgentAvailable()) { setResponse(TEXT.agentUnavailable); return; } if (state.sending) return; state.lastMessage = message; setLoading(true); setResponse(TEXT.loading); try { const responseText = await requestAgent(message); setResponse(responseText); await clearDraft(); if (state.elements.input) state.elements.input.value = ""; await addRecentMessage(message, responseText); } catch (error) { setResponse(`请求失败:${error.message}`, { canRetry: true }); } finally { setLoading(false); } } async function requestAgent(message) { return requestScriptCatAgent(message); } async function requestScriptCatAgent(message) { if (!state.conversation) { state.conversation = await CAT.agent.conversation.create({ system: [ "你是智能猫 SmartCat,一个运行在 ScriptCat 用户脚本里的轻量 AI Agent。", "请直接、准确地回答用户问题。", "用户消息会附带当前网页标题、URL 和选中文本,可按需参考。", ].join("\n"), }); } const prompt = buildAgentPrompt(message); const reply = await state.conversation.chat(prompt); if (reply && typeof reply.content === "string") return reply.content; if (typeof reply === "string") return reply; return JSON.stringify(reply, null, 2); } function buildAgentPrompt(message) { const selection = String(window.getSelection ? window.getSelection() : ""); const context = [ "页面上下文:", `标题:${document.title || ""}`, `URL:${location.href}`, ]; if (message.includes("@selection")) { if (state.selectedNode) { context.push([ "节点选择引用:", `标签:<${state.selectedNode.tag}>`, state.selectedNode.truncated ? `注意:节点文本已截断到 ${MAX_NODE_TEXT_CHARS} 字符。` : "", "内容:", state.selectedNode.text, ].filter(Boolean).join("\n")); } else { context.push("节点选择引用:用户输入了 @selection,但尚未选择页面节点。"); } } if (message.includes("@page")) { const pageText = extractPageText(); context.push([ "页面正文引用:", pageText.text || "未提取到页面正文文本。", pageText.truncated ? `注意:页面正文已截断到 ${MAX_PAGE_TEXT_CHARS} 字符。` : "", ].filter(Boolean).join("\n")); } if (message.includes("@img")) { if (state.selectedImage) { context.push([ "图片引用:", `地址:${state.selectedImage.src}`, `替代文本:${state.selectedImage.alt || "无"}`, `标题:${state.selectedImage.title || "无"}`, `原始尺寸:${state.selectedImage.width || "未知"}x${state.selectedImage.height || "未知"}`, `显示尺寸:${state.selectedImage.displayWidth || "未知"}x${state.selectedImage.displayHeight || "未知"}`, ].join("\n")); } else { context.push("图片引用:用户输入了 @img,但尚未选择页面图片。"); } } if (message.includes("@file")) { if (state.attachment) { context.push([ "文件引用:", `文件名:${state.attachment.name}`, `类型:${state.attachment.type}`, `大小:${state.attachment.size} bytes`, state.attachment.truncated ? `注意:文件内容已截断到 ${MAX_ATTACHMENT_CHARS} 字符。` : "", "内容:", state.attachment.text, ].filter(Boolean).join("\n")); } else { context.push("文件引用:用户输入了 @file,但尚未选择文件。"); } } if (!message.includes("@selection")) { context.push(`当前选中文本:${selection || "无"}`); } return [message, "", context.join("\n")].join("\n"); } function extractPageText() { const source = document.body; if (!source) return { text: "", truncated: false }; const clone = source.cloneNode(true); clone .querySelectorAll([ "script", "style", "noscript", "template", "svg", "canvas", "iframe", "object", "embed", "video", "audio", "source", "picture", "nav", "header", "footer", ].join(",")) .forEach((node) => node.remove()); const text = clone.textContent .replace(/\u00a0/g, " ") .replace(/[ \t\r\f\v]+/g, " ") .replace(/\n\s*\n\s*\n+/g, "\n\n") .split("\n") .map((line) => line.trim()) .filter(Boolean) .join("\n") .trim(); return { text: text.slice(0, MAX_PAGE_TEXT_CHARS), truncated: text.length > MAX_PAGE_TEXT_CHARS, }; } function nativeAgentAvailable() { return ( typeof CAT !== "undefined" && CAT && CAT.agent && CAT.agent.conversation && typeof CAT.agent.conversation.create === "function" ); } // --------------------------------------------------------------------------- // Dragging // --------------------------------------------------------------------------- function startPanelDrag(event) { if (!state.panel || event.button !== 0) return; if (event.target.closest("button")) return; const rect = state.panel.getBoundingClientRect(); state.drag = { pointerId: event.pointerId, offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top, width: rect.width, height: rect.height, }; state.panel.classList.add("smartcat-dragging"); setPanelPosition(rect.left, rect.top); state.elements.header.setPointerCapture(event.pointerId); event.preventDefault(); window.addEventListener("pointermove", movePanelDrag, true); window.addEventListener("pointerup", stopPanelDrag, true); window.addEventListener("pointercancel", stopPanelDrag, true); } function movePanelDrag(event) { if (!state.drag || !state.panel || event.pointerId !== state.drag.pointerId) return; const margin = 8; const maxLeft = Math.max(margin, window.innerWidth - state.drag.width - margin); const maxTop = Math.max(margin, window.innerHeight - state.drag.height - margin); const left = clamp(event.clientX - state.drag.offsetX, margin, maxLeft); const top = clamp(event.clientY - state.drag.offsetY, margin, maxTop); setPanelPosition(left, top); } function stopPanelDrag(event) { if (!state.drag || event.pointerId !== state.drag.pointerId) return; if (state.elements.header && state.elements.header.hasPointerCapture(event.pointerId)) { state.elements.header.releasePointerCapture(event.pointerId); } if (state.panel) { state.panel.classList.remove("smartcat-dragging"); } state.drag = null; window.removeEventListener("pointermove", movePanelDrag, true); window.removeEventListener("pointerup", stopPanelDrag, true); window.removeEventListener("pointercancel", stopPanelDrag, true); } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function setPanelPosition(left, top) { state.panel.style.setProperty("left", `${left}px`, "important"); state.panel.style.setProperty("top", `${top}px`, "important"); state.panel.style.setProperty("transform", "none", "important"); } // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- async function copyCurrentResponse() { if (!state.lastResponseText) return; try { if (typeof GM_setClipboard === "function") { GM_setClipboard(state.lastResponseText); } else if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(state.lastResponseText); } else { throw new Error("当前浏览器不支持剪贴板 API"); } setResponse(`${state.lastResponseText}\n\n${TEXT.copied}`); } catch (error) { setResponse(`复制失败:${error.message}`, { canRetry: true }); } } async function showRecentMessages() { const recent = await loadRecentMessages(); if (!recent.length) { setResponse(TEXT.noRecent); return; } const text = recent .map((item, index) => { const time = item.createdAt ? new Date(item.createdAt).toLocaleString() : ""; return [ `#${index + 1} ${time}`, `Q: ${item.message}`, `A: ${item.responseText}`, ].join("\n"); }) .join("\n\n---\n\n"); setResponse(text); } // --------------------------------------------------------------------------- // Event binding // --------------------------------------------------------------------------- function registerMenuCommand() { if (typeof GM_registerMenuCommand === "function") { GM_registerMenuCommand("打开智能猫 SmartCat", openPanel); } } function bindShortcut() { document.addEventListener( "keydown", async (event) => { if (state.panel && state.panel.classList.contains("smartcat-open") && event.key === "Escape") { event.preventDefault(); hidePanel(); return; } if (matchesConfiguredShortcut(event)) { event.preventDefault(); await openPanel(); } }, true, ); } function matchesShortcut(event, shortcut) { const parts = String(shortcut || "") .split("+") .map((part) => part.trim().toLowerCase()) .filter(Boolean); if (!parts.length) return false; const expectedKey = parts[parts.length - 1]; const key = normalizeKey(event.key); const wantsCtrl = parts.includes("ctrl") || parts.includes("control"); const wantsAlt = parts.includes("alt") || parts.includes("option"); const wantsShift = parts.includes("shift"); const wantsMeta = parts.includes("meta") || parts.includes("cmd") || parts.includes("command"); return ( key === normalizeKey(expectedKey) && event.ctrlKey === wantsCtrl && event.altKey === wantsAlt && event.shiftKey === wantsShift && event.metaKey === wantsMeta ); } function matchesConfiguredShortcut(event) { if (matchesShortcut(event, state.config.shortcut)) return true; // Keep Command+K working on macOS even if an older saved config still says Alt+K. return isMacPlatform() && matchesShortcut(event, "Command+K"); } function normalizeKey(key) { const lower = String(key || "").toLowerCase(); if (lower === " ") return "space"; if (lower === "escape") return "esc"; return lower; } async function init() { registerMenuCommand(); bindShortcut(); console.log(`[${APP.englishName}] loaded. Open with ${state.config.shortcut} or ScriptCat menu.`); } init(); })();