tee logo
tee Actions

Supercharge your workflow

Discover and install custom actions for the tee extension. Or create your own and share it with the world.

Translate
Translate selected text to another language
const targetLang = await input.text(
  "Translate to which language?",
);

if (!targetLang || targetLang.length == 0) return;

const BATCH_SIZE = 20;
const POLL_INTERVAL = 1500;
const DEFAULT_RETRY_AFTER_MS = 60000;

let rateLimited = false;
let rateLimitPopupShown = false;
let isProcessing = false;

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function chunk(arr, size) {
  const result = [];
  for (let i = 0; i < arr.length; i += size) {
    result.push(arr.slice(i, i + size));
  }
  return result;
}

function isRateLimitError(err) {
  const message = String(err?.message || err || "").toLowerCase();
  return (
    message.includes("429") ||
    message.includes("rate limit") ||
    message.includes("too many requests")
  );
}

function getRetryAfterMs(err) {
  const message = String(err?.message || err || "");
  const headerMatch = message.match(/retry-after[:=]\s*(\d+)/i);
  if (headerMatch) return Number(headerMatch[1]) * 1000;
  const bodyMatch = message.match(/retry[_-]?after["']?\s*[:=]\s*(\d+)/i);
  if (bodyMatch) return Number(bodyMatch[1]) * 1000;
  return null;
}

async function enterRateLimitMode(waitMs) {
  if (rateLimited) return;
  rateLimited = true;
  spinner.hide();
  const seconds = Math.ceil(waitMs / 1000);
  if (!rateLimitPopupShown) {
    rateLimitPopupShown = true;
    popup.error(`Model rate limit reached. Paused for ${seconds} seconds.`);
  }
  console.warn(`Rate limited. Waiting ${seconds}s`);
  await sleep(waitMs);
  rateLimited = false;
  rateLimitPopupShown = false;
  popup.success("Translation resumed.");
}

/**
 * ==========================================================
 * DETECT CAPTCHA / CLOUDFLARE PAGES
 * ==========================================================
 */
const pageTitle = (await page.getTitle() || "").toLowerCase();
const pageURL   = (await page.getURL()   || "").toLowerCase();

const BLOCKED_KEYWORDS = [
  "captcha", "cloudflare", "verification", "security check", "are you human",
];

if (BLOCKED_KEYWORDS.some(kw => pageTitle.includes(kw) || pageURL.includes(kw))) {
  popup.error("Translation disabled on verification pages.");
  return;
}

/**
 * ==========================================================
 * CODE HEURISTICS
 * Filter nodes that are unambiguously code before sending to
 * the AI — reduces token usage and avoids mistranslations.
 * ==========================================================
 */
function looksLikeCode(s) {
  if (/^https?:\/\/\S+$/.test(s))                     return true; // URL
  if (/^([a-z0-9-]+\.)+[a-z]{2,}(\/\S*)?$/i.test(s)) return true; // bare domain
  if (/^\.{0,2}[/\\][^\s]{3,}$/.test(s))              return true; // file path
  if (/^[$#]\s+\S/.test(s))                            return true; // shell prompt
  if (/^#!\//.test(s))                                 return true; // shebang
  if (/^#[0-9a-fA-F]{3,8}$/.test(s))                  return true; // hex colour
  if (/^v?\d+\.\d+(\.\d+)?(-[\w.]+)?$/.test(s))       return true; // semver
  if (/^import\s+[{*]/.test(s))                        return true; // import stmt
  if (/^(require|from)\s*['"`(]/.test(s))              return true; // require/from
  return false;
}

function shouldSkipText(text) {
  if (!text) return true;
  const t = text.trim();
  if (t.length < 2)     return true;
  if (looksLikeCode(t)) return true;
  return false;
}

/**
 * ==========================================================
 * TRANSLATE BATCH
 * ==========================================================
 */
async function translateBatch(batch) {
  const payload = batch
    .map(item => `NODE_ID:${item.id}\nTEXT:\n${item.text}\nEND_NODE`)
    .join("\n\n");

  try {
    const response = await ai.chat([
      {
        role: "system",
        content: `You are a professional translator. Translate all text into ${targetLang}.

═══════════════════════════════════════
WHAT YOU MUST NEVER TRANSLATE
═══════════════════════════════════════

Leave the following COMPLETELY UNCHANGED — return them word-for-word:

• URLs and domain names          e.g. https://example.com  github.com
• File paths                     e.g. /usr/bin/node  ./src/index.js
• Shell / terminal commands      e.g. npm install react  git commit -m "fix"
• Package names                  e.g. react  lodash  numpy  @types/node
• Import / require statements    e.g. import React from 'react'
• Function / class / variable names  e.g. handleClick  UserService  MAX_RETRIES
• Code keywords                  e.g. const  async  null  undefined
• Version strings                e.g. v1.2.3  2.0.0-beta
• Hex colours                    e.g. #ff0000  #1a2b3c
• Operators / symbols            e.g. =>  &&  !==
• Any text that is clearly source code

If a node is entirely untranslatable (pure code, symbol, punctuation), set TYPE:skip and return the text unchanged.

═══════════════════════════════════════
RETURN FORMAT — no deviations
═══════════════════════════════════════

NODE_ID:<id>
TYPE:<prose|skip>
TEXT:
<translated text, or original if skip>
END_NODE

Rules:
1. Output EVERY input node — never omit one.
2. Never merge or split nodes.
3. Never modify NODE_ID values.
4. No markdown, no commentary, nothing outside the blocks.
5. Preserve leading/trailing whitespace and punctuation exactly.`,
      },
      {
        role: "user",
        content: payload,
      },
    ]);

    const regex = /NODE_ID:(.*?)\n(?:TYPE:(.*?)\n)?TEXT:\n([\s\S]*?)\nEND_NODE/g;
    const updates = [];
    let match;
    while ((match = regex.exec(response)) !== null) {
      const id   = match[1].trim();
      const type = (match[2] || "prose").trim().toLowerCase();
      const text = match[3].trim();
      updates.push({ id, text, skip: type === "skip" });
    }

    return updates;
  } catch (err) {
    if (isRateLimitError(err)) {
      const waitMs = getRetryAfterMs(err) ?? DEFAULT_RETRY_AFTER_MS;
      await enterRateLimitMode(waitMs);
      return [];
    }
    throw err;
  }
}

/**
 * ==========================================================
 * PROCESS CURRENT VIEWPORT
 * ==========================================================
 */
async function processViewport() {
  if (isProcessing) return;
  if (rateLimited)  return;

  isProcessing = true;

  try {
    const allNodes = await dom.getVisibleTextNodes();
    if (!allNodes || !allNodes.length) return;

    // Filter out code-like text nodes before sending to AI.
    // dom.getVisibleTextNodes() already skips <code>/<pre>/etc,
    // but looksLikeCode catches inline code-like values the DOM
    // structure alone can't detect (semver, hex, shell prompts…).
    const nodes = allNodes.filter(node => !shouldSkipText(node.text));

    if (!nodes.length) return;

    const batches = chunk(nodes, BATCH_SIZE);
    spinner.show(`Translating ${nodes.length} text blocks...`);

    for (let i = 0; i < batches.length; i++) {
      if (rateLimited) break;
      spinner.update(`Translating batch ${i + 1}/${batches.length}`);
      try {
        const updates = await translateBatch(batches[i]);
        if (updates && updates.length) {
          // dom.patchTextNodes expects { id, text } pairs.
          // Filter out TYPE:skip nodes — their text is unchanged
          // so there is nothing to write back.
          const patches = updates
            .filter(u => !u.skip)
            .map(u => ({ id: u.id, text: u.text }));

          if (patches.length) {
            await dom.patchTextNodes(patches);
          }
        }
      } catch (err) {
        console.error(err);
      }
    }

    spinner.hide();
  } finally {
    isProcessing = false;
  }
}

/**
 * ==========================================================
 * INITIAL PASS
 * ==========================================================
 */
await processViewport();

popup.success(`Lazy translation enabled (${targetLang})`);

/**
 * ==========================================================
 * SCROLL-BASED LAZY TRANSLATION
 * ==========================================================
 */
setInterval(async () => {
  try {
    await processViewport();
  } catch (err) {
    console.error(err);
  }
}, POLL_INTERVAL);
Page Chatbot
A floating AI chatbot that can answer questions about the current page.
await popup.show("Reading page context...");
const pageText = await page.getText();
const contextText = pageText.substring(0, 10000); 
const instanceId = Date.now();
let chatHistory = [ { role: "system", content: "You are a helpful assistant. Use the following page text to answer the user's questions:\n\n" + contextText } ];
let messageCount = 0;

const css = 
"@keyframes tfSpringUp { 0% { opacity: 0; transform: translateY(10px) scale(0.98); } 100% { transform: translateY(0) scale(1); opacity: 1; } }" +
"@keyframes tfPopIn { 0% { opacity: 0; transform: scale(0.95) translateY(4px); } 100% { transform: scale(1) translateY(0); opacity: 1; } }" +
"#tf-chat-container-" + instanceId + " { position: fixed; bottom: 24px; right: 24px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }" +
"#tf-chat-bubble-" + instanceId + " { display: flex; align-items: center; justify-content: center; width: 60px; height: 60px; background: #C8FF00; border-radius: 50%; box-shadow: 0 4px 12px rgba(0,0,0,0.15); cursor: pointer; font-size: 24px; transition: transform 0.2s; animation: tfPopIn 0.3s forwards; }" +
"#tf-chat-bubble-" + instanceId + ":hover { transform: scale(1.05); }" +
"#tf-chat-window-" + instanceId + " { display: none; width: 360px; height: 520px; background: #121212; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); border: 1px solid #2a2a2a; flex-direction: column; overflow: hidden; transform-origin: bottom right; }" +
".tf-chat-open #tf-chat-window-" + instanceId + " { display: flex; animation: tfSpringUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; }" +
".tf-chat-open #tf-chat-bubble-" + instanceId + " { display: none; }" +
"#tf-chat-header-" + instanceId + " { background: #1a1a1a; color: #fff; padding: 16px 20px; font-weight: 600; font-size: 15px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #2a2a2a; }" +
"#tf-chat-close-" + instanceId + " { cursor: pointer; color: #888; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: all 0.15s; }" +
"#tf-chat-close-" + instanceId + ":hover { color: #fff; background: #2a2a2a; }" +
"#tf-chat-messages-" + instanceId + " { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column-reverse; gap: 12px; scroll-behavior: smooth; }" +
".tf-msg-" + instanceId + " { padding: 12px 16px; border-radius: 12px; max-width: 85%; font-size: 14px; line-height: 1.5; color: #fff; white-space: pre-wrap; animation: tfPopIn 0.2s forwards; }" +
".tf-msg-user-" + instanceId + " { background: #2a2a2a; align-self: flex-end; border-bottom-right-radius: 4px; border: 1px solid #333; }" +
".tf-msg-ai-" + instanceId + " { background: #C8FF00; color: #000; font-weight: 500; align-self: flex-start; border-bottom-left-radius: 4px; }" +
"#tf-chat-input-area-" + instanceId + " { display: flex; padding: 14px; background: #1a1a1a; border-top: 1px solid #2a2a2a; gap: 10px; align-items: flex-end; }" +
"#tf-chat-input-" + instanceId + " { flex: 1; background: #121212; border: 1px solid #333; color: #fff; padding: 12px 14px; border-radius: 8px; outline: none; min-height: 20px; max-height: 120px; overflow-y: auto; font-size: 14px; transition: border-color 0.15s; }" +
"#tf-chat-input-" + instanceId + ":focus { border-color: #C8FF00; }" +
"#tf-chat-send-" + instanceId + " { background: #C8FF00; color: #000; width: 40px; height: 40px; border-radius: 8px; font-weight: bold; cursor: pointer; display: flex; align-items: center; justify-content: center; user-select: none; transition: background 0.15s; flex-shrink: 0; }" +
"#tf-chat-send-" + instanceId + ":hover { background: #b3e600; }" +
"[contenteditable]:empty::before { content: 'Ask about this page...'; color: #666; pointer-events: none; }";
await dom.injectCSS(css);

const inputAreaHtml = 
'<div id="tf-chat-input-' + instanceId + '" contenteditable="true"></div>' +
'<div id="tf-chat-send-' + instanceId + '">➤</div>';

const html = 
'<div id="tf-chat-container-' + instanceId + '">' +
'  <div id="tf-chat-bubble-' + instanceId + '"><svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#121212" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg></div>' +
'  <div id="tf-chat-window-' + instanceId + '">' +
'    <div id="tf-chat-header-' + instanceId + '">tee Assistant<div id="tf-chat-close-' + instanceId + '">✖</div></div>' +
'    <div id="tf-chat-messages-' + instanceId + '"><div class="tf-msg-' + instanceId + ' tf-msg-ai-' + instanceId + '">Hello! I\'ve read this page. What would you like to know?</div></div>' +
'    <div id="tf-chat-input-area-' + instanceId + '">' +
'       ' + inputAreaHtml +
'    </div>' +
'  </div>' +
'</div>';
await dom.append("body", html);

const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
await popup.success("Chatbot is ready!");

let isOpen = false;
while (true) {
    if (!isOpen) {
        await dom.waitForEvent("#tf-chat-bubble-" + instanceId, "click");
        isOpen = true;
        await dom.addClass("#tf-chat-container-" + instanceId, "tf-chat-open");
    }

    const sendP = dom.waitForEvent("#tf-chat-send-" + instanceId, "click").then(r => r ? { source: 'send', ...r } : null);
    const enterP = dom.waitForEvent("#tf-chat-input-" + instanceId, "keydown").then(r => r ? { source: 'input', ...r } : null);
    const closeP = dom.waitForEvent("#tf-chat-close-" + instanceId, "click").then(r => r ? { source: 'close', ...r } : null);
    
    const eventRes = await Promise.race([sendP, enterP, closeP]);
    
    if (!eventRes) {
        const uiExists = await dom.waitForSelector("#tf-chat-container-" + instanceId, 100);
        if (!uiExists) break;
        continue;
    }
    
    if (eventRes.source === 'close') {
        isOpen = false;
        await dom.removeClass("#tf-chat-container-" + instanceId, "tf-chat-open");
        continue;
    }
    
    if (eventRes.source === 'input' && (eventRes.key !== "Enter" || eventRes.shiftKey)) {
        continue;
    }

    const extracted = await dom.extractText("#tf-chat-input-" + instanceId);
    const userMessage = (extracted[0] || "").trim();
    
    await dom.replaceHtml("#tf-chat-input-area-" + instanceId, inputAreaHtml);
    
    if (!userMessage) continue;
    
    const userDiv = '<div class="tf-msg-' + instanceId + ' tf-msg-user-' + instanceId + '">' + escapeHtml(userMessage) + '</div>';
    await dom.prepend("#tf-chat-messages-" + instanceId, userDiv);
    
    chatHistory.push({ role: "user", content: userMessage });
    
    const typingId = "tf-typing-" + Date.now();
    const typingDiv = '<div id="' + typingId + '" class="tf-msg-' + instanceId + ' tf-msg-ai-' + instanceId + '">Thinking...</div>';
    
    await dom.prepend("#tf-chat-messages-" + instanceId, typingDiv);
    
    if (messageCount >= 2) {
        await dom.remove("#" + typingId);
        const pitchDiv = '<div class="tf-msg-' + instanceId + ' tf-msg-ai-' + instanceId + '">Liked it? You can make it your own! Upgrade to tee Premium to unlock unlimited messages, custom UI, and the ability to edit this exact script.</div>';
        await dom.prepend("#tf-chat-messages-" + instanceId, pitchDiv);
        continue;
    }

    try {
        const aiResponse = await ai.chat(chatHistory);
        messageCount++;
        await dom.remove("#" + typingId);
        const aiDiv = '<div class="tf-msg-' + instanceId + ' tf-msg-ai-' + instanceId + '">' + escapeHtml(aiResponse) + '</div>';
        await dom.prepend("#tf-chat-messages-" + instanceId, aiDiv);
        chatHistory.push({ role: "assistant", content: aiResponse });
    } catch (err) {
        await dom.remove("#" + typingId);
        const errDiv = '<div class="tf-msg-' + instanceId + ' tf-msg-ai-' + instanceId + '" style="background:#ff4444;color:#fff;">Error: ' + err.message + '</div>';
        await dom.prepend("#tf-chat-messages-" + instanceId, errDiv);
    }
}