Matrix 首页推荐 

Matrix 是少数派的写作社区,我们主张分享真实的产品体验,有实用价值的经验与思考。我们会不定期挑选 Matrix 最优质的文章,展示来自用户的最真实的体验和观点。 
文章代表作者个人观点,少数派仅对标题和排版略作修改。


我是一个 Mac 用户,因为工作原因,不得不在办公时使用一台性能堪忧的 Windows 笔记本电脑。

在此前的一篇文章里,我介绍了自己如何利用 PowerToys 和 AutoHotKey(下文简称 AHK) 等工具来改善我的 Windows 使用体验,提高办公效率。当时我提到自己在 Mac 上使用的翻译软件 Bob,主要功能是划词(句)翻译和截图识别文字,除了自带服务外,还可以接入各大服务商,借助各种模型完成翻译;而在 Windows 上,翻译功能我使用 PowerToys 自带的 PowerToys Run 加上有道翻译插件,配合 AHK 热键,实现复制选中内容并立即唤出 PowerToys Run 翻译。识别文字则直接使用的 PowerToys 自带的「文本提取器」工具。

很显然,上述替代方案不够好用,一是响应速度一般,二是翻译和识别的质量不好,特别是对于较长的文本和扫描件。

于是,我首先找到了 Windows 上各方面都和 Bob 挺相似的工具 STranslate,STranslate 说实话已经基本可以满足我的需求,但我还是产生了想要折腾一下的念头——当然这也不是全无原因的,一来可能是 STranslate 的用户相对较少,第三方插件不如 Bob 多,虽然 STranslate 已经提供了相当多的服务商选择,但我目前使用的 API 服务商还未在列表中;二来,要么是我的办公电脑性能太差、要么是办公网的限制,现有服务响应速度并不是特别快,这导致我往往得盯着弹出的窗口等待半天;三来,可能还是因为电脑不行,有时会出现热键失效,或者划词(句)翻译无法正确翻译被选中语句的情况,重启软件后才恢复正常。

本着能轻便就轻便,能少开一个进程就少开一个的原则,我打开了一直在后台默默工作的 AHK 脚本。我的目标是模仿 Bob 和 STranslate,利用 AHK 脚本和大模型 API,实现翻译选中文字,以及截图识别文字的功能。

在实现的过程中,我借助了 DeepSeek 和 Gemini 等 AI 来起草代码,效果相当不错,其中翻译功能的核心部分基本上可以拿 AI 给出的示例直接使用,识别文字的功能错误相对较多(经测试加上联网搜索效果会好一些)。在理解 AI 给出的代码后,经过各种小修小补,补上我自己的额外需求,最终得到了我想要的效果。

这是我写的第一个利用大模型 API 的小工具,写的过程还是十分有趣的。下面我将介绍 AHK 是如何实现这两个功能的,并在最后给出完整的代码示例。

准备工作

首先,需要 AHK 这个脚本工具(官方网址),AHK 有很多教程,其文档也有中文翻译版本,我在上一篇文章中也对此做了不少介绍,此处就不再赘述。

需要注意的是,如果你跟我一样使用 AHK 2.0 版本(推荐使用这个最新的版本),在询问 AI 时一定要强调使用 AHK 2.0 版本的语法,并且即使如此,AI 偶尔也会给出不能够在 2.0 版本中使用的代码,这时候针对错误要求 AI 修改即可;其次在向大模型服务商发送请求时要使用 JSON 格式,需要使用 JSON 库,这里我用的是这个,下载后放在同 AutoHotkey.exe 同目录的 Lib 文件夹内(如无就新建一个)就可以1

类似的,在截图文字识别中,要将剪贴板中的截图保存下来发送请求,需要使用到 GDIP 库。这里我走了很多弯路,一是 AI 总是给出不存在的语法,比如什么 Clipboard.GetData();二是调用 Windows API 函数 GetClipboardData,但在我这里尝试了许多次、换了都没有成功;三是网上的 GDIP 库很多是基于旧语法的,在 AHK 2.0 中不能使用。最终我找到的可用于 AHK 2.0 的 GDIP 库来自这个地址

