一键发送到AI(支持图文)

按快捷键选择页面元素,快速发送到Gemini/ChatGPT/AI Studio/DeepSeek

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         一键发送到AI(支持图文)
// @name:en      Ask AI Anywhere (Support Image)
// @namespace    https://blog.xlab.app/
// @more         https://github.com/ttttmr/UserJS
// @version      0.9
// @description  按快捷键选择页面元素,快速发送到Gemini/ChatGPT/AI Studio/DeepSeek
// @description:en  Quickly send web content (text & images) to AI (Gemini, ChatGPT, AI Studio, DeepSeek) with a shortcut
// @author       tmr
// @match        http://*/*
// @match        https://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// ==/UserScript==

const CONFIG = {
  SHORTCUT_TRIGGER: (e) => e.altKey && e.code === "Digit2",
  PROVIDERS: {
    gemini: {
      name: "Gemini",
      url: "https://gemini.google.com/app",
      inputSelector: 'div[contenteditable="true"], textarea',
      sendButtonSelector: "button.submit",
    },
    chatgpt: {
      name: "ChatGPT",
      url: "https://chatgpt.com/",
      inputSelector: "#prompt-textarea",
      sendButtonSelector: 'button[data-testid="send-button"]',
    },
    aistudio: {
      name: "AI Studio",
      url: "https://aistudio.google.com/prompts/new_chat",
      inputSelector: "ms-autosize-textarea textarea",
      sendButtonSelector: 'button[aria-label="Run"]',
    },
    deepseek: {
      name: "DeepSeek",
      url: "https://chat.deepseek.com/",
      inputSelector: 'textarea[placeholder*="DeepSeek"]',
      sendButtonSelector: 'div[role="button"].ds-icon-button',
    },
  },
  GENERATE_PROMPT: (data) => {
    const { title, url, selection, content, images } = data;
    const zh = navigator.language.toLowerCase().startsWith("zh");
    const prompts = [];
    if (zh) {
      prompts.push(`我正在阅读:${title}`);
    } else {
      prompts.push(`I'm reading: ${title}`);
    }
    if (content) {
      if (zh) {
        prompts.push("内容:");
      } else {
        prompts.push("Content:");
      }
      prompts.push("```markdown");
      prompts.push(content);
      prompts.push("```");
    }
    if (selection) {
      console.log("[Ask] found selection");
      if (zh) {
        prompts.push(`其中${selection}如何理解?`);
      } else {
        prompts.push(`How to understand ${selection}?`);
      }
    } else if (content) {
      console.log("[Ask] found content");
      if (zh) {
        prompts.push("使用通俗的语言总结这篇文章");
      } else {
        prompts.push("Summarize this article in plain language");
      }
    } else if (images) {
      console.log("[Ask] found images");
      if (zh) {
        prompts.push("解释这个图片");
      } else {
        prompts.push("Explain this image");
      }
    }
    return prompts.join("\n");
  },
};

