String Translator — translates the selected text via Cloudflare Workers AI and copies the result.
  • JavaScript 53.5%
  • HTML 42.1%
  • Python 4.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Rodrigo de Avila 0e4c70b84d
site: add privacy policy page
The Chrome Web Store requires a privacy policy for extensions that send
page content and hold authentication tokens. The new page states what is
stored locally, what is sent to Cloudflare, and that nothing is
collected by the developer. The landing page links to it from the footer
and now points setup at the options page, where the credentials moved.

Also ignore the .wrangler directory left by Pages deploys.
2026-09-23 20:41:02 -03:00
.serena site, serena: add chrome web store link and serena project config 2026-09-18 08:44:23 -03:00
_locales popup, options: move Cloudflare credentials to the options page 2026-09-23 20:34:04 -03:00
icons treewide: add translation status feedback and refresh the UI 2026-09-16 21:18:25 -03:00
site site: add privacy policy page 2026-09-23 20:41:02 -03:00
tools treewide: add translation status feedback and refresh the UI 2026-09-16 21:18:25 -03:00
.gitignore site: add privacy policy page 2026-09-23 20:41:02 -03:00
background.js treewide: add translation status feedback and refresh the UI 2026-09-16 21:18:25 -03:00
CLAUDE.md treewide: add translation status feedback and refresh the UI 2026-09-16 21:18:25 -03:00
clipboard.js first commit 2026-08-14 16:48:52 -03:00
i18n.js first commit 2026-08-14 16:48:52 -03:00
manifest.json popup, options: move Cloudflare credentials to the options page 2026-09-23 20:34:04 -03:00
options.html popup, options: move Cloudflare credentials to the options page 2026-09-23 20:34:04 -03:00
options.js popup, options: move Cloudflare credentials to the options page 2026-09-23 20:34:04 -03:00
popup.html popup, options: move Cloudflare credentials to the options page 2026-09-23 20:34:04 -03:00
popup.js popup, options: move Cloudflare credentials to the options page 2026-09-23 20:34:04 -03:00
README.md popup, options: move Cloudflare credentials to the options page 2026-09-23 20:34:04 -03:00
test-clipboard.mjs first commit 2026-08-14 16:48:52 -03:00
test.mjs treewide: add translation status feedback and refresh the UI 2026-09-16 21:18:25 -03:00
translate.js treewide: add translation status feedback and refresh the UI 2026-09-16 21:18:25 -03:00

S15 — String Translator

Extensão do Chrome que traduz o texto selecionado usando um modelo de linguagem da Cloudflare Workers AI e coloca o resultado na área de transferência.

Selecione um texto → Ctrl+Shift+Y (ou botão direito → Traduzir com S15) → notificação de conclusão → Ctrl+V onde quiser.

A extensão nunca altera a página: o resultado vai sempre para a área de transferência.

Credenciais da Cloudflare

A API precisa de duas coisas, preenchidas na tela de opções (botão Editar… no popup, ou botão direito no ícone → Opções; enquanto faltar alguma, o popup avisa no topo):

Ambos ficam em chrome.storage.local, ou seja, só neste navegador — não são sincronizados com a conta Google.

Para conferir as credenciais fora do Chrome:

curl -s https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/ai/run/@cf/google/gemma-4-26b-a4b-it \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"messages":[{"role":"system","content":"Translate into Brazilian Portuguese. Output only the translation."},{"role":"user","content":"hello world"}],"max_tokens":4096}'

Esperado: {"result":{"response":"olá mundo",...},"success":true,...}

Trocar o @cf/... da URL é a forma mais rápida de comparar modelos antes de escolher um no popup.

Instalação

  1. python3 tools/make_icons.py (os PNGs já vêm gerados; só é preciso se você mudar o ícone)
  2. Abra chrome://extensions, ative o Modo do desenvolvedor
  3. Carregar sem compactação → selecione esta pasta
  4. Abra as opções da extensão (botão direito no ícone → Opções) e preencha Account ID e API Token

Modelo