在下载上述两个库并放在指定位置后,在脚本中加入这样两行代码,后续即可使用里面的函数:

#Include <JSON>
#Include <GDIP_ALL>

最后,是需要准备大模型 API,或者翻译 API,目前有大量选择,这里不做任何推荐,请注意保留好自己的 API KEY(可能有 ID 等)不要对外泄露。不同的 API 在请求服务时的代码有所不同,需要查阅各家提供的文档,当然更方便的方式是请 AI 查阅这些文档并给出相应的 AHK 2.0 代码。因为我自己使用的是 OpenRouter(文档地址),其他服务商在代码上与此可能有差异的地方,下面我会尽量做出提示。

设置热键、获取文字和截图

首先我们要针对翻译和文字识别功能,设置不同的热键(快捷键)

^+d:: translate()
^+1:: ocr()

这里我分别使用的是 Ctrl + Shift + D,以及 Ctrl + Shift + 1,没有什么特殊的原因,单纯是这个按键和我在 Mac 上使用的 Bob 设置相同。需要说明的是,我个人是交换了 Ctrl 和 Alt 的,所以这里其实使用的是键盘上的 Alt 按键。如果使用 Alt,只要将上面的 ^ 修改为 !。

然后我们需要获取文字和图片,这里我都使用的是剪贴板。并且对于返回的结果,我也会保存到剪贴板,如果你不想要让剪贴板受到「污染」,可以考虑加上以下代码来临时保存剪贴板内容再还原回去(参考文档),我自己未使用这个方式。

ClipSaved := ClipboardAll() ; 保存剪贴板内容
;(完成后续需要使用剪贴板的各种操作)
A_Clipboard := ClipSaved ; 恢复剪贴板
ClipSaved := ""

所谓划词(句)翻译,就是把选中的词句翻译为指定语言,因此这时候操作上一定是选中了要翻译的内容,这里发送 Ctrl + C 复制就可以,等待剪贴板存在文字内容后,继续后续操作。

translate(){
A_Clipboard:= ""
Send "^c"
ClipWait() ; 等待剪贴板存在文字
text:= A_Clipboard
;(完成后续的各种操作)
}

截图识别相对复杂,也是上面我提到走了不少弯路的地方,截图利用系统自带的 Windows + Shift + S 就可以,由于我自己在工作时常开着微信,微信截图在我这里稍快一点,且截图选框后还可以修改范围,所以我用的是微信截图快捷键 Ctrl + Alt + A。等待剪贴板存在内容后,这里需要临时将截图保存到某个文件中,文件的地址可以自己指定,事后会删除。

ocr(){
A_Clipboard:= ""
Send "^!a" ; 系统截图使用 #+s,微信截图使用 ^!a
ClipWait(, 1) ; ClipWait 第二个参数指定为 1,意思是任何类型的数据,否则默认仅限于文本或文件

file_tmp:= "tmp_clipboard_pic_" A_YYYY A_MM A_DD ".png" ; 选一个不会与已有文件重复的名字即可
path:= "C:\Users\用户名\Downloads\" file_tmp
; 下面几行是使用 GDIP 库的一系列操作,用途只有把图片临时保存并写入文件,然后再读取这个文件,存入变量 img
pToken:= Gdip_Startup()
pBitmap := Gdip_CreateBitmapFromClipboard()
Gdip_SaveBitmapToFile(pBitmap, path)
Gdip_DisposeImage(pBitmap)
Gdip_Shutdown(pToken)
img:= FileRead(path, "RAW")
;(完成后续的各种操作)
FileDelete(path)
}

发送请求以获取翻译结果、识别文字结果

从这一部分开始,不同 API 服务商操作会有所不同,以我使用的 OpenRouter 为例,首先我需要获取请求的网址,以及我申请的 API KEY,然后选择使用的模型。这里可以一次性填入多个模型,注意识别文字需要使用具有视觉功能的模型,下面以翻译功能为例,识别文字部分详见后面的完整代码。如果使用的是腾讯翻译,则需要的是 API ID 和 API KEY,且不需要选择模型。