// Helper to wait for element using MutationObserver
function waitForElement(selector, checkFn = (el) => true) {
  return new Promise((resolve) => {
    const element = document.querySelector(selector);
    if (element && checkFn(element)) {
      return resolve(element);
    }

    const observer = new MutationObserver(() => {
      const element = document.querySelector(selector);
      if (element && checkFn(element)) {
        resolve(element);
        observer.disconnect();
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  });
}

// Helper to get valid image source, handling lazy loading and relative URLs
function getImageSrc(imgNode) {
  const candidates = [imgNode.src, imgNode.getAttribute("data-src")];

  for (const src of candidates) {
    if (src && !src.startsWith("data:")) {
      try {
        return new URL(src, location.href).href;
      } catch {}
    }
  }
  return null;
}

// Helper to check if image should be included (filters out icons, avatars, etc.)
function shouldIncludeImage(imgNode) {
  // Filter by keywords
  const keywords = ["avatar", "icon", "logo", "profile"];
  const checkStr = `${imgNode.className || ""} ${imgNode.alt || ""} ${
    imgNode.id || ""
  }`.toLowerCase();
  if (keywords.some((k) => checkStr.includes(k))) return false;

  return true;
}

// Helper to extract text (Markdown) and images from element or fragment
function extractContent(elementOrFragment) {
  if (!elementOrFragment) return { text: "", images: [] };

  let text = "";
  const images = [];

  function traverse(node) {
    if (node.nodeType === Node.TEXT_NODE) {
      // Escape Markdown characters in text
      return node.textContent.replace(/([*_`[\]])/g, "\\$1");
    }

    if (node.nodeType !== Node.ELEMENT_NODE) return "";

    const tagName = node.tagName;
    if (tagName === "SCRIPT" || tagName === "STYLE" || tagName === "NOSCRIPT")
      return "";

    const parts = [];
    for (const child of node.childNodes) {
      parts.push(traverse(child));
    }
    const content = parts.join("");

    // Handle specific tags
    switch (tagName) {
      case "IMG": {
        const src = getImageSrc(node);
        if (src && shouldIncludeImage(node)) {
          const filename = `img_${images.length + 1}`;
          images.push({ url: src, filename });
          return `\n![${filename}]\n`;
        }
        return "";
      }
      case "BR":
        return "\n";
      case "P":
      case "DIV":
        return `\n${content}\n`;
      case "H1":
        return `\n# ${content}\n`;
      case "H2":
        return `\n## ${content}\n`;
      case "H3":
        return `\n### ${content}\n`;
      case "H4":
        return `\n#### ${content}\n`;
      case "H5":
        return `\n##### ${content}\n`;
      case "H6":
        return `\n###### ${content}\n`;
      case "STRONG":
      case "B":
        return `**${content}**`;
      case "EM":
      case "I":
        return `*${content}*`;
      case "A": {
        const href = node.href;
        if (href) {
          try {
            const url = new URL(href, location.href);
            const isImage =
              ["http:", "https:"].includes(url.protocol) &&
              /\.(jpeg|jpg|gif|png|webp|svg|bmp)$/i.test(url.pathname);

            if (isImage) {
              const filename = `img_${images.length + 1}`;
              images.push({ url: href, filename });
              return `\n![${filename}]\n`;
            } else {
              return `[${content}](${href})`;
            }
          } catch {}
        }
      }
      case "CODE":
        return `\`${content}\``;
      case "PRE":
        return `\n\`\`\`\n${content}\n\`\`\`\n`;
      case "BLOCKQUOTE":
        return `\n> ${content}\n`;
      case "LI":
        return `\n- ${content}`;
      case "UL":
      case "OL":
        return `\n${content}\n`;
      case "TR":
        return `\n${content}`;
      case "TD":
      case "TH":
        return ` ${content} |`;
      default:
        return content;
    }
  }

  text = traverse(elementOrFragment);

  // Clean up whitespace
  text = text.replace(/\n(\s*\n)+/g, "\n").trim();

  // Deduplicate images
  const uniqueImages = [];
  const seenUrls = new Set();
  for (const img of images) {
    if (!seenUrls.has(img.url)) {
      seenUrls.add(img.url);
      uniqueImages.push(img);
    }
  }

  return { text, images: uniqueImages };
}

// Helper to fetch image as File object
function fetchImageAsFile(url, filename, referrer) {
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      method: "GET",
      url: url,
      headers: {
        Referer: referrer,
      },
      responseType: "blob",
      onload: (response) => {
        if (response.status === 200) {
          const blob = response.response;
          const file = new File([blob], filename, { type: blob.type });
          resolve(file);
        } else {
          reject(new Error(`Failed to fetch image: ${response.status}`));
        }
      },
      onerror: (err) => reject(err),
    });
  });
}

// DOM Selector Class
class DomSelector {
  constructor() {
    this.state = {
      active: false,
      overlay: null,
      currentElement: null,
      onSelect: null,
    };
    this.boundHandleMouseMove = this.handleMouseMove.bind(this);
    this.boundHandleClick = this.handleClick.bind(this);
    this.boundHandleKeydown = this.handleKeydown.bind(this);
  }

  injectStyles() {
    if (document.getElementById("ask-ai-anywhere-selector-styles")) return;

    const css = `
      .ask-ai-anywhere-selector-overlay {
        position: absolute;
        border: 3px solid #4285f4;
        background: rgba(66, 133, 244, 0.1);
        pointer-events: none;
        z-index: 2147483647;
        transition: all 0.1s ease;
        box-shadow: 0 0 0 1px rgba(66, 133, 244, 0.3);
      }
      .ask-ai-anywhere-selector-active {
        cursor: crosshair !important;
      }
      .ask-ai-anywhere-selector-active * {
        cursor: crosshair !important;
      }
    `;
    const style = GM_addStyle(css);
    if (style) {
      style.id = "ask-ai-anywhere-selector-styles";
    }
  }

  createOverlay() {
    const overlay = document.createElement("div");
    overlay.className = "ask-ai-anywhere-selector-overlay";
    overlay.style.display = "none";
    document.body.appendChild(overlay);
    return overlay;
  }

  highlight(element) {
    if (!this.state.overlay) return;

    if (!element || element === document.documentElement) {
      this.state.overlay.style.display = "none";
      this.state.currentElement = null;
      return;
    }

    const rect = element.getBoundingClientRect();
    const overlay = this.state.overlay;

    overlay.style.display = "block";
    overlay.style.left = `${rect.left + window.scrollX}px`;
    overlay.style.top = `${rect.top + window.scrollY}px`;
    overlay.style.width = `${rect.width}px`;
    overlay.style.height = `${rect.height}px`;

    this.state.currentElement = element;
  }

  handleMouseMove(e) {
    if (!this.state.active) return;

    e.stopPropagation();
    const element = document.elementFromPoint(e.clientX, e.clientY);
    this.highlight(element);
  }

  handleClick(e) {
    if (!this.state.active) return;

    e.preventDefault();
    e.stopPropagation();

    const element = this.state.currentElement;
    if (element && this.state.onSelect) {
      const content = element; // Pass the whole element
      this.state.onSelect(content);
      this.deactivate();
    }
  }

  handleKeydown(e) {
    if (!this.state.active) return;

    if (e.key === "Escape") {
      e.preventDefault();
      e.stopPropagation();
      console.log("[Selector] Canceled by user");
      this.deactivate();
    }
  }

  activate(onSelect) {
    if (this.state.active) return;

    console.log("[Selector] Activating DOM selector");

    this.injectStyles();
    this.state.overlay = this.createOverlay();
    this.state.active = true;
    this.state.onSelect = onSelect;

    document.body.classList.add("ask-ai-anywhere-selector-active");

    // Add event listeners with capture to intercept all events
    document.addEventListener("mousemove", this.boundHandleMouseMove, true);
    document.addEventListener("click", this.boundHandleClick, true);
    document.addEventListener("keydown", this.boundHandleKeydown, true);
  }

  deactivate() {
    if (!this.state.active) return;

    console.log("[Selector] Deactivating DOM selector");

    document.body.classList.remove("ask-ai-anywhere-selector-active");

    // Remove event listeners
    document.removeEventListener("mousemove", this.boundHandleMouseMove, true);
    document.removeEventListener("click", this.boundHandleClick, true);
    document.removeEventListener("keydown", this.boundHandleKeydown, true);

    // Clean up overlay
    if (this.state.overlay) {
      this.state.overlay.remove();
      this.state.overlay = null;
    }

    this.state.active = false;
    this.state.currentElement = null;
    this.state.onSelect = null;
  }
}

const domSelector = new DomSelector();

// Initialize Provider page to receive prompts
async function initProviderPage(providerConfig) {
  console.log(`[Ask] Initializing ${providerConfig.name} page`);

  // Check for prompt from URL param or storage
  const urlParams = new URLSearchParams(window.location.search);
  const urlPrompt = urlParams.get("q");
  const prompt = urlPrompt || GM_getValue("ask_prompt");

  // Check for images in storage
  const storedImagesJson = GM_getValue("ask_images");
  let images = [];
  let referrer = "";
  if (storedImagesJson) {
    try {
      const data = JSON.parse(storedImagesJson);
      images = Array.isArray(data) ? data : data.urls;
      referrer = Array.isArray(data) ? "" : data.referrer;
    } catch (e) {
      console.error("[Ask] Failed to parse stored images", e);
    }
  }

  if (!prompt && (!images || images.length === 0)) return;

  console.log("[Ask] Found content to process");

  // Start fetching images immediately if any
  const imageFetchPromise =
    images && images.length > 0
      ? Promise.all(
          images.map((img) => {
            return fetchImageAsFile(img.url, img.filename, referrer).catch(
              (err) => {
                console.error(`[Ask] Failed to fetch image ${img.url}`, err);
                return null;
              }
            );
          })
        )
      : Promise.resolve([]);

  if (document.readyState !== "complete") {
    await new Promise((resolve) => window.addEventListener("load", resolve));
  }

  try {
    const inputBox = await waitForElement(providerConfig.inputSelector);
    console.log("[Ask] Input box found");
    inputBox.focus();

    // 1. Paste Images
    const rawFiles = await imageFetchPromise;
    const files = rawFiles.filter((f) => f !== null);
    if (files.length > 0) {
      console.log(
        `[Ask] Waiting for window load to paste ${files.length} images...`
      );

      console.log(`[Ask] Window loaded, pasting images`);
      const dataTransfer = new DataTransfer();
      files.forEach((file) => {
        console.log(
          `[Ask] Adding file to DataTransfer: ${file.name} (${file.type}, ${file.size} bytes)`
        );
        dataTransfer.items.add(file);
      });

      const pasteEvent = new ClipboardEvent("paste", {
        bubbles: true,
        cancelable: true,
        clipboardData: dataTransfer,
      });

      // Fallback for some browsers/environments where constructor doesn't set clipboardData correctly
      if (!pasteEvent.clipboardData) {
        Object.defineProperty(pasteEvent, "clipboardData", {
          value: dataTransfer,
          writable: false,
        });
      }

      inputBox.dispatchEvent(pasteEvent);
    }

    // 2. Fill Text
    if (prompt) {
      console.log("[Ask] Filling text prompt");
      inputBox.focus(); // Ensure focus is back on input
      if (inputBox.tagName === "TEXTAREA") {
        const valueSetter = Object.getOwnPropertyDescriptor(
          window.HTMLTextAreaElement.prototype,
          "value"
        ).set;
        valueSetter.call(inputBox, prompt);
      } else {
        // Safe text insertion for contenteditable
        // If we just pasted images, we don't want to wipe them out with textContent = ...
        // So we append a text node.
        const textNode = document.createTextNode(prompt);
        inputBox.appendChild(textNode);
      }
      inputBox.dispatchEvent(new Event("input", { bubbles: true }));
      inputBox.dispatchEvent(new Event("change", { bubbles: true }));
    }

    // 3. Send
    const btn = await waitForElement(
      providerConfig.sendButtonSelector,
      (btn) => {
        return !btn.disabled && btn.getAttribute("aria-disabled") !== "true";
      }
    );

    if (btn) {
      console.log("[Ask] Send button ready, clicking");
      btn.click();
      // Cleanup
      console.log("[Ask] Cleanup");
      GM_deleteValue("ask_prompt");
      GM_deleteValue("ask_images");
    }
  } catch (err) {
    console.error("[Ask] Error processing content", err);
  }
}

// Handle shortcut trigger
function handleShortcut(e) {
  if (!CONFIG.SHORTCUT_TRIGGER(e)) return;

  console.log("[Source] Shortcut triggered");
  e.preventDefault();
  e.stopPropagation();
  e.stopImmediatePropagation();

  const selection = window.getSelection();
  let selectionText = "";
  let selectionImages = [];

  if (selection.rangeCount > 0) {
    const container = document.createElement("div");
    for (let i = 0; i < selection.rangeCount; i++) {
      container.appendChild(selection.getRangeAt(i).cloneContents());
    }
    const result = extractContent(container);
    selectionText = result.text;
    selectionImages = result.images;
  }

  domSelector.activate((element) => {
    const { text: content, images: elementImages } = extractContent(element);

    // Combine images and deduplicate
    const allImages = [...selectionImages, ...elementImages];
    // Deduplicate again based on URL
    const uniqueImages = [];
    const seenUrls = new Set();
    for (const img of allImages) {
      if (!seenUrls.has(img.url)) {
        seenUrls.add(img.url);
        uniqueImages.push(img);
      }
    }

    const promptText = CONFIG.GENERATE_PROMPT({
      title: document.title,
      url: location.href,
      selection: selectionText,
      content,
      images: uniqueImages,
    });
    console.log(
      "[Source] Generated prompt from element, length:",
      promptText.length
    );

    GM_setValue("ask_prompt", promptText);
    if (uniqueImages.length > 0) {
      console.log(`[Source] Saving ${uniqueImages.length} images to storage`);
      GM_setValue(
        "ask_images",
        JSON.stringify({
          urls: uniqueImages,
          referrer: location.href,
        })
      );
    }

    const currentProviderKey = GM_getValue("provider", "gemini");
    const provider = CONFIG.PROVIDERS[currentProviderKey];

    const win = window.open(provider.url, "_blank");
    if (!win) {
      console.log("[Source] Failed to open window");
      return;
    }
    console.log(`[Source] ${provider.name} window opened`);
  });
}

let menuIds = [];
// Register menu command to switch provider
function registerMenuCommands() {
  // Unregister existing commands
  for (const id of menuIds) {
    GM_unregisterMenuCommand(id);
  }
  menuIds = [];

  const currentProviderKey = GM_getValue("provider", "gemini");

  Object.entries(CONFIG.PROVIDERS).forEach(([key, config]) => {
    const isCurrent = currentProviderKey === key;
    const title = isCurrent ? `✅ ${config.name}` : `⬜ ${config.name}`;

    const id = GM_registerMenuCommand(title, () => {
      GM_setValue("provider", key);
      registerMenuCommands(); // Re-register to update checkmarks
    });
    menuIds.push(id);
  });
}

(async function () {
  "use strict";

  // Check if we are on a provider page
  const currentUrl = location.href;
  for (const [key, config] of Object.entries(CONFIG.PROVIDERS)) {
    if (currentUrl.startsWith(config.url)) {
      await initProviderPage(config);
      return; // Exit if we are on a provider page
    }
  }

  // Otherwise, we are on a source page
  registerMenuCommands();
  window.addEventListener("keydown", handleShortcut, true);
})();