Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
- Fix: ActivityPubリクエストURLチェック実装は仕様に従っていないのを修正
- Fix: 連合無しモードでも外部から照会可能だった問題を修正
- Fix: テスト用WebHookのペイロードの`emojis`パラメータが実際のものと異なる問題を修正
- Enhance: add LLM translation support with OpenAI compatible API

## 2025.3.1

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

export class AddLlmTranslatorSupport1740989976502 {
name = 'AddLlmTranslatorSupport1740989976502'

async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableLlmTranslator" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "meta" ADD "enableLlmTranslatorRedisCache" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "meta" ADD "llmTranslatorRedisCacheTtl" integer NOT NULL DEFAULT '2880'`);
await queryRunner.query(`ALTER TABLE "meta" ADD "llmTranslatorBaseUrl" character varying(1024)`);
await queryRunner.query(`ALTER TABLE "meta" ADD "llmTranslatorApiKey" character varying(1024)`);
await queryRunner.query(`ALTER TABLE "meta" ADD "llmTranslatorModel" character varying(1024)`);
await queryRunner.query(`ALTER TABLE "meta" ADD "llmTranslatorTemperature" real`);
await queryRunner.query(`ALTER TABLE "meta" ADD "llmTranslatorTopP" real`);
await queryRunner.query(`ALTER TABLE "meta" ADD "llmTranslatorMaxTokens" integer`);
await queryRunner.query(`ALTER TABLE "meta" ADD "llmTranslatorSysPrompt" character varying(1024)`);
await queryRunner.query(`ALTER TABLE "meta" ADD "llmTranslatorUserPrompt" character varying(1024)`);
}