url:= "https://openrouter.ai/api/v1/chat/completions"
api:= "" ; 填入申请的 API KEY
models:= ["",] ; 填入模型名称,可以填写多个,用引号包围,用逗号隔开(均为英文标点)
w_model:= 1
langs:= ["中文", "英文",] ; 如果有其他语言需要,可以继续填入
t_lang:= 1

可以看到,我这里设置了两个初始为 1 的标记变量,目的是用来切换使用的模型和目标语言,以目标语言为例,默认选项 t_lang = 1 时,数组 langs[t_lang] 为 “中文”(注意 AHK 数组是从 1 开始的)。为此,我当然也设置了切换使用模型和目标语言的热键,并且,在切换后会显示切换的结果,持续一小段时间。以下以切换目标语言为例:

^+2:: switch_target_lang()
switch_target_lang(){
global t_lang
t_lang:= Mod(t_lang, langs.Length) + 1 ; 循环切换数字,也就是对数组长度求余后加一
ToolTip Format("翻译为{}", langs[t_lang]) ; 显示当前设置为翻译成什么语言
SetTimer () => ToolTip(), -1200 ; 让显示窗口在短时间内消失
}

效果如下:

事实上,OpenRouter 也支持自动选择或自动切换使用的模型(参考文档),目前我采用自己切换选中模型的方式,主要是为了确定翻译结果来自于哪个模型,以便于比较各模型速度和效果。

接下来是获取翻译,将前面选中并复制的文字 text,以及选中的目标语言 langs[t_lang] 以及选择的模型 models[w_model] 分别传入以下函数。这部分内容因选择的服务商而异,在构造请求方面有不同,腾讯 API 似乎还有一个签名的过程,这部分可以请 AI 查阅对应服务商的文档来帮助我们起草:

get_translation(text, lang, model){
; 使用 COM 组件,其中 HTTP 请求头 Header 怎么构造在服务商的文档中一般有写
req:= ComObject("WinHttp.WinHttpRequest.5.1")
req.open("POST", url, true)
req.SetRequestHeader("Authorization", "Bearer " api)
req.SetRequestHeader("Content-Type", "application/json")
 
; 构造请求的内容,一般就是要翻译的内容,对于大模型而言需要加上提示词
prompt:= "你是一位精通多种语言的专业翻译,请将内容精准地翻译为" lang ",只返回译文不要额外内容。"
message:= [
Map("role", "system", "content", prompt),
Map("role", "user", "content", text)
]
 
; 发送请求
req.Send(JSON.Stringify(Map(
"model", model,
"messages", message,
"temperature", 0.5 ; 大模型的温度参数,越高越有创造力,越低越严谨
)))
req.WaitForResponse()
 
; 收到并返回结果
response:= JSON.Parse(req.ResponseText)
return response["choices"][1].Get("message").Get("content")
}

对于识别文字可能还有一些额外步骤,比如对于 OpenRouter,需要把图片先转换为 Base64 编码,以下是我测试后可行的转换方法,即调用 Windows API 函数 CryptBinaryToString,网上其实也有针对 AHK 2.0 的 Base64 库,如果以下方法不行,也可以尝试使用库(我的测试方法是,把转换后的 img_64 赋值给剪贴板 A_Clipboard,然后网上找一个 Base64 解码为图片的在线网站试一下能不能把它还原回去):

DllCall("crypt32\CryptBinaryToString", "ptr", img.Ptr, "uint", img.size, "uint", 0x1, "ptr", 0, "uint*", &size := 0)
buf := Buffer(size * 2)
DllCall("crypt32\CryptBinaryToString", "ptr", img.Ptr, "uint", img.size, "uint", 0x1, "ptr", buf.Ptr, "uint*", &size)
img_64:= StrGet(buf.Ptr)

识别文字使用的提示词和请求内容(毕竟要告知大模型这是 Base64 编码的图片)也有所不同:

