使用 Hammerspoon 在任何輸入框中快速進行翻譯

使用 Hammerspoon 在任何輸入框中快速進行翻譯
💡
ti;dr
為了達到「在任何輸入框中進行快速翻譯」的功能:
1. 在 macOS 上安裝 Hammerspoon:可在官網下載或使用 brew。
2. 申請 xAI 的 API key。
3. 複製下方的 Lua 腳本,貼到 ~/.hammerspoon/init.lua 檔案內,將 xAI API key 貼進去,儲存並 reload config。

我一直很喜歡 Chrome 擴充功能「沉浸式翻譯」中的「輸入框增強」功能:快速連按三次空白鍵,就能將輸入框內的文字翻譯成英文。可惜這個功能僅限 Chrome 瀏覽器使用,而且有些輸入框無法支援 (例如網址列)。

前陣子看到 PJ Wu 的文章,使用了 Keyboard Maestro 呼叫 OpenAI API 進行文字翻譯和處理,在電腦的任何地方都可以翻譯。原本就對這類自動化工具非常感興趣,既然有人拋磚引玉,就決定動手試看看。

基本上,文字處理的過程就如同 PJ 文章中所述:選取文字,然後觸發腳本,腳本將文字藉由 API 完成翻譯後,貼回輸入框取代原文字。在這個架構上,我做了一些調整:

改用 Hammerspoon

跟 Keyboard Maestro 一樣,Hammerspoon 也是 macOS 平台上的自動化工具,但它開源免費😂。不同於 Keyboard Maestro 有圖形使用者界面 (GUI),Hammerspoon 所有的自動化功能都必須使用 Lua 程式語言撰寫腳本。對於沒有程式基礎的人來說,可能會有些挑戰。但身處 LLM 時代,這也不完全是個問題了~

話雖如此,一開始使用 GPT-o3-mini-high、Claude 3.7 Sonnet、Grok 3 寫出的腳本都沒能順利執行。最後是透過 Windsurf 編輯器搭配 Claude 3.7 Sonnet (Thinking) 才成功。

在此淚推 Windsurf 這個 AI 輔助的程式碼編輯器,有興趣的人歡迎使用下面的推薦連結註冊,你我都可以多獲得 500 個進階 API call 🙏

Windsurf Editor and Codeium extensions
Codeium is the AI code assistant platform that developers love and enterprises trust. Also the builders of Windsurf, the first agentic IDE.

改用 Cmd + Opt + T 觸發

PJ 的腳本是使用滑鼠右鍵觸發,但我習慣手不用離開鍵盤做事,這部分就看各人喜好囉。

改用 xAI API

之所以使用 xAI API 是因為,只要儲值 5 美金一次,並且同意分享數據給 xAI,每月就可以獲得 150 美金的免費 API 額度,偉哉馬斯克!如果只是翻譯非機密文字的話,我覺得這是個可以好好利用的資源。


Lua 腳本分享

在電腦上的 ~/.hammerspoon 資料夾中找到 init.lua 檔案,由於該資料夾預設是隱藏的,可以使用 cmd + shift + . 顯示隱藏資料夾。複製下方的腳本,將你自己的 xAI API key 貼進去,儲存並 reload config,就可以使用了。

-- Hammerspoon configuration for text translation using xAI API

-- Set up the keyboard shortcut (cmd+opt+T)
local hotkeyModifiers = {"cmd", "alt"}
local hotkeyKey = "t"

-- xAI API configuration
local xAI_API_KEY = "YOUR_API_KEY"
local xAI_API_URL = "https://api.x.ai/v1/chat/completions"

