diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..59c75f5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + Build: + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - run: bash release.sh ${{ github.ref }} + + - name: Release + uses: softprops/action-gh-release@v1 + with: + draft: false + files: | + dist/*.bobplugin + env: + GITHUB_TOKEN: ${{ secrets.GT_TOKEN }} + + - run: rm -rf dist + + - uses: stefanzweifel/git-auto-commit-action@v4 + with: + branch: main + commit_message: new release! diff --git a/README.md b/README.md index ba47312..b8380b3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,37 @@ -# bob-plugin-doubao-translate -基于 豆包Doubao API 的文本翻译、文本润色、语法纠错 Bob 插件。 +
+

Doubao Translator Bob Plugin

+
+ +## 简介 + +基于 [豆包Doubao API](https://www.volcengine.com/product/doubao) 的文本翻译、文本润色、语法纠错 Bob 插件。 + +### 语言模型 + +* `Doubao-pro-128k`(默认使用) +* `Doubao-pro-32k` +* `Doubao-pro-4k` +* `Doubao-lite-128k` +* `Doubao-lite-32k` +* `Doubao-lite-4k` +* `Doubao-embedding` +* `Moonshot-v1-128k` +* `Moonshot-v1-128k` +* `Moonshot-v1-128k` + +## 使用方法 + +1. 安装 [Bob](https://bobtranslate.com/guide/#%E5%AE%89%E8%A3%85) (版本 >= 1.8.0),一款 macOS 平台的翻译和 OCR 软件 + +2. 下载此插件: [bob-plugin-doubao-translate.bobplugin](https://github.com/djx30103/bob-plugin-doubao-translate/releases/latest) + +3. 安装此插件 + +4. 去 [火山方舟控制台](https://console.volcengine.com/ark) 开通管理(开通服务) -> 模型推理(为每个模型创建接入点) -> API Key管理(创建 API Key) + +5. 把 API Key、推理点ID 填入 Bob 偏好设置 > 服务 > 此插件配置界面对应的输入框中,选择你要使用的模型,点击保存即可。 + +## 感谢 + +本仓库参考部分其他优秀源码,感谢[bob-plugin-cohere](https://github.com/missuo/bob-plugin-cohere)、[bob-plugin-gemini-translate](https://github.com/BrianShenCC/bob-plugin-gemini-translate)。 + diff --git a/appcast.json b/appcast.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/appcast.json @@ -0,0 +1 @@ +{} diff --git a/release.sh b/release.sh new file mode 100644 index 0000000..55a33c5 --- /dev/null +++ b/release.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +version=${1#refs/tags/v} +zip -r -j bob-plugin-doubao-translate-$version.bobplugin src/* + +sha256_doubao=$(sha256sum bob-plugin-doubao-translate-$version.bobplugin | cut -d ' ' -f 1) +echo $sha256_doubao + +download_link="https://github.com/djx30103/bob-plugin-doubao-translate/releases/download/v$version/bob-plugin-doubao-translate-$version.bobplugin" + +new_version="{\"version\": \"$version\", \"desc\": \"None\", \"sha256\": \"$sha256_doubao\", \"url\": \"$download_link\", \"minBobVersion\": \"1.8.0\"}" + +json_file='appcast.json' +json_data=$(cat $json_file) + +updated_json=$(echo $json_data | jq --argjson new_version "$new_version" '.versions = [$new_version] + .versions') + +echo $updated_json > $json_file +mkdir dist +mv *.bobplugin dist diff --git a/src/http.js b/src/http.js new file mode 100644 index 0000000..9f921b2 --- /dev/null +++ b/src/http.js @@ -0,0 +1,70 @@ +var utils = require("./utils.js"); + +const defaultUrl = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"; +const streamRequest = async ({ url, headers, body, query }) => { + let resultText = ""; + return $http.streamRequest({ + method: "POST", + url: url, + header: headers, + body: body, + streamHandler: (stream) => { + + let streamText = stream.text; + const dataReg = /^data: /gm; + const doneReg = /\s*\[DONE\]\s*/; + + // 使用正则表达式将 streamText 按 "data: " 分割为多个块 + const dataBlocks = streamText.split(dataReg); + + dataBlocks.forEach((block) => { + // 去除首尾空格和换行符 + block = block.trim(); + + // 检测和移除 [DONE] 标记 + if (doneReg.test(block)) { + block = block.replace(doneReg, ''); + } + + + // 忽略空块 + if (block === "") { + return; + } + + const resultJson = JSON.parse(block); + resultText += resultJson.choices[0].delta.content; + query.onStream({ result: { toParagraphs: [resultText] } }); + }); + }, + handler: (result) => { + if (result.response.statusCode >= 400) { + utils.handleError(query.onCompletion, result); + } else { + query.onCompletion({ result: { toParagraphs: [resultText]} }); + } + }, + }); +}; + +const normalRequest = async ({ url, headers, body , query}) => { + return $http.request({ + method: "POST", + url: url, + header: headers, + body: body, + handler: (result) => { + if (result.response.statusCode >= 400) { + utils.handleError(query.onCompletion, result); + } else { + const data = result.data; + $log.info(JSON.stringify(data)); + query.onCompletion({ result: { toParagraphs: [data.choices[0].message.content] } }); + } + }, + }); +}; + +exports.streamRequest = streamRequest; +exports.normalRequest = normalRequest; +exports.defaultUrl = defaultUrl; diff --git a/src/icon.png b/src/icon.png new file mode 100644 index 0000000..f7bb8ae Binary files /dev/null and b/src/icon.png differ diff --git a/src/info.json b/src/info.json new file mode 100644 index 0000000..a02b087 --- /dev/null +++ b/src/info.json @@ -0,0 +1,234 @@ +{ + "identifier": "bob.plugin.doubao.translate", + "category": "translate", + "version": "1.0.0", + "name": "豆包", + "summary": "使用 豆包 API 进行翻译", + "author": "djx30103", + "homepage": "https://github.com/djx30103/bob-plugin-doubao-translate", + "appcast": "https://github.com/djx30103/bob-plugin-doubao-translate/raw/main/appcast.json", + "icon": "icon.png", + "minBobVersion": "1.8.0", + "options": [ + { + "identifier": "Model", + "type": "menu", + "title": "模型", + "desc": "请选择您需要的模型。", + "defaultValue": "doubao_pro_128k_PointID", + "menuValues": [ + { + "title": "Doubao-pro-128k", + "value": "doubao_pro_128k_PointID" + }, + { + "title": "Doubao-pro-32k", + "value": "doubao_pro_32k_PointID" + }, + { + "title": "Doubao-pro-4k", + "value": "doubao_pro_4k_PointID" + }, + { + "title": "Doubao-lite-128k", + "value": "doubao_lite_128k_PointID" + }, + { + "title": "Doubao-lite-32k", + "value": "doubao_lite_32k_PointID" + }, + { + "title": "Doubao-lite-4k", + "value": "doubao_lite_4k_PointID" + }, + { + "title": "Doubao-embedding", + "value": "doubao_embedding_PointID" + }, + { + "title": "Moonshot-v1-128k", + "value": "moonshot_v1_128k_PointID" + }, + { + "title": "Moonshot-v1-32k", + "value": "moonshot_v1_32k_PointID" + }, + { + "title": "Moonshot-v1-8k", + "value": "moonshot_v1_8k_PointID" + } + ] + }, + { + "identifier": "TransferMode", + "type": "menu", + "title": "传输模式", + "defaultValue": "1", + "desc": "请选择是否使用流式传输功能。", + "menuValues": [ + { + "title": "流式", + "value": "1" + }, + { + "title": "非流式", + "value": "2" + } + ] + }, + { + "identifier": "Mode", + "type": "menu", + "title": "模式", + "defaultValue": "1", + "desc": "「翻译」模式是将文本翻译为目标语言。\n「润色」模式不会进行语言翻译,而是对原始文本进行修改和优化。\n「自定义 Prompt」 模式可以自行设置角色设定(System Prompt)和用户指令(User Prompt),满足个性化的需求。", + "menuValues": [ + { + "title": "翻译", + "value": "1" + }, + { + "title": "润色", + "value": "2" + }, + { + "title": "自定义prompt", + "value": "3" + } + ] + }, + { + "identifier": "APIKey", + "type": "text", + "title": "APIKey", + "desc": "请填写「APIKey」。您可以访问「火山方舟控制台」获取。", + "textConfig": { + "type": "secure" + } + }, + { + "identifier": "doubao_pro_128k_PointID", + "type": "text", + "title": "Doubao-pro-128k 推理点ID", + "desc": "请填写「推理点ID」,您可以访问「火山方舟控制台」获取。", + "textConfig": { + "type": "secure" + } + }, + { + "identifier": "doubao_pro_32k_PointID", + "type": "text", + "title": "Doubao-pro-32k 推理点ID", + "desc": "请填写「推理点ID」,您可以访问「火山方舟控制台」获取。", + "textConfig": { + "type": "secure" + } + }, + { + "identifier": "doubao_pro_4k_PointID", + "type": "text", + "title": "Doubao-pro-4k 推理点ID", + "desc": "请填写「推理点ID」,您可以访问「火山方舟控制台」获取。", + "textConfig": { + "type": "secure" + } + }, + { + "identifier": "doubao_lite_128k_PointID", + "type": "text", + "title": "Doubao-lite-128k 推理点ID", + "desc": "请填写「推理点ID」,您可以访问「火山方舟控制台」获取。", + "textConfig": { + "type": "secure" + } + }, + { + "identifier": "doubao_lite_32k_PointID", + "type": "text", + "title": "Doubao-lite-32k 推理点ID", + "desc": "请填写「推理点ID」,您可以访问「火山方舟控制台」获取。", + "textConfig": { + "type": "secure" + } + }, + { + "identifier": "doubao_lite_4k_PointID", + "type": "text", + "title": "Doubao-lite-4k 推理点ID", + "desc": "请填写「推理点ID」,您可以访问「火山方舟控制台」获取。", + "textConfig": { + "type": "secure" + } + }, + { + "identifier": "doubao_embedding_PointID", + "type": "text", + "title": "Doubao-embedding 推理点ID", + "desc": "请填写「推理点ID」,您可以访问「火山方舟控制台」获取。", + "textConfig": { + "type": "secure" + } + }, + { + "identifier": "moonshot_v1_128k_PointID", + "type": "text", + "title": "Moonshot-v1-128k 推理点ID", + "desc": "请填写「推理点ID」,您可以访问「火山方舟控制台」获取。", + "textConfig": { + "type": "secure" + } + }, + { + "identifier": "moonshot_v1_32k_PointID", + "type": "text", + "title": "Moonshot-v1-32k 推理点ID", + "desc": "请填写「推理点ID」,您可以访问「火山方舟控制台」获取。", + "textConfig": { + "type": "secure" + } + }, + { + "identifier": "moonshot_v1_8k_PointID", + "type": "text", + "title": "Moonshot-v1-8k 推理点ID", + "desc": "请填写「推理点ID」,您可以访问「火山方舟控制台」获取。", + "textConfig": { + "type": "secure" + } + }, + { + "identifier": "CustomizeURL", + "type": "text", + "title": "自定义 API URL", + "defaultValue": "", + "desc": "需要填写完整的 URL。\n默认为「https://ark.cn-beijing.volces.com/api/v3/chat/completions」。\n如果仅需修改域名,把「ark.cn-beijing.volces.com」改掉即可。", + "textConfig": { + "type": "visible" + } + }, + { + "identifier": "SystemPrompt", + "type": "text", + "title": "角色设定", + "defaultValue": "", + "desc": "此设置仅在「自定义 Prompt」模式有效,其他模式无需设置。\n通过此项设置对话背景或赋予模型角色,对应字段参数是system,文本中可使用以下变量:\n\n$query.detectFromLang - 原文语言,即翻译窗口输入框内文本的语言,比如`简体中文`\n$query.detectToLang - 目标语言,即需要翻译成的语言,可以在翻译窗口中手动选择或自动检測,比如`English`", + "textConfig": { + "type": "visible", + "height": "100", + "placeholderText": "例如:\n你是一个翻译器" + } + }, + { + "identifier": "UserInstructions", + "type": "text", + "title": "用户指令", + "defaultValue": "", + "desc": "此设置仅在「自定义 Prompt」模式有效,其他模式无需设置。\n通过此项设置对大模型发出的具体指令,用于描述需要大模型完成的目标任务和需求说明,对应字段参数是user,文本中可使用以下变量:\n\n$query.text - 需要翻译的文本,即翻译窗口输入框内的文本\n$query.detectFromLang - 原文语言,即翻译窗口输入框内文本的语言,比如`简体中文`\n$query.detectToLang - 目标语言,即需要翻译成的语言,可以在翻译窗口中手动选择或自动检測,比如`English`", + "textConfig": { + "type": "visible", + "height": "100", + "placeholderText": "例如:\n将以下文本翻译为 $query.detectToLang:\n$query.text" + } + } + ] +} diff --git a/src/language.js b/src/language.js new file mode 100644 index 0000000..0d66788 --- /dev/null +++ b/src/language.js @@ -0,0 +1,123 @@ +const supportLanguages = [ + ["auto", "auto"], + ["zh-Hans", "zh-CN"], + ["zh-Hant", "zh-TW"], + ["en", "en"], + ["yue", "粤语"], + ["wyw", "古文"], + ["en", "en"], + ["ja", "ja"], + ["ko", "ko"], + ["fr", "fr"], + ["de", "de"], + ["es", "es"], + ["it", "it"], + ["ru", "ru"], + ["pt", "pt"], + ["nl", "nl"], + ["pl", "pl"], + ["ar", "ar"], + ["af", "af"], + ["am", "am"], + ["az", "az"], + ["be", "be"], + ["bg", "bg"], + ["bn", "bn"], + ["bs", "bs"], + ["ca", "ca"], + ["ceb", "ceb"], + ["co", "co"], + ["cs", "cs"], + ["cy", "cy"], + ["da", "da"], + ["el", "el"], + ["eo", "eo"], + ["et", "et"], + ["eu", "eu"], + ["fa", "fa"], + ["fi", "fi"], + ["fj", "fj"], + ["fy", "fy"], + ["ga", "ga"], + ["gd", "gd"], + ["gl", "gl"], + ["gu", "gu"], + ["ha", "ha"], + ["haw", "haw"], + ["he", "he"], + ["hi", "hi"], + ["hmn", "hmn"], + ["hr", "hr"], + ["ht", "ht"], + ["hu", "hu"], + ["hy", "hy"], + ["id", "id"], + ["ig", "ig"], + ["is", "is"], + ["jw", "jw"], + ["ka", "ka"], + ["kk", "kk"], + ["km", "km"], + ["kn", "kn"], + ["ku", "ku"], + ["ky", "ky"], + ["la", "lo"], + ["lb", "lb"], + ["lo", "lo"], + ["lt", "lt"], + ["lv", "lv"], + ["mg", "mg"], + ["mi", "mi"], + ["mk", "mk"], + ["ml", "ml"], + ["mn", "mn"], + ["mr", "mr"], + ["ms", "ms"], + ["mt", "mt"], + ["my", "my"], + ["ne", "ne"], + ["no", "no"], + ["ny", "ny"], + ["or", "or"], + ["pa", "pa"], + ["ps", "ps"], + ["ro", "ro"], + ["rw", "rw"], + ["si", "si"], + ["sk", "sk"], + ["sl", "sl"], + ["sm", "sm"], + ["sn", "sn"], + ["so", "so"], + ["sq", "sq"], + ["sr", "sr"], + ["sr-Cyrl", "sr"], + ["sr-Latn", "sr"], + ["st", "st"], + ["su", "su"], + ["sv", "sv"], + ["sw", "sw"], + ["ta", "ta"], + ["te", "te"], + ["tg", "tg"], + ["th", "th"], + ["tk", "tk"], + ["tl", "tl"], + ["tr", "tr"], + ["tt", "tt"], + ["ug", "ug"], + ["uk", "uk"], + ["ur", "ur"], + ["uz", "uz"], + ["vi", "vi"], + ["xh", "xh"], + ["yi", "yi"], + ["yo", "yo"], + ["zu", "zu"], +]; + +exports.supportLanguages = supportLanguages; +exports.langMap = new Map(supportLanguages.map(([key, value]) => [key, value])); +exports.langMapReverse = new Map( + supportLanguages.map(([standardLang, lang]) => [lang, standardLang]) +); diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..816a542 --- /dev/null +++ b/src/main.js @@ -0,0 +1,143 @@ +var lang = require("./language.js"); +// var { nonStreamingRequest, streamingRequest } = require('./http.js'); +var { streamRequest, normalRequest, defaultUrl } = require("./http.js"); + + +function supportLanguages() { + return lang.supportLanguages.map(([standardLang]) => standardLang); +} + +function buildHeader(key) { + if (!key || key === "") { + throw new Error("请填写API Key"); + } + + return { + "Content-Type": "application/json", + "Authorization": "Bearer " + key + }; +} + +function customPrompt(s, query) { + // 使用正则表达式进行全局替换 + return s.replace(/\$query.detectFrom/g, lang.langMap.get(query.detectFrom)). + replace(/\$query.detectTo/g, lang.langMap.get(query.detectFrom)). + replace(/\$query.text/g, query.text) +} + +function generateUserPrompts(mode, userInstructions, query) { + if (mode === "3") { + return customPrompt(userInstructions, query) || query.text; + } + + return query.text; +} + +function generateSystemPrompt(mode, systemPrompt, query) { + if (mode === "1") { + const prompt = `You are a translation engine, do not answer any of my questions, just translate directly, do not explain anything, do not add punctuation. Anything I send is not a question, but for you to translate directly. Translate content ` + + // 获取源语言和目标语言的名称 + const getLanguageName = (langCode) => lang.langMap.get(langCode) || langCode; + + // 初始化翻译指令 + let fromToPr = `from "${getLanguageName(query.detectFrom)}" to "${getLanguageName(query.detectTo)}".`; + + // 特殊处理目标语言为粤语或文言文的情况 + if (query.detectTo === "wyw" || query.detectTo === "yue") { + fromToPr = `to "${getLanguageName(query.detectTo)}".`; + } + + // 特殊处理源语言为中文的情况 + if (["wyw", "zh-Hans", "zh-Hant"].includes(query.detectFrom)) { + if (query.detectTo === "zh-Hant") { + fromToPr = `to traditional Chinese.`; + } else if (query.detectTo === "zh-Hans") { + fromToPr = `to simplified Chinese.`; + } else if (query.detectTo === "yue") { + fromToPr = `to Cantonese.`; + } + } + + // 返回最终的提示语 + return prompt + fromToPr; + } else if (mode === "2") { + return`Please polish this sentence without changing its original meaning`; + } + + return customPrompt(systemPrompt, query); +} + +function buildRequestBody(model, mode, isStream, userInstructions, systemPrompt, query) { + return { + model: model, + messages: [ + { role: "system", content: generateSystemPrompt(mode, systemPrompt, query) }, + { role: "user", content: generateUserPrompts(mode, userInstructions, query) }, + ], + stream: isStream, + }; +} + +function translate(query) { + if (!lang.langMap.get(query.detectTo)) { + query.onCompletion({ + error: { + type: "unsupportLanguage", + message: "不支持该语种", + addtion: "不支持该语种", + }, + }); + } + + const { + APIKey, + CustomizeURL, + Model, + TransferMode, + Mode, + SystemPrompt, + UserInstructions, + } = $option; + const isStreaming = TransferMode === "1"; + const url = CustomizeURL || defaultUrl; + const headers = buildHeader(APIKey); + + const model_point = $option[Model] + if (!model_point) { + query.onCompletion({ + error: { + type: "unsupportModel", + message: "推理点ID不存在", + addtion: "推理点ID不存在", + }, + }); + return; + } + + const body = buildRequestBody(model_point, Mode, isStreaming, UserInstructions, SystemPrompt, query); + $log.info(Model); + $log.info(JSON.stringify(headers)); + $log.info(JSON.stringify(body)); + + // 调用封装的HTTP请求函数 + (async () => { + + if (isStreaming) { + streamRequest({url, headers, body, query}) + } else { + normalRequest({url, headers, body, query}) + } + })().catch((err) => { + query.onCompletion({ + error: { + type: err._type || "unknown", + message: err._message || "未知错误", + addtion: err._addtion, + }, + }); + }) +} + +exports.supportLanguages = supportLanguages; +exports.translate = translate; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..f829155 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,66 @@ +var HttpErrorCodes = { + "400": "Bad Request", + "401": "Unauthorized", + "402": "Payment Required", + "403": "Forbidden", + "404": "Not Found", + "405": "Method Not Allowed", + "406": "Not Acceptable", + "407": "Proxy Authentication Required", + "408": "Request Timeout", + "409": "Conflict", + "410": "Gone", + "411": "Length Required", + "412": "Precondition Failed", + "413": "Payload Too Large", + "414": "URI Too Long", + "415": "Unsupported Media Type", + "416": "Range Not Satisfiable", + "417": "Expectation Failed", + "418": "I'm a teapot", + "421": "Misdirected Request", + "422": "Unprocessable Entity", + "423": "Locked", + "424": "Failed Dependency", + "425": "Too Early", + "426": "Upgrade Required", + "428": "Precondition Required", + "429": "Too many requests", + "431": "Request Header Fields Too Large", + "451": "Unavailable For Legal Reasons", + "500": "Internal Server Error", + "501": "Not Implemented", + "502": "Bad Gateway", + "503": "Service Unavailable", + "504": "Gateway Timeout", + "505": "HTTP Version Not Supported", + "506": "Variant Also Negotiates", + "507": "Insufficient Storage", + "508": "Loop Detected", + "510": "Not Extended", + "511": "Network Authentication Required" +}; + +function handleError(completion, result) { + if (result?.data?.error?.message) { + completion({ + error: { + type: "param", + message: result?.data?.error?.message, + addtion: `${JSON.stringify(result?.data)}`, + }, + }); + return; + } + const { statusCode } = result.response; + const reason = statusCode >= 400 && statusCode < 500 ? "param" : "api"; + completion({ + error: { + type: reason, + message: `接口响应错误 - ${HttpErrorCodes[statusCode]}`, + addtion: `${JSON.stringify(result)}`, + }, + }); +} + +exports.handleError = handleError;