prompt:= "提取图片中的全部文字(保持原文换行), 只需要回答提取的文字内容。"
message:= [
Map("role", "system", "content", prompt),
Map("role", "user", "content", [
Map("type", "text", "text", prompt),
Map("type", "image_url", "image_url", Map("url", "data:image/png;base64," img_64))
])
]

其余部分则没什么差异,与上面的翻译函数基本一致,这里就不展示了,详见后文完整代码。

显示、复制返回的结果

在得到上述翻译、识别文字结果后,我遇到了一个额外的困难,这部分我不确定是否是 OpenRouter 服务商独有的,那就是我得到的结果是一串乱码,从其颇有规律的排布来看应该是某种编码,经过一些测试,我发现它是 ISO-8859-1(Latin-1)编码。

因此额外地,我需要将结果再转为 UTF-8 编码:

str:= get_translation(text, langs[t_lang], models[w_model]) ; 获取翻译结果
; 以下三行的效果是将结果从 ISO-8859-1 编码(在 AHK 中为 cp28591)转为 UTF-8 编码
byte:= Buffer(StrPut(str, "cp28591"))
StrPut(str, byte, "cp28591")
result:= StrGet(byte, "utf-8")

显示结果有至少两种方法,一种是使用 ToolTip,效果是显示一个工具提示的小窗口,考虑到文字较小,且一次性翻译或识别文字的文字量较大,没有选择让它一段时间后自动消失,而是设计为任意位置点击左键消失,之后将内容复制到剪贴板,方便使用或再次查看。

ToolTip result
KeyWait("LButton", "D") ; 等待一次左键点击
ToolTip() ; 关闭工具提示窗口
A_Clipboard:= result ; 将结果复制到剪贴板

这种做法的优点是不会弹出额外窗口,显得很轻便,但缺点是文字较多的时候看起来较为不便。所以另一种选项是使用 msgbox,弹出一个对话框来显示结果,由于窗口的文字框的大小更合适,可以考虑把原文和译文都显示出来,如下所示:

msgbox Format("原文:{}`r`n`r`n`r`n译文:{}", text, result)
A_Clipboard:= result

我自己使用的仍然是 ToolTip 方法,因为我觉得这看起来更轻便,全屏任意位置点击左键关闭窗口也更不影响操作。

其他

考虑到请求服务可能失败,为了让自己知道请求已经失败了而不是网络太慢,可以使用 try 语句在失败时抛出错误。

try{
;(获取翻译的一些列操作)
} catch as e {
ToolTip Format("翻译失败:{}", e.Message) ; 显示翻译失败和报错内容
SetTimer () => ToolTip(), -1800 ; 短时间后自动关闭显示
}

自己写的脚本有一个好处,就是可以随时根据需要修改或添加功能,比如前面提到的切换模型,在每次只输出一个结果的使用场景下,切换模型比 Bob 等工具还要方便一点。同样的道理,也可以写一个切换不同服务商的开关并分配热键,或者像是 Bob 那样,同时显示多个不同的模型的输出结果,从上到下依次排布。

最后,给出我的完整代码供大家参考:

#Requires AutoHotkey v2.0
#Include <JSON>
#Include <GDIP_ALL>
 
url:= ""
api:= ""
models:= ["",]
w_model:= 1
langs:=["中文", "英文",]
t_lang:= 1
 
url_ocr:= ""
api_ocr:= ""
models_ocr:= ["",]
w_model_ocr:= 1
 
 
^+d:: translate()
^+1:: ocr()
^+2:: switch_target_lang()
^+3:: switch_model()
^+4:: switch_model_ocr()
 
switch_target_lang(){
global t_lang
t_lang:= Mod(t_lang, langs.Length) + 1
ToolTip Format("翻译为{}", langs[t_lang])
SetTimer () => ToolTip(), -1200
}
 
switch_model(){
global w_model
w_model:= Mod(w_model, models.Length) + 1
ToolTip Format("使用以下模型:{}", models[w_model])
SetTimer () => ToolTip(), -1800
}
 
