Vibe Writing

Minyoung Jeong @kkung@hackers.pub

最近、英語でメッセージを送る機会が様々な理由で増えており、英文作成のためにAIの助けを多く借りています。しかし、毎回コピー&ペーストするのが非常に面倒だったので、Hammerspoonを使えばMacのAccessibility APIを簡単に利用できることを思い出し、シンプルなツールを作成しました。また、ほとんどの開発をVibe codingで試みようとしましたが、存在しないAPIを使おうとするため、結局は参考程度にして自分で作りました。((Vibe coderへの道は今日も長く険しい...))

以下のスクリプトを使用すると、テキストを入力して選択し、Cmd+Shift+Kを押すと、選択範囲を維持したまま韓国語⇔英語の翻訳が実行されます。

local config = {
  -- OpenAI API Key
  openai_api_key = "sk-proj--",

  -- I'll use Response API
  openai_api_url = "https://api.openai.com/v1/responses",

  -- Model name
  openai_model = "gpt-4o",
}

local function callOpenAI(text, callback)
  if not config.openai_api_key then
    hs.alert.show("Config error: missing openai_api_key")
    return
  end

  local insturction = "입력된 문장이 영어일 경우 한국어로, 한국어일 경우 영어로 변환해줘. 의미를 모국어 사용자가 자연스럽게 받아들일 수 있게 정확하고 유창하게 전달하고, 불필요한 문장을 생략하여 명료하게 작성해. 번역어 외의 다른 문장을 추가하지 말고 번역 그 자체만 반환해."

  local request = hs.json.encode({
    model = config.openai_model,
    instructions = insturction,
    input = text,
    max_output_tokens = 5000,
  })

  print(request)

  local headers = {
    ["Content-Type"] = "application/json",
    ["Authorization"] = "Bearer " .. config.openai_api_key
  }

  hs.http.asyncPost(config.openai_api_url, request, headers, function(status, response, _)
    if status == 200 then
      local success, data = pcall(hs.json.decode, response)
      if success and data.status == "completed" then
        local translated = data.output[1].content[1].text
        print("TR: " .. translated)
        callback(translated)
      else
        hs.alert.show("Call failed " .. data)
        callback(nil)
      end
    else
      print(status, response)
      hs.alert.show("Call failed " .. status)
      callback(nil)
    end
  end)
end

local function getSelectedTextCB()
  local orig_cb = hs.pasteboard.getContents()
  print("Original Clipboard " .. orig_cb)

  hs.eventtap.keyStroke({"cmd"}, "c")

  local sel = hs.pasteboard.getContents()
  print("Selected Text " .. orig_cb)

  hs.timer.doAfter(0.1, function()
    if orig_cb then
      hs.pasteboard.setContents(orig_cb)
    end
  end)

  return sel
end

local function getFocusedElement()
  local ax = hs.axuielement
  local sys = ax.systemWideElement()
  local focused = sys:attributeValue("AXFocusedUIElement")

  return focused
end

local function getSelectedTextAX()
  local focused = getFocusedElement()

  if not focused then
    hs.alert.show("Could not found focused element")
    return nil
  end

  local selected_text = focused:attributeValue("AXSelectedText")
  if selected_text and selected_text ~= "" then
    return selected_text
  end
end

local function getSelectedText()
  local sel = getSelectedTextAX()
  if sel == nil then
    sel = getSelectedTextCB()
  end

  return sel
end

local function replaceSelectedTextCB(new_text)
  local orig_cb = hs.pasteboard.getContents()
  hs.pasteboard.setContents(new_text)
  hs.eventtap.keyStroke({"cmd"}, "v")
  hs.timer.doAfter(0.1, function()
    if orig_cb then
      hs.pasteboard.setContents(orig_cb)
    end
  end)
end

local function replaceSelectedTextAX(new_text)
  local focused = getFocusedElement()
  local ran = focused:attributeValue("AXSelectedTextRange")
  if ran then
    local val = focused:attributeValue("AXValue")
    local start_pos = utf8.offset(val, ran.location + 1)
    local end_pos = utf8.offset(val, ran.location + ran.length + 1)

    print(hs.inspect(val) .. "start_pos=" .. start_pos .. ", end_pos=" .. end_pos)
    local new_val = string.sub(val, 1, start_pos - 1) ..
                   new_text ..
                   string.sub(val, end_pos, -1)
    print(hs.inspect(new_text) .. ", " .. new_val)

    focused:setAttributeValue("AXValue", new_val)

    hs.timer.doAfter(0.1, function()
      -- adjust cursor position
      local new_ran = {location = ran.location, length = utf8.len(new_text)}
      focused:setAttributeValue("AXSelectedTextRange", new_ran)
    end)

    return true
  else
    return nil
  end
end

local function replaceSelectedText(new_text)
  if replaceSelectedTextAX(new_text) == nil then
    replaceSelectedTextCB(new_text)
  end
end

hs.hotkey.bind({"cmd", "shift"}, "k", function()
  local sel = getSelectedText()
  if sel and sel ~= "" then
    callOpenAI(sel, function(translated)
      replaceSelectedText(translated)
    end)
  end
end)
4

1 comment

If you have a fediverse account, you can comment on this article from your own instance. Search https://hackers.pub/ap/articles/0197b9e6-4a4b-7ac1-8f5b-eef6bebeec7d on your instance and reply to it.

1