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

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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);
})();