switch_model_ocr(){
global w_model_ocr
w_model_ocr:= Mod(w_model_ocr, models_ocr.Length) + 1
ToolTip Format("使用以下模型:{}", models_ocr[w_model_ocr])
SetTimer () => ToolTip(), -1800
}
 
translate(){
A_Clipboard:= ""
Send "^c"
ClipWait()
text:= A_Clipboard
 
try{
str:= get_translation(text, langs[t_lang], models[w_model])
byte:= Buffer(StrPut(str, "cp28591"))
StrPut(str, byte, "cp28591")
result:= StrGet(byte, "utf-8")
 
ToolTip result
KeyWait("LButton", "D")
ToolTip()
A_Clipboard:= result
} catch as e {
ToolTip Format("翻译失败:{}", e.Message)
SetTimer () => ToolTip(), -1800
}
}
 
get_translation(text, lang, model){
req:= ComObject("WinHttp.WinHttpRequest.5.1")
req.open("POST", url, true)
req.SetRequestHeader("Authorization", "Bearer " api)
req.SetRequestHeader("Content-Type", "application/json")
 
prompt:= "你是一位精通多种语言的专业翻译,请将内容精准地翻译为" lang ",只返回译文不要额外内容。"
message:= [
Map("role", "system", "content", prompt),
Map("role", "user", "content", text)
]
 
req.Send(JSON.Stringify(Map(
"model", model,
"messages", message,
"temperature", 0.5
)))
req.WaitForResponse()
response:= JSON.Parse(req.ResponseText)
return response["choices"][1].Get("message").Get("content")
}
 
 
ocr(){
A_Clipboard:= ""
Send "#+s" ;^!a
ClipWait(, 1)
 
file_tmp:= "tmp_clipboard_pic_" A_YYYY A_MM A_DD ".png"
path:= "C:\Users\用户名\Downloads\" file_tmp
pToken:= Gdip_Startup()
pBitmap := Gdip_CreateBitmapFromClipboard()
Gdip_SaveBitmapToFile(pBitmap, path)
Gdip_DisposeImage(pBitmap)
Gdip_Shutdown(pToken)
img:= FileRead(path, "RAW")
 
try{
str:= get_ocr(img, models_ocr[w_model_ocr])
byte:= Buffer(StrPut(str, "cp28591"))
StrPut(str, byte, "cp28591")
result:= StrGet(byte, "utf-8")
 
ToolTip result
KeyWait("LButton", "D")
ToolTip()
A_Clipboard:= result
} catch as e {
ToolTip Format("识别失败:{}", e.Message)
SetTimer () => ToolTip(), -1800
}
 
FileDelete(path)
}
 
get_ocr(img, model){
DllCall("crypt32\CryptBinaryToString", "ptr", img.Ptr, "uint", img.size, "uint", 0x1, "ptr", 0, "uint*", &size := 0)
buf := Buffer(size * 2)
DllCall("crypt32\CryptBinaryToString", "ptr", img.Ptr, "uint", img.size, "uint", 0x1, "ptr", buf.Ptr, "uint*", &size)
img_64:= StrGet(buf.Ptr)
 
req:= ComObject("WinHttp.WinHttpRequest.5.1")
req.open("POST", url, true)
req.SetRequestHeader("Authorization", "Bearer " api)
req.SetRequestHeader("Content-Type", "application/json")
 
prompt:= "提取图片中的全部文字(保持原文换行), 只需要回答提取的文字内容。"
message:= [
Map("role", "system", "content", prompt),
Map("role", "user", "content", [
Map("type", "text", "text", prompt),
Map("type", "image_url", "image_url", Map("url", "data:image/png;base64," img_64))
])
]
 
req.Send(JSON.Stringify(Map(
"model", model,
"messages", message,
"temperature", 0.5
)))
req.WaitForResponse()
response:= JSON.Parse(req.ResponseText)
return response["choices"][1].Get("message").Get("content")
}

> 关注 少数派小红书,感受精彩数字生活 🍃

> 实用、好用的 正版软件,少数派为你呈现 🚀