diff --git a/docs/configuration_manual_CN.md b/docs/configuration_manual_CN.md index 24d4cee..94f8e8e 100644 --- a/docs/configuration_manual_CN.md +++ b/docs/configuration_manual_CN.md @@ -4,44 +4,48 @@ - 必选项 -- 默认值: OpenAI +- 默认值:OpenAI - 说明 - - OpenAI: 使用 OpenAI 官方服务 + - OpenAI:使用 OpenAI 官方服务 - - Azure OpenAI: 使用 [Azure OpenAI Service](https://learn.microsoft.com/zh-cn/azure/ai-services/Translator/quickstart-text-rest-api) + - OpenAI Compatible:使用与 OpenAI 兼容的服务,如 [Ollama](https://ollama.com/blog/openai-compatibility) 等服务;或是自定义/第三方反代服务,如 [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/) 或者 [Ollama](https://ollama.com/blog/openai-compatibility) 等服务 - - Custom: 使用自定义服务,如 [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/) 或者 [Ollama](https://ollama.com/blog/openai-compatibility) 等服务商 + - Azure OpenAI:使用 [Azure OpenAI Service](https://learn.microsoft.com/zh-cn/azure/ai-services/openai/chatgpt-quickstart) + + - Google Gemini:使用 [Google Gemini](https://ai.google.dev/gemini-api/docs) 服务 ### API URL -- 可选项(OpenAI)/ 必填项(Azure OpenAI 和 Custom) +- 可选项(OpenAI 和 Google Gemini)/ 必填项(Azure OpenAI 和 OpenAI Compatible) -- 默认值: 无 +- 默认值:无 - 说明 - - OpenAI: 可选,默认为: `https://api.openai.com` + - OpenAI:可选,默认为:`https://api.openai.com` + + - OpenAI Compatible:必填,需填入完整的 API URL,例如使用 Cloudflare AI Gateway 时,填入: + + ``` + https://gateway.ai.cloudflare.com/v1/CLOUDFLARE_ACCOUNT_ID/GATEWAY_ID/openai/chat/completions + ``` - - Azure OpenAI: 必填,完整的 API URL,格式为: + - Azure OpenAI:必填,需填入完整的 API URL,格式为: ``` https://RESOURCE_NAME.openai.azure.com/openai/deployments/DEPLOYMENT_NAME/chat/completions?api-version=API_VERSION ``` - - Custom: 必填,值为完整的 API URL,例如使用 Cloudflare AI Gateway 时,需填入: - - ``` - https://gateway.ai.cloudflare.com/v1/CLOUDFLARE_ACCOUNT_ID/GATEWAY_ID/openai/chat/completions - ``` + - Google Gemini:可选,默认为:`https://generativelanguage.googleapis.com/v1beta/models` ### API KEY - 必填项 -- 默认值: 无 +- 默认值:无 - 说明 @@ -51,7 +55,7 @@ - 必选项 -- 默认值: `gpt-3.5-turbo` +- 默认值:`gpt-3.5-turbo` - 说明 @@ -61,7 +65,7 @@ - 可选项 -- 默认值: `gpt-3.5-turbo` +- 默认值:`gpt-3.5-turbo` - 说明 @@ -71,7 +75,7 @@ - 可选项 -- 默认值: `You are a translation engine that can only translate text and cannot interpret it.` +- 默认值:`You are a translation engine that can only translate text and cannot interpret it.` - 说明 @@ -89,7 +93,7 @@ - 可选项 -- 默认值: `translate from $sourceLang to $targetLang:\n\n$text` +- 默认值:`translate from $sourceLang to $targetLang:\n\n$text` - 说明 @@ -101,7 +105,7 @@ - 可选项 -- 默认值: `Enable` +- 默认值:`Enable` - 说明 @@ -113,7 +117,7 @@ - 可选项 -- 默认值: `0.2` +- 默认值:`0.2` - 说明 diff --git a/docs/configuration_manual_EN.md b/docs/configuration_manual_EN.md index 65b6402..3bb6fec 100644 --- a/docs/configuration_manual_EN.md +++ b/docs/configuration_manual_EN.md @@ -10,13 +10,15 @@ - OpenAI: Use official OpenAI service - - Azure OpenAI: Use [Azure OpenAI Service](https://learn.microsoft.com/zh-cn/azure/ai-services/Translator/quickstart-text-rest-api) + - OpenAI Compatible: Use OpenAI compatible service, such as [Ollama](https://ollama.com/blog/openai-compatibility) service; or custom/third-party reverse proxy service, such as [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/) - - Custom: Use custom service, such as [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/) or [Ollama](https://ollama.com/blog/openai-compatibility) + - Azure OpenAI: Use [Azure OpenAI Service](https://learn.microsoft.com/zh-cn/azure/ai-services/openai/chatgpt-quickstart) + + - Google Gemini: Use [Google Gemini](https://ai.google.dev/gemini-api/docs) service ### API URL -- Optional (OpenAI) / Required (Azure OpenAI and Custom) +- Optional (OpenAI and Google Gemini) / Required (Azure OpenAI and OpenAI Compatible) - Default value: None @@ -24,19 +26,19 @@ - OpenAI: Optional, default value: `https://api.openai.com` + - OpenAI Compatible: Required, complete API URL, for example when using Cloudflare AI Gateway: + + ``` + https://gateway.ai.cloudflare.com/v1/CLOUDFLARE_ACCOUNT_ID/GATEWAY_ID/openai/chat/completions + ``` + - Azure OpenAI: Required, complete API URL in format: ``` https://RESOURCE_NAME.openai.azure.com/openai/deployments/DEPLOYMENT_NAME/chat/completions?api-version=API_VERSION ``` - - For more information, please refer to [Cloudflare AI Gateway Official Documentation](https://developers.cloudflare.com/ai-gateway/). - - - Custom: Required, complete API URL, for example when using Cloudflare AI Gateway: - - ``` - https://gateway.ai.cloudflare.com/v1/CLOUDFLARE_ACCOUNT_ID/GATEWAY_ID/openai/chat/completions - ``` + - Google Gemini: Optional, default value: `https://generativelanguage.googleapis.com/v1beta/models` ### API KEY diff --git a/public/info.json b/public/info.json index 2912fe6..f8de2c5 100644 --- a/public/info.json +++ b/public/info.json @@ -1,74 +1,66 @@ { - "identifier": "yetone.openai.translator", - "version": "3.0.0", - "category": "translate", - "name": "OpenAI Translator", - "summary": "GPT powered translator", - "icon": "", + "appcast": "https://raw.githubusercontent.com/openai-translator/bob-plugin-openai-translator/main/appcast.json", "author": "yetone ", + "category": "translate", "homepage": "https://github.com/openai-translator/bob-plugin-openai-translator", - "appcast": "https://raw.githubusercontent.com/openai-translator/bob-plugin-openai-translator/main/appcast.json", + "icon": "", + "identifier": "yetone.openai.translator", "minBobVersion": "1.8.0", + "name": "OpenAI Translator", "options": [ { - "identifier": "serviceProvider", - "type": "menu", - "title": "Service Provider", "defaultValue": "openai", + "identifier": "serviceProvider", "menuValues": [ { "title": "OpenAI", "value": "openai" }, + { + "title": "OpenAI Compatible", + "value": "openai-compatible" + }, { "title": "Azure OpenAI", "value": "azure-openai" }, { - "title": "Custom", - "value": "custom" + "title": "Google Gemini", + "value": "gemini" } - ] + ], + "title": "Service Provider", + "type": "menu" }, { + "desc": "OpenAI: https://api.openai.com\n\nOpenAI Compatible: 完整的 API 地址,例如:https://gateway.ai.cloudflare.com/v1/CF_ACCOUNT_ID/GATEWAY_ID/openai/chat/completions\n\nAzure OpenAI: https://RESOURCE_NAME.openai.azure.com/openai/deployments/DEPLOYMENT_NAME/chat/completions?api-version=API_VERSION\n\nGoogle Gemini: https://generativelanguage.googleapis.com/v1beta/models", "identifier": "apiUrl", - "type": "text", - "title": "API URL", - "desc": "OpenAI: https://api.openai.com\n\nAzure OpenAI: https://RESOURCE_NAME.openai.azure.com/openai/deployments/DEPLOYMENT_NAME/chat/completions?api-version=API_VERSION\n\nCustom: 您的完整 API 地址,例如:https://gateway.ai.cloudflare.com/v1/CF_ACCOUNT_ID/GATEWAY_ID/openai/chat/completions", "textConfig": { - "type": "visible", - "placeholderText": "https://api.openai.com" - } + "placeholderText": "https://api.openai.com", + "type": "visible" + }, + "title": "API URL", + "type": "text" }, { - "identifier": "apiKeys", - "type": "text", - "title": "API KEY", "desc": "必填项。可以用英文逗号分割多个 API KEY 以实现额度加倍及负载均衡", + "identifier": "apiKeys", "textConfig": { - "type": "secure", "height": "40", - "placeholderText": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - } + "placeholderText": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "type": "secure" + }, + "title": "API KEY", + "type": "text" }, { - "identifier": "model", - "type": "menu", - "title": "模型", "defaultValue": "gpt-3.5-turbo", + "identifier": "model", "menuValues": [ { "title": "Custom", "value": "custom" }, - { - "title": "GPT-3.5 Turbo", - "value": "gpt-3.5-turbo" - }, - { - "title": "GPT-4", - "value": "gpt-4" - }, { "title": "GPT-4o", "value": "gpt-4o" @@ -82,60 +74,72 @@ "value": "gpt-4-turbo" }, { - "title": "GPT-4 32K", - "value": "gpt-4-32k" + "title": "GPT-4", + "value": "gpt-4" + }, + { + "title": "GPT-3.5 Turbo", + "value": "gpt-3.5-turbo" + }, + { + "title": "Gemini 1.5 Pro", + "value": "gemini-1.5-pro" + }, + { + "title": "Gemini 1.5 Flash", + "value": "gemini-1.5-flash" } - ] + ], + "title": "模型", + "type": "menu" }, { - "identifier": "customModel", - "type": "text", - "title": "自定义模型", "desc": "可选项。当 Model 选择 Custom 时,此项为必填项。请填写有效的模型名称", + "identifier": "customModel", "textConfig": { - "type": "visible", - "placeholderText": "gpt-3.5-turbo" - } + "placeholderText": "gpt-3.5-turbo", + "type": "visible" + }, + "title": "自定义模型", + "type": "text" }, { - "identifier": "customSystemPrompt", - "type": "text", - "title": "系统指令", "defaultValue": "You are a translation engine that can only translate text and cannot interpret it.", - "desc": "可选项。自定义 System Prompt,填写则会覆盖默认的 System Prompt。自定义 Prompt可使用以下变量:\n\n`$text` - 需要翻译的文本,即翻译窗口输入框内的文本 `$sourceLang` - 原文语言,即翻译窗口输入框内文本的语言,比如「简体中文」\n\n`$targetLang` - 目标语言,即需要翻译成的语言,可以在翻译窗口中手动选择或自动检测,比如「English」", + "desc": "可选项。自定义 System Prompt,填写则会覆盖默认的 System Prompt。自定义 Prompt可使用以下变量:\n\n`$text` - 需要翻译的文本,即翻译窗口输入框内的文本 `$sourceLang` - 原文语言,即翻译窗口输入框内文本的语言,比如「简体中文」\n\n`$targetLang` - 目标语言,即需要翻译成的语言,可以在翻译窗口中手动选择或自动检测,例如「English」", + "identifier": "customSystemPrompt", "textConfig": { - "type": "visible", "height": "100", - "placeholderText": "You are a translation engine that can only translate text and cannot interpret it.", "keyWords": [ "$text", "$sourceLang", "$targetLang" - ] - } + ], + "placeholderText": "You are a translation engine that can only translate text and cannot interpret it.", + "type": "visible" + }, + "title": "系统指令", + "type": "text" }, { - "identifier": "customUserPrompt", - "type": "text", - "title": "用户指令", "defaultValue": "translate from $sourceLang to $targetLang:\n\n$text", "desc": "可选项。自定义 User Prompt,填写则会覆盖默认的 User Prompt,默认值为`$text`(即翻译窗口输入框内的文本)。\n\n自定义 Prompt 中可以使用与系统指令中相同的变量", + "identifier": "customUserPrompt", "textConfig": { - "type": "visible", "height": "100", - "placeholderText": "translate from $sourceLang to $targetLang:\n\n$text", "keyWords": [ "$text", "$sourceLang", "$targetLang" - ] - } + ], + "placeholderText": "translate from $sourceLang to $targetLang:\n\n$text", + "type": "visible" + }, + "title": "用户指令", + "type": "text" }, { - "identifier": "stream", - "type": "menu", - "title": "流式输出", "defaultValue": "enable", + "identifier": "stream", "menuValues": [ { "title": "Enable", @@ -145,18 +149,22 @@ "title": "Disable", "value": "disable" } - ] + ], + "title": "流式输出", + "type": "menu" }, { - "identifier": "temperature", - "type": "text", - "title": "温度", "defaultValue": "0.2", "desc": "可选项。温度值越高,生成的文本越随机。默认值为 0.2", + "identifier": "temperature", "textConfig": { - "type": "visible", - "placeholderText": "0.2" - } + "placeholderText": "0.2", + "type": "visible" + }, + "title": "温度", + "type": "text" } - ] + ], + "summary": "AI powered translator", + "version": "3.0.0" } \ No newline at end of file diff --git a/src/adapter/azure-openai.ts b/src/adapter/azure-openai.ts new file mode 100644 index 0000000..aa93549 --- /dev/null +++ b/src/adapter/azure-openai.ts @@ -0,0 +1,61 @@ +import { ServiceError, ValidationCompletion } from "@bob-translate/types"; +import { handleValidateError } from "../utils"; +import { OpenAiChatCompletion } from "../types"; +import { OpenAiAdapter } from "./openai"; + +export class AzureOpenAiAdapter extends OpenAiAdapter { + + public override buildHeaders(apiKey: string): Record { + return { + "Content-Type": "application/json", + "api-key": apiKey, + }; + } + + public override getTextGenerationUrl(apiUrl: string): string { + return apiUrl; + } + + public override async testApiConnection( + apiKey: string, + apiUrl: string, + completion: ValidationCompletion, + ): Promise { + const header = this.buildHeaders(apiKey); + + try { + const resp = await $http.request({ + method: "POST", + url: apiUrl, + header, + body: { + messages: [{ + content: "You are a helpful assistant.", + role: "system", + }, { + content: "Test connection.", + role: "user", + }], + max_tokens: 5 + } + }); + + if (resp.data.error) { + const { statusCode } = resp.response; + const reason = (statusCode >= 400 && statusCode < 500) ? "param" : "api"; + handleValidateError(completion, { + type: reason, + message: resp.data.error, + troubleshootingLink: "https://bobtranslate.com/service/translate/azureopenai.html" + }); + return; + } + + if ((resp.data as OpenAiChatCompletion).choices.length > 0) { + completion({ result: true }); + } + } catch (error) { + handleValidateError(completion, error as ServiceError); + } + } +} diff --git a/src/adapter/gemini.ts b/src/adapter/gemini.ts new file mode 100644 index 0000000..9127c60 --- /dev/null +++ b/src/adapter/gemini.ts @@ -0,0 +1,123 @@ +import { HttpResponse, ServiceError, TextTranslateQuery, ValidationCompletion } from "@bob-translate/types"; +import type { OpenAiChatCompletion, GeminiResponse, ServiceAdapter } from "../types"; +import { generatePrompts, handleValidateError } from "../utils"; + +export class GeminiAdapter implements ServiceAdapter { + + private model = $option.model === "custom" ? $option.customModel : $option.model; + + private baseUrl = $option.apiUrl || 'https://generativelanguage.googleapis.com/v1beta/models'; + + public buildHeaders(apiKey: string): Record { + return { + 'Content-Type': 'application/json', + "x-goog-api-key": apiKey, + }; + } + + public buildRequestBody(query: TextTranslateQuery): unknown { + const { temperature } = $option; + + const { generatedSystemPrompt, generatedUserPrompt } = generatePrompts(query); + + return { + system_instruction: { + parts: { + text: generatedSystemPrompt + } + }, + contents: { + parts: { + text: generatedUserPrompt + } + }, + generationConfig: { + temperature: Number(temperature) ?? 0.2, + topK: 40, + topP: 0.95, + maxOutputTokens: 8192, + responseMimeType: "text/plain" + } + }; + } + + public getTextGenerationUrl(_apiUrl: string): string { + const { stream } = $option; + + const operationName = stream === "enable" ? 'streamGenerateContent' : 'generateContent'; + return `${this.baseUrl}/${this.model}:${operationName}`; + } + + public parseResponse(response: HttpResponse): string { + const { data } = response; + if (typeof data === 'object' && 'candidates' in data) { + if (!data?.candidates?.[0]?.content?.parts?.[0]?.text) { + throw new Error("Invalid response format from Gemini API"); + } + return data.candidates[0].content.parts[0].text.trim(); + } + + throw new Error("Unsupported response type"); + } + + public async testApiConnection( + apiKey: string, + apiUrl: string, + completion: ValidationCompletion, + ): Promise { + const header = this.buildHeaders(apiKey); + + try { + const resp = await $http.request({ + method: "GET", + url: apiUrl, + header + }); + + if (resp.data.error) { + handleValidateError(completion, { + type: "param", + message: resp.data.error, + troubleshootingLink: "https://bobtranslate.com/service/translate/gemini.html" + }); + return; + } + + if (resp.data.models?.length > 0) { + completion({ result: true }); + } + } catch (error) { + handleValidateError(completion, error as ServiceError); + } + } + + public handleStream( + streamData: { text: string }, + query: TextTranslateQuery, + targetText: string + ): string { + try { + const cleanedText = streamData.text.startsWith(',') + ? streamData.text.slice(1) + : streamData.text; + + const parsedChunk = JSON.parse(cleanedText); + const text = parsedChunk.candidates?.[0]?.content?.parts?.[0]?.text; + + if (text) { + targetText += text; + query.onStream({ + result: { + from: query.detectFrom, + to: query.detectTo, + toParagraphs: [targetText], + }, + }); + } + } catch (error) { + throw new Error('Failed to parse Gemini stream response'); + } + + return targetText; + } +} diff --git a/src/adapter/index.ts b/src/adapter/index.ts new file mode 100644 index 0000000..1c36eec --- /dev/null +++ b/src/adapter/index.ts @@ -0,0 +1,18 @@ +import { OpenAiAdapter } from './openai'; +import { GeminiAdapter } from './gemini'; +import { OpenAiCompatibleAdapter } from "./openai"; +import type { ServiceAdapter, ServiceProvider } from '../types'; +import { AzureOpenAiAdapter } from './azure-openai'; + +export const getServiceAdapter = (serviceProvider: ServiceProvider): ServiceAdapter => { + switch (serviceProvider) { + case 'gemini': + return new GeminiAdapter(); + case 'azure-openai': + return new AzureOpenAiAdapter(); + case 'openai-compatible': + return new OpenAiCompatibleAdapter(); + default: + return new OpenAiAdapter(); + } +} \ No newline at end of file diff --git a/src/adapter/openai.ts b/src/adapter/openai.ts new file mode 100644 index 0000000..8494b1f --- /dev/null +++ b/src/adapter/openai.ts @@ -0,0 +1,195 @@ +import { HttpResponse, ServiceError, TextTranslateQuery, ValidationCompletion } from "@bob-translate/types"; +import type { OpenAiChatCompletion, GeminiResponse, OpenAiModelList, ServiceAdapter } from "../types"; +import { generatePrompts, handleGeneralError, handleValidateError, replacePromptKeywords } from "../utils"; + +const isServiceError = (error: unknown): error is ServiceError => { + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof (error as ServiceError).message === 'string' + ); +} + +export class OpenAiAdapter implements ServiceAdapter { + + private baseUrl = $option.apiUrl || "https://api.openai.com"; + + private buffer = ''; + + private model = $option.model === "custom" ? $option.customModel : $option.model; + + public buildHeaders(apiKey: string): Record { + return { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }; + } + + public buildRequestBody(query: TextTranslateQuery) { + const { customSystemPrompt, customUserPrompt, temperature, stream } = $option; + const { generatedSystemPrompt, generatedUserPrompt } = generatePrompts(query); + + const systemPrompt = replacePromptKeywords(customSystemPrompt, query) || generatedSystemPrompt; + const userPrompt = replacePromptKeywords(customUserPrompt, query) || generatedUserPrompt; + + const modelTemperature = Number(temperature) ?? 0.2; + + return { + model: this.model, + temperature: modelTemperature, + max_tokens: 1000, + top_p: 1, + frequency_penalty: 1, + presence_penalty: 1, + stream: stream === "enable", + messages: [ + { + role: "system", + content: systemPrompt, + }, + { + role: "user", + content: userPrompt, + }, + ], + }; + } + + public parseResponse(response: HttpResponse): string { + const { data } = response; + if ('choices' in data) { + const { choices } = data; + if (!choices || choices.length === 0) { + throw new Error("No choices returned from API"); + } + let text = choices[0].message.content?.trim(); + + // 使用正则表达式删除字符串开头和结尾的特殊字符 + text = text?.replace(/^(『|「|"|")|(』|」|"|")$/g, ""); + + // 判断并删除字符串末尾的 `" =>` + if (text?.endsWith('" =>')) { + text = text.slice(0, -4); + } + return text || ''; + } + throw new Error("Unsupported response type"); + } + + public getTextGenerationUrl(_apiUrl: string): string { + return `${this.baseUrl}/v1/chat/completions`; + } + + protected getValidationUrl(_apiUrl: string): string { + return `${this.baseUrl}/v1/models`; + } + + private parseStreamResponse(text: string): string | null { + if (text === '[DONE]') { + return null; + } + + try { + const dataObj = JSON.parse(text); + // https://github.com/openai/openai-node/blob/master/src/resources/chat/completions#L190 + const { choices } = dataObj; + return choices[0]?.delta?.content || null; + } catch (error) { + throw error; + } + } + + public handleStream( + streamData: { text: string }, + query: TextTranslateQuery, + targetText: string + ): string { + this.buffer += streamData.text; + + while (true) { + const match = this.buffer.match(/data: (.*?})\n/); + if (match) { + const textFromResponse = match[1].trim(); + try { + const delta = this.parseStreamResponse(textFromResponse); + if (delta) { + targetText += delta; + query.onStream({ + result: { + from: query.detectFrom, + to: query.detectTo, + toParagraphs: [targetText], + }, + }); + } + } catch (error) { + if (isServiceError(error)) { + handleGeneralError(query, { + type: error.type || 'param', + message: error.message || 'Failed to parse JSON', + addition: error.addition, + }); + } else { + handleGeneralError(query, { + type: 'param', + message: 'An unknown error occurred', + }); + } + } + this.buffer = this.buffer.slice(match[0].length); + } else { + break; + } + } + + return targetText; + } + + + public async testApiConnection( + apiKey: string, + apiUrl: string, + completion: ValidationCompletion, + ): Promise { + const header = this.buildHeaders(apiKey); + const validationUrl = this.getValidationUrl(apiUrl); + + try { + const resp = await $http.request({ + method: "GET", + url: validationUrl, + header + }); + + if (resp.data.error) { + const { statusCode } = resp.response; + const reason = (statusCode >= 400 && statusCode < 500) ? "param" : "api"; + handleValidateError(completion, { + type: reason, + message: resp.data.error, + troubleshootingLink: "https://bobtranslate.com/service/translate/openai.html" + }); + return; + } + + const modelList = resp.data as OpenAiModelList; + if (modelList.data?.length > 0) { + completion({ result: true }); + } + } catch (error) { + handleValidateError(completion, error as ServiceError); + } + } +} + +export class OpenAiCompatibleAdapter extends OpenAiAdapter { + + public override getTextGenerationUrl(apiUrl: string): string { + return apiUrl; + } + + protected override getValidationUrl(apiUrl: string): string { + return apiUrl.replace(/\/chat\/completions$/, '/models'); + } +} \ No newline at end of file diff --git a/src/const.ts b/src/const.ts index 42e030f..d894388 100644 --- a/src/const.ts +++ b/src/const.ts @@ -26,7 +26,7 @@ export const HTTP_ERROR_CODES = { 425: "Too Early", 426: "Upgrade Required", 428: "Precondition Required", - 429: "请求过于频繁,请慢一点。OpenAI 对您在 API 上的请求实施速率限制。或是您的 API credits 已超支,需要充值。好消息是您仍然可以使用官方的 Web 端聊天页面", + 429: "Too Many Requests", 431: "Request Header Fields Too Large", 451: "Unavailable For Legal Reasons", 500: "Internal Server Error", diff --git a/src/main.ts b/src/main.ts index 956b535..a83c06a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,5 @@ -import { SYSTEM_PROMPT } from "./const"; -import { langMap, supportLanguageList } from "./lang"; -import type { ChatCompletion, ModelList, ServiceProvider } from "./types"; +import { supportLanguageList } from "./lang"; +import type { ServiceAdapter, ServiceProvider } from "./types"; import type { HttpResponse, PluginValidate, @@ -9,21 +8,15 @@ import type { TextTranslateQuery } from "@bob-translate/types"; import { - buildHeader, - ensureHttpsAndNoTrailingSlash, getApiKey, handleGeneralError, handleValidateError, - replacePromptKeywords } from "./utils"; +import { getServiceAdapter } from "./adapter"; +import { ensureHttpsAndNoTrailingSlash } from "./utils"; -function validateConfig( - serviceProvider: ServiceProvider, - apiKeys?: string, - apiUrl?: string, - model?: string, - customModel?: string -): ServiceError | null { +const validatePluginConfig = (): ServiceError | null => { + const { apiKeys, apiUrl, customModel, model, serviceProvider } = $option; if (serviceProvider !== 'openai' && !apiUrl) { return { @@ -45,7 +38,7 @@ function validateConfig( return { type: "param", message: "配置错误 - API URL 格式不正确", - addition: "Azure OpenAI 的 API URL 格式应为:https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/chat/completions?api-version=API_VERSION", + addition: "Azure OpenAI 的 API URL 格式应为:https://RESOURCE_NAME.openai.azure.com/openai/deployments/DEPLOYMENT_NAME/chat/completions?api-version=API_VERSION", troubleshootingLink: "https://bobtranslate.com/service/translate/azureopenai.html" }; } @@ -70,229 +63,60 @@ function validateConfig( return null; } -function getServiceConfig(serviceProvider: ServiceProvider, apiUrl?: string) { - switch (serviceProvider) { - case 'azure-openai': - return { - chatCompletionsUrl: apiUrl!, - isAzureOpenAi: true, - validateUrl: apiUrl!, - }; - case 'custom': - return { - chatCompletionsUrl: apiUrl!, - isAzureOpenAi: false, - validateUrl: apiUrl!.replace(/\/chat\/completions$/, '/models'), - }; - default: // openai - const baseUrl = ensureHttpsAndNoTrailingSlash(apiUrl || "https://api.openai.com"); - return { - chatCompletionsUrl: `${baseUrl}/v1/chat/completions`, - isAzureOpenAi: false, - validateUrl: `${baseUrl}/v1/models`, - }; - } -} - -const isServiceError = (error: unknown): error is ServiceError => { - return ( - typeof error === 'object' && - error !== null && - 'message' in error && - typeof (error as ServiceError).message === 'string' - ); -} - -const generatePrompts = (query: TextTranslateQuery): { - generatedSystemPrompt: string, - generatedUserPrompt: string -} => { - let generatedSystemPrompt = null; - const { detectFrom, detectTo } = query; - const sourceLang = langMap.get(detectFrom) || detectFrom; - const targetLang = langMap.get(detectTo) || detectTo; - let generatedUserPrompt = `translate from ${sourceLang} to ${targetLang}`; - - if (detectTo === "wyw" || detectTo === "yue") { - generatedUserPrompt = `翻译成${targetLang}`; - } - - if ( - detectFrom === "wyw" || - detectFrom === "zh-Hans" || - detectFrom === "zh-Hant" - ) { - if (detectTo === "zh-Hant") { - generatedUserPrompt = "翻译成繁体白话文"; - } else if (detectTo === "zh-Hans") { - generatedUserPrompt = "翻译成简体白话文"; - } else if (detectTo === "yue") { - generatedUserPrompt = "翻译成粤语白话文"; - } - } - if (detectFrom === detectTo) { - generatedSystemPrompt = - "You are a text embellisher, you can only embellish the text, don't interpret it."; - if (detectTo === "zh-Hant" || detectTo === "zh-Hans") { - generatedUserPrompt = "润色此句"; - } else { - generatedUserPrompt = "polish this sentence"; - } - } - - generatedUserPrompt = `${generatedUserPrompt}:\n\n${query.text}` - - return { - generatedSystemPrompt: generatedSystemPrompt ?? SYSTEM_PROMPT, - generatedUserPrompt - }; -} - -const buildRequestBody = (model: string, query: TextTranslateQuery) => { - let { customSystemPrompt, customUserPrompt, temperature } = $option; - const { generatedSystemPrompt, generatedUserPrompt } = generatePrompts(query); - - customSystemPrompt = replacePromptKeywords(customSystemPrompt, query); - customUserPrompt = replacePromptKeywords(customUserPrompt, query); - - const systemPrompt = customSystemPrompt || generatedSystemPrompt; - const userPrompt = customUserPrompt || generatedUserPrompt; - - const modelTemperature = Number(temperature ?? 0.2); - - const standardBody = { - model: model, - temperature: modelTemperature, - max_tokens: 1000, - top_p: 1, - frequency_penalty: 1, - presence_penalty: 1, - }; - - return { - ...standardBody, - model: model, - messages: [ - { - role: "system", - content: systemPrompt, - }, - { - role: "user", - content: userPrompt, - }, - ], - }; -} - -const handleStreamResponse = ( - query: TextTranslateQuery, - targetText: string, - textFromResponse: string -) => { - if (textFromResponse !== '[DONE]') { - try { - const dataObj = JSON.parse(textFromResponse); - // https://github.com/openai/openai-node/blob/master/src/resources/chat/completions#L190 - const { choices } = dataObj; - const delta = choices[0]?.delta?.content; - if (delta) { - targetText += delta; - query.onStream({ - result: { - from: query.detectFrom, - to: query.detectTo, - toParagraphs: [targetText], - }, - }); - } - } catch (error) { - if (isServiceError(error)) { - handleGeneralError(query, { - type: error.type || 'param', - message: error.message || 'Failed to parse JSON', - addition: error.addition, - }); - } else { - handleGeneralError(query, { - type: 'param', - message: 'An unknown error occurred', - }); - } - } - } - return targetText; -} - const handleGeneralResponse = ( query: TextTranslateQuery, - result: HttpResponse + result: HttpResponse, + adapter: ServiceAdapter ) => { - const { choices } = result.data as ChatCompletion; - - if (!choices || choices.length === 0) { + try { + const text = adapter.parseResponse(result); + query.onCompletion({ + result: { + from: query.detectFrom, + to: query.detectTo, + toParagraphs: text.split("\n"), + }, + }); + } catch (error) { handleGeneralError(query, { type: "api", - message: "接口未返回结果", + message: `接口未返回结果`, addition: JSON.stringify(result), }); - return; } - - let targetText = choices[0].message.content?.trim(); - - // 使用正则表达式删除字符串开头和结尾的特殊字符 - targetText = targetText?.replace(/^(『|「|"|“)|(』|」|"|”)$/g, ""); - - // 判断并删除字符串末尾的 `" =>` - if (targetText?.endsWith('" =>')) { - targetText = targetText.slice(0, -4); - } - - query.onCompletion({ - result: { - from: query.detectFrom, - to: query.detectTo, - toParagraphs: targetText!.split("\n"), - }, - }); } const translate: TextTranslate = (query) => { - const { apiKeys, apiUrl, customModel, model, serviceProvider, stream } = $option; - - const error = validateConfig( - serviceProvider as ServiceProvider, + const { apiKeys, apiUrl, - model, - customModel - ); + serviceProvider, + stream, + } = $option; + + const serviceAdapter = getServiceAdapter(serviceProvider as ServiceProvider); + const apiKey = getApiKey(apiKeys); + + const error = validatePluginConfig(); if (error) { handleGeneralError(query, error); return; } - const serviceConfig = getServiceConfig(serviceProvider as ServiceProvider, apiUrl); - const { chatCompletionsUrl, isAzureOpenAi } = serviceConfig; - - const modelValue = model === "custom" ? customModel : model; - const apiKey = getApiKey(apiKeys); - const header = buildHeader(isAzureOpenAi, apiKey); - const body = buildRequestBody(modelValue, query); + const textGenerationUrl = serviceAdapter.getTextGenerationUrl(ensureHttpsAndNoTrailingSlash(apiUrl)); + const header = serviceAdapter.buildHeaders(apiKey); + const body = serviceAdapter.buildRequestBody(query); - let targetText = ""; // 初始化拼接结果变量 - let buffer = ""; // 新增 buffer 变量 + let targetText = ""; (async () => { if (stream === "enable") { await $http.streamRequest({ method: "POST", - url: chatCompletionsUrl, + url: textGenerationUrl, header, body: { - ...body, - stream: true, + ...body as Record, }, cancelSignal: query.cancelSignal, streamHandler: (streamData) => { @@ -303,23 +127,18 @@ const translate: TextTranslate = (query) => { addition: "请在插件配置中填写正确的 API Keys", troubleshootingLink: "https://bobtranslate.com/service/translate/openai.html" }); - } else if (streamData.text !== undefined) { - // 将新的数据添加到缓冲变量中 - buffer += streamData.text; - // 检查缓冲变量是否包含一个完整的消息 - while (true) { - const match = buffer.match(/data: (.*?})\n/); - if (match) { - // 如果是一个完整的消息,处理它并从缓冲变量中移除 - const textFromResponse = match[1].trim(); - targetText = handleStreamResponse(query, targetText, textFromResponse); - buffer = buffer.slice(match[0].length); - } else { - // 如果没有完整的消息,等待更多的数据 - break; - } - } + return; + } + + if (!streamData.text) { + return; } + + targetText = serviceAdapter.handleStream( + { text: streamData.text }, + query, + targetText + ); }, handler: (result) => { if (result.response.statusCode >= 400) { @@ -338,15 +157,15 @@ const translate: TextTranslate = (query) => { } else { const result = await $http.request({ method: "POST", - url: chatCompletionsUrl, + url: textGenerationUrl, header, - body, + body: body as Record, }); if (result.error) { handleGeneralError(query, result); } else { - handleGeneralResponse(query, result); + handleGeneralResponse(query, result, serviceAdapter); } } })().catch((error) => { @@ -355,101 +174,24 @@ const translate: TextTranslate = (query) => { } const pluginValidate: PluginValidate = (completion) => { - const { apiKeys, apiUrl, customModel, model, serviceProvider } = $option; - - const error = validateConfig( - serviceProvider as ServiceProvider, - apiKeys, - apiUrl, - model, - customModel - ); + const { apiKeys, apiUrl, serviceProvider } = $option; + const apiKey = getApiKey(apiKeys); + const pluginConfigError = validatePluginConfig(); + const serviceAdapter = getServiceAdapter(serviceProvider as ServiceProvider); - if (error) { - handleValidateError(completion, error); + if (pluginConfigError) { + handleValidateError(completion, pluginConfigError); return; } - const serviceConfig = getServiceConfig(serviceProvider as ServiceProvider, apiUrl); - const { isAzureOpenAi, validateUrl } = serviceConfig; - const apiKey = getApiKey(apiKeys); - const header = buildHeader(isAzureOpenAi, apiKey); - - (async () => { - if (isAzureOpenAi) { - $http.request({ - method: "POST", - url: validateUrl, - header: header, - body: { - "messages": [{ - "content": "You are a helpful assistant.", - "role": "system", - }, { - "content": "Test connection.", - "role": "user", - }], - max_tokens: 5 - }, - handler: function (resp) { - const data = resp.data as { - error: string; - } - if (data.error) { - const { statusCode } = resp.response; - const reason = (statusCode >= 400 && statusCode < 500) ? "param" : "api"; - handleValidateError(completion, { - type: reason, - message: data.error, - troubleshootingLink: "https://bobtranslate.com/service/translate/azureopenai.html" - }); - return; - } - if ((resp.data as ChatCompletion).choices.length > 0) { - completion({ - result: true, - }) - } - } - }); - } else { - $http.request({ - method: "GET", - url: validateUrl, - header: header, - handler: function (resp) { - const data = resp.data as { - error: string; - } - if (data.error) { - const { statusCode } = resp.response; - const reason = (statusCode >= 400 && statusCode < 500) ? "param" : "api"; - handleValidateError(completion, { - type: reason, - message: data.error, - troubleshootingLink: "https://bobtranslate.com/service/translate/openai.html" - }); - return; - } - const modelList = resp.data as ModelList; - if (modelList.data?.length > 0) { - completion({ - result: true, - }) - } - } - }); - } - })().catch((error) => { + serviceAdapter.testApiConnection(apiKey, ensureHttpsAndNoTrailingSlash(apiUrl), completion).catch((error) => { handleValidateError(completion, error); }); } const pluginTimeoutInterval = () => 60; -function supportLanguages() { - return supportLanguageList.map(([standardLang]) => standardLang); -} +const supportLanguages = () => supportLanguageList.map(([standardLang]) => standardLang); export { pluginTimeoutInterval, diff --git a/src/types.ts b/src/types.ts index 653385f..7637b71 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,8 @@ // https://github.com/openai/openai-node/blob/master/src/resources/chat/completions.ts -interface ChatCompletionDelta { +import { HttpResponse, TextTranslateQuery, ValidationCompletion } from "@bob-translate/types"; + +interface OpenAiChatCompletionDelta { role: 'assistant'; content: string; /** @@ -12,7 +14,7 @@ interface ChatCompletionDelta { /** * A chat completion message generated by the model. */ -export interface ChatCompletionMessage { +export interface OpenAiChatCompletionMessage { /** * The contents of the message. */ @@ -30,9 +32,9 @@ export interface ChatCompletionMessage { } -interface ChatCompletionChoice { +interface OpenAiChatCompletionChoice { index: number; - delta: ChatCompletionDelta; + delta: OpenAiChatCompletionDelta; /** * The refusal message generated by the model. */ @@ -50,22 +52,22 @@ interface ChatCompletionChoice { /** * A chat completion message generated by the model. */ - message: ChatCompletionMessage; + message: OpenAiChatCompletionMessage; } -export interface ChatCompletion { +export interface OpenAiChatCompletion { id: string; object: string; created: number; model: string; system_fingerprint?: string; - choices: ChatCompletionChoice[]; + choices: OpenAiChatCompletionChoice[]; } /** * Describes an OpenAI model offering that can be used with the API. */ -interface Model { +interface OpenAiModel { /** * The model identifier, which can be referenced in the API endpoints. */ @@ -87,9 +89,54 @@ interface Model { owned_by: string; } -export interface ModelList { +export interface OpenAiModelList { object: string, - data: Model[] + data: OpenAiModel[] +} + +export interface GeminiResponse { + usageMetadata: { + promptTokenCount: number; + totalTokenCount: number; + candidatesTokenCount: number; + }; + modelVersion: string; + candidates: Array<{ + content: { + parts: Array<{ + text: string; + }>; + role: string; + }; + finishReason: string; + avgLogprobs: number; + }>; +} + +interface StreamHandler { + handleStream: ( + streamData: { text: string }, + query: TextTranslateQuery, + targetText: string + ) => string; +} + +export interface ServiceAdapter { + buildHeaders: (apiKey: string) => Record; + + buildRequestBody: (query: TextTranslateQuery) => unknown; + + parseResponse: (response: HttpResponse) => string; + + getTextGenerationUrl: (apiUrl: string) => string; + + testApiConnection: ( + apiKey: string, + apiUrl: string, + completion: ValidationCompletion, + ) => Promise; + + handleStream: StreamHandler['handleStream']; } -export type ServiceProvider = 'openai' | 'azure-openai' | 'custom'; +export type ServiceProvider = 'azure-openai' | 'gemini' | 'openai' | 'openai-compatible'; diff --git a/src/utils.ts b/src/utils.ts index f1020c9..3e050b0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,29 +4,17 @@ import type { TextTranslateQuery, ValidationCompletion } from "@bob-translate/types"; -import { HTTP_ERROR_CODES } from "./const"; +import { HTTP_ERROR_CODES, SYSTEM_PROMPT } from "./const"; +import { langMap } from "./lang"; - -function buildHeader(isAzureServiceProvider: boolean, apiKey: string): { - "Content-Type": string; - "api-key"?: string; - Authorization?: string; -} { - return { - "Content-Type": "application/json", - [isAzureServiceProvider ? "api-key" : "Authorization"]: - isAzureServiceProvider ? apiKey : `Bearer ${apiKey}`, - }; -} - -function ensureHttpsAndNoTrailingSlash(url: string): string { +export const ensureHttpsAndNoTrailingSlash = (url: string): string => { const hasProtocol = /^[a-z]+:\/\//i.test(url); const modifiedUrl = hasProtocol ? url : "https://" + url; return modifiedUrl.endsWith("/") ? modifiedUrl.slice(0, -1) : modifiedUrl; } -function getApiKey(apiKeys: string): string { +export const getApiKey = (apiKeys: string): string => { const trimmedApiKeys = apiKeys.endsWith(",") ? apiKeys.slice(0, -1) : apiKeys; @@ -34,10 +22,55 @@ function getApiKey(apiKeys: string): string { return apiKeySelection[Math.floor(Math.random() * apiKeySelection.length)]; } -function handleGeneralError( +export const generatePrompts = (query: TextTranslateQuery): { + generatedSystemPrompt: string, + generatedUserPrompt: string +} => { + let generatedSystemPrompt = null; + const { detectFrom, detectTo } = query; + const sourceLang = langMap.get(detectFrom) || detectFrom; + const targetLang = langMap.get(detectTo) || detectTo; + let generatedUserPrompt = `translate from ${sourceLang} to ${targetLang}`; + + if (detectTo === "wyw" || detectTo === "yue") { + generatedUserPrompt = `翻译成${targetLang}`; + } + + if ( + detectFrom === "wyw" || + detectFrom === "zh-Hans" || + detectFrom === "zh-Hant" + ) { + if (detectTo === "zh-Hant") { + generatedUserPrompt = "翻译成繁体白话文"; + } else if (detectTo === "zh-Hans") { + generatedUserPrompt = "翻译成简体白话文"; + } else if (detectTo === "yue") { + generatedUserPrompt = "翻译成粤语白话文"; + } + } + if (detectFrom === detectTo) { + generatedSystemPrompt = + "You are a text embellisher, you can only embellish the text, don't interpret it."; + if (detectTo === "zh-Hant" || detectTo === "zh-Hans") { + generatedUserPrompt = "润色此句"; + } else { + generatedUserPrompt = "polish this sentence"; + } + } + + generatedUserPrompt = `${generatedUserPrompt}:\n\n${query.text}` + + return { + generatedSystemPrompt: generatedSystemPrompt ?? SYSTEM_PROMPT, + generatedUserPrompt + }; +} + +export const handleGeneralError = ( query: TextTranslateQuery, error: ServiceError | HttpResponse -) { +) => { if ("response" in error) { // Handle HTTP response error const { statusCode } = error.response; @@ -61,10 +94,10 @@ function handleGeneralError( } } -function handleValidateError( +export const handleValidateError = ( completion: ValidationCompletion, error: ServiceError -) { +) => { completion({ result: false, error: { @@ -75,22 +108,16 @@ function handleValidateError( }); } -function replacePromptKeywords( +export const replacePromptKeywords = ( prompt: string, query: TextTranslateQuery -): string { - if (!prompt) return prompt; +): string => { + if (!prompt) { + return prompt; + } + return prompt .replace("$text", query.text) .replace("$sourceLang", query.detectFrom) .replace("$targetLang", query.detectTo); } - -export { - buildHeader, - ensureHttpsAndNoTrailingSlash, - getApiKey, - handleGeneralError, - handleValidateError, - replacePromptKeywords, -}; \ No newline at end of file