A tradução é feita por um modelo de linguagem de propósito geral com um prompt de tradução, e não por um modelo dedicado de tradução automática (o m2m100-1.2b, usado na primeira versão, entregava qualidade ruim em pt-BR). Escolha no popup:

Modelo ID Entrada / Saída por 1M tokens Nota
Gemma 4 26B (padrão) @cf/google/gemma-4-26b-a4b-it $0,10 / $0,30 linhagem do Gemini, 256k de contexto
GLM 4.7 Flash @cf/zai-org/glm-4.7-flash $0,06 / $0,40 100+ idiomas, entrada mais barata
Llama 3.3 70B @cf/meta/llama-3.3-70b-instruct-fp8-fast $0,29 / $2,25 o maior da lista
Mistral Small 3.1 24B @cf/mistralai/mistral-small-3.1-24b-instruct $0,35 / $0,56 idiomas europeus

Esta tabela é a comparação entre eles: no popup, o rótulo de cada modelo é só nome próprio (Gemma 4 26B — Google), justamente para não precisar de tradução.

O padrão é o Gemma 4 porque é da linhagem do Gemini (a Cloudflare o descreve como "built from Gemini 3 research"), custa pouco e tem 256k de contexto. Troque no popup e compare com os seus próprios textos — qualidade de tradução varia por domínio e nenhum benchmark substitui um teste no material real. A Workers AI dá 10.000 neurons por dia sem cobrança, o que cobre uso pessoal com folga.

Para acrescentar outro modelo do catálogo, adicione uma entrada em MODELS no translate.js. O ID precisa incluir o fornecedor (@cf/google/…), não só o nome do modelo como aparece no índice da documentação.

Prompt

buildBody() monta a requisição no formato de chat. O texto selecionado vai isolado na mensagem user, e a mensagem system instrui explicitamente a nunca seguir instruções contidas nele — texto copiado de uma página qualquer pode conter algo que pareça uma ordem para o modelo.

Ao contrário do m2m100, que exigia códigos ISO de duas letras, o prompt usa o nome do idioma por extenso (Brazilian Portuguese), que é o campo name de cada entrada em LANGS. Modelos de linguagem acertam mais assim do que com códigos. O botão ⇄ inverte origem e destino.

Idioma da interface

Por padrão a extensão segue o idioma do navegador; sem tradução para ele, cai no inglês. Para forçar outro idioma, use o seletor no topo da tela de opções (botão Editar… no popup, ou botão direito no ícone → Opções).

A interface existe em todos os idiomas para os quais a extensão traduz: inglês, português (Brasil e Portugal), espanhol, francês, alemão, italiano, holandês, russo, polonês, turco, árabe, hebraico, híndi, japonês, coreano, chinês simplificado, sueco, ucraniano e indonésio. Em árabe e hebraico a tela inverte para RTL sozinha.

Duas diferenças entre essa lista e a LANGS, impostas pelo catálogo de locales do Chrome: es-ES é es, e en cobre en-US e en-GB (que não diferem em nada aqui).

As traduções ficam em _locales/<dir>/messages.json, no formato padrão de extensão do Chrome — inclusive a descrição da extensão e a do atalho, que o manifest referencia como __MSG_extDescription__ e __MSG_cmdDescription__. Repare no sublinhado do diretório: pt_BR, não pt-BR.

Acrescentar um idioma são dois passos: criar _locales/<dir>/messages.json com as mesmas chaves de en e incluir o código em LOCALES, no i18n.js. node test.mjs falha se faltar chave, e uma chave sem tradução cai no inglês em vez de sumir da tela.

Os nomes de idioma nas listas não estão nesses arquivos: vêm do Intl.DisplayNames, que já traduz "American English" para "Inglês (Estados Unidos)" sozinho.

A extensão lê o messages.json com fetch, e não com chrome.i18n.getMessage(): a API nativa resolve pelo idioma do Chrome e não aceita troca em tempo de execução, o que deixaria o seletor acima sem efeito. Por isso os placeholders são {n}, {reason} (substituídos por translator()), e não o $nome$ do getMessage().