async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableLlmTranslator"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableLlmTranslatorRedisCache"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "llmTranslatorRedisCacheTtl"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "llmTranslatorBaseUrl"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "llmTranslatorApiKey"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "llmTranslatorModel"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "llmTranslatorTemperature"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "llmTranslatorTopP"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "llmTranslatorMaxTokens"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "llmTranslatorSysPrompt"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "llmTranslatorUserPrompt"`);
}
}
1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@
"oauth": "0.10.2",
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
"openai": "4.86.1",
"os-utils": "0.0.14",
"otpauth": "9.3.6",
"parse5": "7.2.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/core/entities/MetaEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export class MetaEntityService {
enableEmail: instance.enableEmail,
enableServiceWorker: instance.enableServiceWorker,

translatorAvailable: instance.deeplAuthKey != null,
translatorAvailable: instance.deeplAuthKey != null || instance.enableLlmTranslator,

serverRules: instance.serverRules,

Expand Down
60 changes: 60 additions & 0 deletions packages/backend/src/models/Meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,4 +664,64 @@ export class MiMeta {
nullable: true,
})
public googleAnalyticsMeasurementId: string | null;

@Column('boolean', {
default: false,
})
public enableLlmTranslator: boolean;

@Column('boolean', {
default: false,
})
public enableLlmTranslatorRedisCache: boolean;

@Column('integer', {
default: 2880,
})
public llmTranslatorRedisCacheTtl: number;

@Column('varchar', {
length: 1024,
nullable: true,
})
public llmTranslatorBaseUrl: string | null;

@Column('varchar', {
length: 1024,
nullable: true,
})
public llmTranslatorApiKey: string | null;

@Column('varchar', {
length: 1024,
nullable: true,
})
public llmTranslatorModel: string | null;

@Column('real', {
nullable: true,
})
public llmTranslatorTemperature: number | null;

@Column('real', {
nullable: true,
})
public llmTranslatorTopP: number | null;

@Column('integer', {
nullable: true,
})
public llmTranslatorMaxTokens: number | null;

@Column('varchar', {
length: 1024,
nullable: true,
})
public llmTranslatorSysPrompt: string | null;

@Column('varchar', {
length: 1024,
nullable: true,
})
public llmTranslatorUserPrompt: string | null;
}
57 changes: 56 additions & 1 deletion packages/backend/src/server/api/endpoints/admin/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,50 @@ export const meta = {
optional: false, nullable: false,
},
},
enableLlmTranslator: {
type: 'boolean',
optional: false, nullable: false,
},
enableLlmTranslatorRedisCache: {
type: 'boolean',
optional: false, nullable: false,
},
llmTranslatorRedisCacheTtl: {
type: 'number',
optional: false, nullable: false,
},
llmTranslatorBaseUrl: {
type: 'string',
optional: false, nullable: true,
},
llmTranslatorApiKey: {
type: 'string',
optional: false, nullable: true,
},
llmTranslatorModel: {
type: 'string',
optional: false, nullable: true,
},
llmTranslatorTemperature: {
type: 'number',
optional: false, nullable: true,
},
llmTranslatorTopP: {
type: 'number',
optional: false, nullable: true,
},
llmTranslatorMaxTokens: {
type: 'number',
optional: false, nullable: true,
},
llmTranslatorSysPrompt: {
type: 'string',
optional: false, nullable: true,
},
llmTranslatorUserPrompt: {
type: 'string',
optional: false, nullable: true,
},
},
},
} as const;
Expand Down Expand Up @@ -597,7 +641,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
defaultDarkTheme: instance.defaultDarkTheme,
enableEmail: instance.enableEmail,
enableServiceWorker: instance.enableServiceWorker,
translatorAvailable: instance.deeplAuthKey != null,
translatorAvailable: instance.deeplAuthKey != null || instance.enableLlmTranslator,
cacheRemoteFiles: instance.cacheRemoteFiles,
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
pinnedUsers: instance.pinnedUsers,
Expand Down Expand Up @@ -672,6 +716,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl,
federation: instance.federation,
federationHosts: instance.federationHosts,
enableLlmTranslator: instance.enableLlmTranslator,
enableLlmTranslatorRedisCache: instance.enableLlmTranslatorRedisCache,
llmTranslatorRedisCacheTtl: instance.llmTranslatorRedisCacheTtl,
llmTranslatorBaseUrl: instance.llmTranslatorBaseUrl,
llmTranslatorApiKey: instance.llmTranslatorApiKey,
llmTranslatorModel: instance.llmTranslatorModel,
llmTranslatorMaxTokens: instance.llmTranslatorMaxTokens,
llmTranslatorTemperature: instance.llmTranslatorTemperature,
llmTranslatorTopP: instance.llmTranslatorTopP,
llmTranslatorSysPrompt: instance.llmTranslatorSysPrompt,
llmTranslatorUserPrompt: instance.llmTranslatorUserPrompt,
};
});
}
Expand Down
75 changes: 75 additions & 0 deletions packages/backend/src/server/api/endpoints/admin/update-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,17 @@ export const paramDef = {
type: 'string',
},
},
enableLlmTranslator: { type: 'boolean' },
enableLlmTranslatorRedisCache: { type: 'boolean' },
llmTranslatorRedisCacheTtl: { type: 'integer' },
llmTranslatorBaseUrl: { type: 'string', nullable: true },
llmTranslatorApiKey: { type: 'string', nullable: true },
llmTranslatorModel: { type: 'string', nullable: true },
llmTranslatorTemperature: { type: 'number', nullable: true },
llmTranslatorTopP: { type: 'number', nullable: true },
llmTranslatorMaxTokens: { type: 'integer', nullable: true },
llmTranslatorSysPrompt: { type: 'string', nullable: true },
llmTranslatorUserPrompt: { type: 'string', nullable: true },
},
required: [],
} as const;
Expand Down Expand Up @@ -675,6 +686,70 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
}

if (ps.enableLlmTranslator !== undefined) {
set.enableLlmTranslator = ps.enableLlmTranslator;
}

if (ps.enableLlmTranslatorRedisCache !== undefined) {
set.enableLlmTranslatorRedisCache = ps.enableLlmTranslatorRedisCache;
}

if (ps.llmTranslatorRedisCacheTtl !== undefined) {
set.llmTranslatorRedisCacheTtl = ps.llmTranslatorRedisCacheTtl;
}

if (ps.llmTranslatorBaseUrl !== undefined) {
if (ps.llmTranslatorBaseUrl === '') {
set.llmTranslatorBaseUrl = null;
} else {
set.llmTranslatorBaseUrl = ps.llmTranslatorBaseUrl;
}
}

if (ps.llmTranslatorApiKey !== undefined) {
if (ps.llmTranslatorApiKey === '') {
set.llmTranslatorApiKey = null;
} else {
set.llmTranslatorApiKey = ps.llmTranslatorApiKey;
}
}

if (ps.llmTranslatorModel !== undefined) {
if (ps.llmTranslatorModel === '') {
set.llmTranslatorModel = null;
} else {
set.llmTranslatorModel = ps.llmTranslatorModel;
}
}

if (ps.llmTranslatorTemperature !== undefined) {
set.llmTranslatorTemperature = ps.llmTranslatorTemperature;
}

if (ps.llmTranslatorTopP !== undefined) {
set.llmTranslatorTopP = ps.llmTranslatorTopP;
}

if (ps.llmTranslatorMaxTokens !== undefined) {
set.llmTranslatorMaxTokens = ps.llmTranslatorMaxTokens;
}

if (ps.llmTranslatorSysPrompt !== undefined) {
if (ps.llmTranslatorSysPrompt === '') {
set.llmTranslatorSysPrompt = null;
} else {
set.llmTranslatorSysPrompt = ps.llmTranslatorSysPrompt;
}
}

if (ps.llmTranslatorUserPrompt !== undefined) {
if (ps.llmTranslatorUserPrompt === '') {
set.llmTranslatorUserPrompt = null;
} else {
set.llmTranslatorUserPrompt = ps.llmTranslatorUserPrompt;
}
}

const before = await this.metaService.fetch(true);

await this.metaService.update(set);
Expand Down
54 changes: 53 additions & 1 deletion packages/backend/src/server/api/endpoints/notes/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@

import { URLSearchParams } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import { OpenAI } from 'openai';
import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js';
import { MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';

export const meta = {
tags: ['notes'],
Expand Down Expand Up @@ -63,6 +65,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.meta)
private serverSettings: MiMeta,

@Inject(DI.redis)
private redisClient: Redis.Redis,

private noteEntityService: NoteEntityService,
private getterService: GetterService,
private httpRequestService: HttpRequestService,
Expand All @@ -87,6 +92,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return;
}

if (this.serverSettings.enableLlmTranslator) {
const res = await this.llmTranslate(note.text, ps.targetLang, note.id);
return {
text: res,
};
}

if (this.serverSettings.deeplAuthKey == null) {
throw new ApiError(meta.errors.unavailable);
}
Expand Down Expand Up @@ -123,4 +135,44 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
};
});
}

private async llmTranslate(text: string, targetLang: string, noteId: string): Promise<string> {
if (this.serverSettings.enableLlmTranslatorRedisCache) {
const key = `llmTranslate:${targetLang}:${noteId}`;
const cached = await this.redisClient.get(key);
if (cached != null) {
this.redisClient.expire(key, this.serverSettings.llmTranslatorRedisCacheTtl * 60);
return cached;
}
const res = await this.getLlmRes(text, targetLang);
await this.redisClient.set(key, res);
this.redisClient.expire(key, this.serverSettings.llmTranslatorRedisCacheTtl * 60);
return res;
} else {
return this.getLlmRes(text, targetLang);
}
}

private async getLlmRes(text: string, targetLang: string): Promise<string> {
const client = new OpenAI({
baseURL: this.serverSettings.llmTranslatorBaseUrl,
apiKey: this.serverSettings.llmTranslatorApiKey ?? '',
});
const message = [];
if (this.serverSettings.llmTranslatorSysPrompt) {
message.push({ role: 'system' as const, content: this.serverSettings.llmTranslatorSysPrompt.replace('{targetLang}', targetLang).replace('{text}', text) });
}
if (this.serverSettings.llmTranslatorUserPrompt) {
message.push({ role: 'user' as const, content: this.serverSettings.llmTranslatorUserPrompt.replace('{targetLang}', targetLang).replace('{text}', text) });
}
Comment thread
ybw2016v marked this conversation as resolved.
const completion = await client.chat.completions.create({
messages: message,
model: this.serverSettings.llmTranslatorModel ?? '',
temperature: this.serverSettings.llmTranslatorTemperature,
max_tokens: this.serverSettings.llmTranslatorMaxTokens,
top_p: this.serverSettings.llmTranslatorTopP,
});

return completion.choices[0].message.content ?? '';
}
}
2 changes: 1 addition & 1 deletion packages/frontend/src/components/MkNote.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<b v-if="translation.sourceLang">{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/components/MkNoteDetailed.vue
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<b v-if="translation.sourceLang">{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
</div>
</div>
Expand Down
Loading
Loading