-- Define the translation function
function translateSelectedText()
    -- Get the current selection by doing a copy
    local originalClipboard = hs.pasteboard.getContents()
    hs.eventtap.keyStroke({"cmd"}, "c")
    hs.timer.usleep(200000) -- Wait 0.2 seconds for copy to complete
    local selectedText = hs.pasteboard.getContents()
    
    -- Check if we got any text
    if not selectedText or selectedText == "" or selectedText == originalClipboard then
        hs.alert.show("No text selected")
        return
    end
    
    -- No translating alert as per user request
    
    -- Create request body for xAI API
    local requestBody = {
        model = "grok-beta",
        messages = {
            {
                role = "system",
                content = "You are a translator. Translate the given text to English. Only provide the translation without any explanation or additional text."
            },
            {
                role = "user",
                content = selectedText
            }
        },
        temperature = 0.3
    }
    
    -- Convert request to JSON
    local jsonBody = hs.json.encode(requestBody)
    
    -- Set up headers with API key
    local headers = {
        ["Content-Type"] = "application/json",
        ["Authorization"] = "Bearer " .. xAI_API_KEY
    }
    
    -- Send API request
    hs.http.asyncPost(xAI_API_URL, jsonBody, headers, function(status, body)
        if status == 200 then
            -- Try to parse the response
            local success, response = pcall(function() return hs.json.decode(body) end)
            
            if success and response and response.choices and 
               response.choices[1] and response.choices[1].message then
                -- Get the translation
                local translation = response.choices[1].message.content
                
                -- Replace the selected text with translation
                hs.pasteboard.setContents(translation)
                hs.eventtap.keyStroke({"cmd"}, "v")
                
                -- No success alert as requested
            else
                -- Show error if we couldn't parse response
                hs.alert.show("Error parsing API response")
            end
        else
            -- Show error if API request failed
            hs.alert.show("API Error: " .. status)
        end
    end)
end

-- Register the hotkey
hs.hotkey.bind(hotkeyModifiers, hotkeyKey, translateSelectedText)

-- Show confirmation that the module loaded
hs.alert.show("Translation module loaded - press cmd+opt+t to translate")

加碼 AutoHotKey 腳本

#Requires AutoHotkey v2.0
#SingleInstance Force
SendMode "Input"
SetWorkingDir A_ScriptDir

; 定義熱鍵:Win+Alt+T
#!t::
{
  OldClipboard := ClipboardAll()
  A_Clipboard := ""
  Send "^c"
  if !ClipWait(0.5, 1)
  {
    MsgBox "未檢測到選中的文本"
    A_Clipboard := OldClipboard
    return
  }
  
  ; 獲取選中的中文文本
  textToTranslate := A_Clipboard
  
  ; 呼叫xAI API進行翻譯
  translatedText := TranslateWithxAI(textToTranslate)
  
  ; 將翻譯結果貼回取代原文
  A_Clipboard := translatedText
  Send "^v"
  
  ; 恢復原始剪貼簿內容
  Sleep 200
  A_Clipboard := OldClipboard
}

; xAI API 翻譯功能
TranslateWithxAI(text) {
  ; 替換以下變數為您的xAI API金鑰
  apiKey := "YOUR_API_KEY"
  apiUrl := "https://api.x.ai/v1/chat/completions"
  
  ; 準備API請求
  oHttp := ComObject("WinHttp.WinHttpRequest.5.1")
  oHttp.Open("POST", apiUrl, false)
  oHttp.SetRequestHeader("Content-Type", "application/json")
  oHttp.SetRequestHeader("Authorization", "Bearer " . apiKey)
  
  ; 準備JSON請求體
  payload := '{"model":"grok-beta","messages":[{"role":"system","content":"You are a professional translator. Translate the following Chinese text to English. Respond with only the translated text, no explanations or additional content."},{"role":"user","content":"' . EscapeJSON(text) . '"}],"temperature":0.3}'
  
  ; 發送請求
  try {
    oHttp.Send(payload)
    response := oHttp.ResponseText
    
    ; 解析JSON響應以獲取翻譯結果
    ; 假設回應格式類似 {"choices":[{"message":{"content":"翻譯結果"}}]}
    if (RegExMatch(response, '"content"\s*:\s*"(.*?)"', &match)) {
      return match[1]
    } else {
      MsgBox "API回應格式不符: " response
      return text  ; 返回原文
    }
  } catch as e {
    MsgBox "API請求失敗: " e.Message
    return text
  }
}

; 輔助函數:轉義JSON字串中的特殊字符
EscapeJSON(str) {
  str := StrReplace(str, "\", "\\")
  str := StrReplace(str, '"', '\"')
  str := StrReplace(str, "`n", "\n")
  str := StrReplace(str, "`r", "\r")
  str := StrReplace(str, "`t", "\t")
  return str
}