Skip to content

Commit

Permalink
feat(translate): 添加对OpenAI-compatible API的支持
Browse files Browse the repository at this point in the history
  • Loading branch information
nzh63 committed Apr 18, 2024
1 parent f8309b5 commit d31e19c
Show file tree
Hide file tree
Showing 11 changed files with 160 additions and 12 deletions.
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@
[![Release](https://github.com/nzh63/Ame/actions/workflows/release.yml/badge.svg)](https://github.com/nzh63/Ame/actions/workflows/release.yml)
[![CodeQL](https://github.com/nzh63/Ame/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/nzh63/Ame/actions/workflows/codeql-analysis.yml)

![例子](./doc/example.png)
![例子](./doc/example.webp)

## 功能
* 从正在运行的游戏中提取文本,支持Hook方式与OCR方式。
* 从翻译器中获取机器翻译结果,包括:
* 离线翻译器(JBeijing与Dr.eye)
* 离线翻译器需要自行购买与安装。
* 若干在线翻译器
* 需要自行购买与安装。
* 在线翻译器
* 可能需要付费与 API key
* 大语言模型
* 可能需要付费与 API key
* 使用语音合成朗读原文、译文。
* 翻译窗口随游戏窗口移动。
* 图形化的、易于配置的设置界面。

## 编译与运行
1. 首先安装[node.js](https://nodejs.org/en/)(v14+)、[yarn](https://yarnpkg.com/),然后:
2. 然后安装[python](https://www.python.org/)[Visual Studio](https://visualstudio.microsoft.com/vs),可以通过执行`yarn global add windows-build-tools`来安装它们
1. 首先安装[node.js](https://nodejs.org/en/)(v18+),安装过程中请勾选“Tools for Native Modules”。
2. 启用[corepack](https://yarnpkg.com/corepack)
3. 执行以下命令即可进行开发与调试。
```cmd
git clone https://github.com/nzh63/Ame
Expand All @@ -28,7 +31,7 @@
```

## 贡献
遵循一般的fork,branch,commit ,Pull request的流程。
遵循一般的fork,branch,commit,pull request的流程。

## 想要添加新的翻译器?
请参考[贡献](#贡献)一节,翻译器相关代码在[src/main/providers](./src/main/providers)下,实现相关逻辑即可,程序会自动根据选项的schema生成配置界面。
Expand Down
1 change: 1 addition & 0 deletions config/DevSpeedupPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default function devSpeedup() {
resolveId(id) {
if (id === 'lodash-es') id = 'lodash';
if (id.trim().startsWith('.')) return null;
if (id.includes('web-streams-polyfill')) return null;
let tryId = id;
while (tryId) {
try {
Expand Down
Binary file removed doc/example.png
Binary file not shown.
Binary file added doc/example.webp
Binary file not shown.
16 changes: 15 additions & 1 deletion src/main/manager/TranslateManager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,21 @@ export class TranslateManager extends BaseManager<TranslateProvider> {
for (const provider of this.providers) {
if (!provider.isReady()) continue;
provider.translate(originalText)
.then(translateText => callback(undefined, { providerId: provider.$id, key, originalText, translateText }))
.then(output => {
if (typeof output === 'string') {
callback(undefined, { providerId: provider.$id, key, originalText, translateText: output });
} else {
return new Promise<void>((resolve, reject) => {
let text = Buffer.alloc(0);
output.on('data', chunk => {
text = Buffer.concat([text, chunk]);
callback(undefined, { providerId: provider.$id, key, originalText, translateText: text.toString('utf-8') });
});
output.on('end', () => { resolve(); });
output.on('error', (err) => { reject(err); });
});
}
})
.catch((e) => callback(e, { providerId: provider.$id, key, originalText, translateText: '' }));
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/main/providers/TranslateProvider.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import type { Readable } from 'stream';
import type { Schema } from '@main/schema';
import { BaseProvider, BaseProviderMethods, BaseProviderOptions, Methods } from './BaseProvider';
import logger from '@logger/providers/translateProvider';

export type TranslateProviderMethods<ID extends string, S extends Schema, D, M extends Methods> = {
translate(text: string): Promise<string> | string;
translate(text: string): Promise<string> | string | Promise<Readable> | Readable;
} & BaseProviderMethods<ID, S, D, M, TranslateProvider<ID, S, D, M>>;

export type TranslateProviderConfig<ID extends string, S extends Schema, D, M extends Methods> = BaseProviderOptions<ID, S, D> & TranslateProviderMethods<ID, S, D, M>;

// eslint-disable-next-line @typescript-eslint/ban-types
export class TranslateProvider<ID extends string = string, S extends Schema = any, D = unknown, M extends Methods = {}> extends BaseProvider<ID, S, D, M, TranslateProviderConfig<ID, S, D, M>> {
public static override readonly providersStoreKey = 'translateProviders';
public async translate(text: string): Promise<string> {
public async translate(text: string): Promise<string | Readable> {
try {
return await this.$config.translate.call(this, text);
} catch (e) {
Expand Down
3 changes: 2 additions & 1 deletion src/main/providers/translate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Schema } from '@main/schema';
import type { TranslateProviderConfig, TranslateProviderMethods } from '@main/providers/TranslateProvider';
import type { BaseProviderOptions, Methods } from '@main/providers/BaseProvider';
import echo from './echo';
import openai from './openai';
import tencentcloud from './tencentcloud';
import baiduAi from './baiduAi';
import qqfanyi from './qqfanyi';
Expand All @@ -24,5 +25,5 @@ export function defineTranslateProvider<
return { ...arg, ...methods };
}

export const availableTranslateConfigs = [...(import.meta.env.DEV ? [echo] : []), tencentcloud, baiduAi, qqfanyi, youdaofanyi, baidufanyi, googleTranslate, JBeijing, DrEye] as const;
export const availableTranslateConfigs = [...(import.meta.env.DEV ? [echo] : []), openai, tencentcloud, baiduAi, qqfanyi, youdaofanyi, baidufanyi, googleTranslate, JBeijing, DrEye] as const;
export type AvailableTranslateConfigs = typeof availableTranslateConfigs;
100 changes: 100 additions & 0 deletions src/main/providers/translate/openai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { ChatCompletionMessageParam, ChatCompletionChunk } from 'openai/resources';
import { Readable } from 'stream';
import OpenAI from 'openai';
import { defineTranslateProvider } from '@main/providers/translate';

export default defineTranslateProvider({
id: 'OpenAI-Compatible API',
description: '你可能对以下链接感兴趣:\nhttps://platform.openai.com/docs/api-reference\nhttps://github.com/ggerganov/llama.cpp/blob/master/examples/server',
optionsSchema: {
enable: Boolean,
apiConfig: {
baseURL: String,
apiKey: String,
organization: String
},
chatConfig: {
model: String,
maxHistory: Number
}
},
optionsDescription: {
enable: '启用',
apiConfig: {
baseURL: 'Base URL',
apiKey: { readableName: 'API Key', description: 'The OpenAI API uses API keys for authentication. Visit your API Keys page to retrieve the API key you\'ll use in your requests.' },
organization: { readableName: '组织', description: 'For users who belong to multiple organizations, you can pass a header to specify which organization is used for an API request. Usage from these API requests will count as usage for the specified organization.' }
},
chatConfig: {
model: '模型',
maxHistory: '最长历史大小'
}
},
defaultOptions: {
enable: false,
apiConfig: {
baseURL: 'https://api.openai.com/v1',
apiKey: '',
organization: ''
},
chatConfig: {
model: 'gpt-4',
maxHistory: 100
}
},
data() {
return {
openai: null as OpenAI | null,
history: [{
role: 'system',
content: '请将用户输入的日文翻译为中文'
}] as ChatCompletionMessageParam[]
};
}
}, {
async init() {
this.openai = new OpenAI(this.apiConfig);
},
isReady() { return this.enable && !!this.openai; },
async translate(t) {
this.history.push({ role: 'user', content: t });
let cur: ChatCompletionMessageParam;
if (this.history.length && this.history[this.history.length - 1].role === 'assistant') {
cur = this.history[this.history.length - 1];
} else {
cur = { role: 'assistant', content: '' };
this.history.push(cur);
}
if (this.history.length > this.chatConfig.maxHistory) {
this.history = this.history.splice(1, this.history.length - this.chatConfig.maxHistory);
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const stream = await this.openai!.chat.completions.create({
model: this.chatConfig.model,
messages: this.history,
stream: true
});
const iter: AsyncIterator<ChatCompletionChunk> = stream[Symbol.asyncIterator]();
const readable = new Readable({
read: async () => {
try {
const { value, done } = await iter.next();
if (done) {
readable.push(null);
return;
}
const content = value.choices[0]?.delta?.content ?? '';
readable.push(content);
cur.content += content;
} catch (e) {
readable.destroy(e as Error);
}
},
destroy: () => {
stream.controller.abort();
}
});
return readable;
}

});
7 changes: 6 additions & 1 deletion src/render/views/Translator.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ export default defineComponent({
(result) => {
const text = texts.find(i => i.original === result.originalText);
if (result.key === hookCode && text) {
text.translate.push({ id: result.providerId, text: result.translateText });
const translate = text.translate.find(i => i.id === result.providerId);
if (translate) {
translate.text = result.translateText;
} else {
text.translate.push({ id: result.providerId, text: result.translateText });
}
updateWindowHeight();
}
},
Expand Down
9 changes: 8 additions & 1 deletion test/main/providers/translate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@ export function buildTest<C extends TranslateProviderConfig<string, any, unknown

suite.addTest(new Test('translate', async function(this: Mocha.Context) {
this.timeout(60000);
const result = await provider.translate('こんにちは。');
let result = await provider.translate('こんにちは。');
if (typeof result !== 'string') {
const chunks = [];
for await (const chunk of result) {
chunks.push(chunk);
}
result = Buffer.concat(chunks).toString();
}
expect(result).to.be.a('string').and.not.to.be.empty;
expect(result).to.match(/[你您]好[.。!!]?/);
}));
Expand Down
16 changes: 16 additions & 0 deletions test/main/providers/translate/openai.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import '../../env';
import openai from '@main/providers/translate/openai';
import { buildTest } from '.';

buildTest(openai, {
enable: true,
apiConfig: {
baseURL: process.env.TEST_PROVIDERS_TRANSLATE_OPENAI_BASEURL ?? '',
apiKey: process.env.TEST_PROVIDERS_TRANSLATE_OPENAI_API_KEY ?? '',
organization: process.env.TEST_PROVIDERS_TRANSLATE_OPENAI_ORGANIZATION ?? ''
},
chatConfig: {
model: 'gpt-4',
maxHistory: 100
}
}, !process.env.TEST_PROVIDERS_TRANSLATE_OPENAI_BASEURL && !process.env.TEST_PROVIDERS_TRANSLATE_OPENAI_API_KEY);

0 comments on commit d31e19c

Please sign in to comment.