O relato de falha da cópia (focused=, secure=) e os logs do service worker ficam fora disso de propósito: são diagnóstico, saem concatenados com mensagens cruas do navegador.

Glossário

Termos que precisam sair sempre traduzidos da mesma forma ficam numa lista própria, na tela de opções da extensão (botão Editar… no popup, ou botão direito no ícone → Opções). Uma linha por termo:

issue = chamado
deploy = publicação
# linhas começando com # são comentários
release notes = notas da versão

O = divide na primeira ocorrência, então a tradução pode conter =. Linhas sem =, sem origem ou sem tradução são ignoradas — entrariam vazias no prompt. A lista é salva sozinha meio segundo depois da última tecla; não há botão salvar.

A tela mora nas opções, e não no popup, porque a lista pode ter centenas de linhas — no popup só fica a contagem (Seguindo N termos do glossário), que é a confirmação de que a tradução está usando o padrão.

Em cada tradução, apenas os termos que aparecem no texto selecionado entram na mensagem system; um glossário longo inteiro em toda requisição custaria tokens e afogaria as instruções de tradução. A comparação é por substring, sem exigir limite de palavra: com limite, o glossário ficaria mudo em japonês e chinês, onde não há espaços entre palavras. O preço é um eventual termo a mais no prompt — inofensivo perto de um termo a menos.

Atalho

O padrão é Ctrl+Shift+Y, alterável em chrome://extensions/shortcuts. A tela de opções mostra o atalho atual (lido de chrome.commands.getAll(), nunca hardcoded) e tem um botão que abre essa página do Chrome: a extensão não tem como capturar ou gravar teclas por conta própria, só apontar para onde isso se faz.

Não é Ctrl+\ porque o Chrome só aceita letras, números e um conjunto fechado de teclas nomeadas (Comma, Period, Space, Home, End…) em atalhos de extensão; barra invertida não está nessa lista. A alternativa seria injetar um listener de teclado em todas as páginas, o que exigiria a permissão <all_urls>.

Testes

node test.mjs && node test-clipboard.mjs   # cada um imprime "ok"

test-clipboard.mjs roda a cópia (clipboard.js) contra um DOM falso de ~25 linhas — sem jsdom, só os métodos que a função toca. Existe porque essa cópia já falhou em uso real sem dizer o motivo; o teste trava o relato de erro.

Cobre o formato da requisição, o parse da resposta e a limpeza da saída. Casos que o teste trava de propósito: resposta de erro da API (colocaria undefined no clipboard), resposta no formato antigo translated_text (denunciaria um ID de modelo errado), e aspas internas sendo confundidas com aspas envolvendo o texto todo.

Diagnóstico

O ícone da extensão mostra um selo com o estado da última tradução, e o texto completo aparece ao passar o mouse: os segundos decorridos em cinza (12s, em andamento), ✓ verde (copiada), ! vermelho (falhou). Ele existe porque a notificação pode ser suprimida pelo sistema — modo "não perturbe", notificações do Chrome desativadas — e nesse caso a tradução parecia simplesmente não acontecer. O selo não depende do sistema operacional.

O contador é o que separa "demorando" de "travou": com prazo de 45s por tentativa e até três tentativas, uma tradução legítima pode passar de dois minutos. Se o número continua subindo, ainda está esperando a Cloudflare; se ele congela, o service worker foi encerrado no meio da chamada.

O popup mostra, embaixo, o estado da última chamada — ✓ Traduzido em 8s · há 2 minutos, a mensagem de erro completa, ou "A última tradução foi interrompida — acione de novo". Essa última linha é o caso do selo congelado: o Chrome desligou o service worker antes de a resposta chegar, e nenhum aviso ia chegar nunca. É a única situação em que acionar de novo é a resposta certa; nas outras a extensão já fez o que dava. Enquanto uma tradução corre, a linha conta os segundos junto com o selo.

Enquanto o selo está contando, um novo acionamento é ignorado em vez de disparar uma segunda requisição — só cabe um resultado na área de transferência, e duas traduções em paralelo terminariam em ordem indefinida. O acionamento descartado fica registrado no log do service worker.

