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)