Os logs do service worker não aparecem no console da página. Abra chrome://extensions → S15 → link "service worker". Cada tradução registra ali o modelo, o status HTTP e o corpo completo da resposta ([S15] …).

Se a notificação disser "Resposta inesperada da Cloudflare", ela vem acompanhada de um recorte do JSON recebido — a resposta chegou, mas em um formato que parseTranslation() não reconhece. Para ver o formato inteiro, chame o modelo direto:

curl -s https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/ai/run/@cf/google/gemma-4-26b-a4b-it \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"messages":[{"role":"user","content":"translate to portuguese: hello"}],"max_tokens":256}' | jq

Erros transitórios da Workers AI

Capacity temporarily exceeded e Internal server error são sobrecarga momentânea do lado da Cloudflare, não problema de configuração: a mesma requisição costuma passar segundos depois. A extensão repete sozinha até duas vezes (esperas de 1,5s e 4s) antes de desistir, e o título do ícone mostra tentando de novo (2ª)… enquanto isso. Se a mensagem de erro terminar em (3 tentativas), as três falharam — aí é esperar um pouco ou trocar de modelo no popup, porque a capacidade é por modelo.

Quem decide o que repetir é isTransient() em translate.js: HTTP 429 e 5xx, mais as mensagens de capacidade. Token inválido e ID de modelo errado ficam fora de propósito — repetir esses só faz esperar mais pelo mesmo erro.

Causas comuns, na ordem: ID de modelo incorreto ou indisponível na conta (errors com código 7000/5007), token sem a permissão Workers AI, ou um campo de saída diferente dos reconhecidos. Achou um campo novo? Acrescente na lista de candidatos dentro de parseTranslation(), em translate.js.

O Gemma 4 devolve result.choices[0].message.content ("object": "chat.completion"), não result.response — parseTranslation() aceita os dois porque os modelos do catálogo não são consistentes entre si.

Se a falha for "Não foi possível copiar", a mensagem traz o motivo e o estado da página (focused=, secure=). secure=false significa página http:, onde a Clipboard API não existe e a cópia depende do document.execCommand, que é obsoleto.

Limitações

  • Não funciona em chrome://, na Chrome Web Store, no visualizador de PDF nem em páginas de outras extensões: o Chrome bloqueia injeção de script nesses lugares, para qualquer extensão.
  • Seleção dentro de um iframe de outra origem não é lida pelo atalho: activeTab não concede acesso a esses frames. Use o menu de contexto nesse caso — ele recebe o texto direto do Chrome, sem depender de injeção.
  • A resposta é limitada a max_tokens: 4096; uma seleção muito longa sai truncada. O default da Cloudflare seria 256, o que cortaria até parágrafos curtos.
  • Sendo um modelo de linguagem e não um tradutor dedicado, a saída pode eventualmente vir com um comentário do modelo apesar do prompt. cleanOutput() remove blocos <think> e aspas envolventes, mas não é um filtro completo.
  • Em páginas http: a Clipboard API não está disponível (contexto não seguro) e a cópia cai para document.execCommand('copy').
  • A extensão não escreve na página. Houve uma tentativa de substituir a seleção no lugar quando ela estava num campo de texto (v1.2.0–1.2.1), removida na v1.3.0 por não funcionar de forma confiável: document.execCommand('insertText') chegava a devolver true sem alterar o campo, e nem setRangeText como reserva resolveu.

Permissões e por quê

Permissão Motivo
activeTab + scripting ler a seleção e escrever na área de transferência, só na aba onde você acionou a extensão
contextMenus item "Traduzir com S15" no botão direito
notifications avisar quando a tradução termina ou falha
storage guardar credenciais, idiomas, o modelo escolhido, o glossário e o idioma da interface
clipboardWrite copiar sem exigir ativação transitória — o gesto do usuário já expirou quando a tradução volta da API
https://api.cloudflare.com/* chamar a API de tradução

Não há content_scripts nem <all_urls>: o acesso à página é concedido pelo próprio gesto (atalho ou menu de contexto) e expira quando você sai dela.