Files
misskey/packages/backend/src/server/api/endpoints/notes/translate.ts

169 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { URLSearchParams } from 'node:url';
import { TextDecoder } from 'node:util';
import { Inject, Injectable } from '@nestjs/common';
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 { MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['notes'],
requireCredential: true,
kind: 'read:account',
res: {
type: 'object',
optional: true, nullable: false,
properties: {
sourceLang: { type: 'string' },
text: { type: 'string' },
},
},
errors: {
unavailable: {
message: 'Translate of notes unavailable.',
code: 'UNAVAILABLE',
id: '50a70314-2d8a-431b-b433-efa5cc56444c',
},
noSuchNote: {
message: 'No such note.',
code: 'NO_SUCH_NOTE',
id: 'bea9b03f-36e0-49c5-a4db-627a029f8971',
},
cannotTranslateInvisibleNote: {
message: 'Cannot translate invisible note.',
code: 'CANNOT_TRANSLATE_INVISIBLE_NOTE',
id: 'ea29f2ca-c368-43b3-aaf1-5ac3e74bbe5d',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
noteId: { type: 'string', format: 'misskey:id' },
targetLang: { type: 'string' },
},
required: ['noteId', 'targetLang'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
private noteEntityService: NoteEntityService,
private getterService: GetterService,
private httpRequestService: HttpRequestService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me.id);
if (!policies.canUseTranslator) {
throw new ApiError(meta.errors.unavailable);
}
const note = await this.getterService.getNote(ps.noteId).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err;
});
if (!(await this.noteEntityService.isVisibleForMe(note, me.id))) {
throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
}
if (note.text == null) {
return;
}
if (this.serverSettings.deeplAuthKey == null) {
throw new ApiError(meta.errors.unavailable);
}
let targetLang = ps.targetLang;
if (targetLang.includes('-')) targetLang = targetLang.split('-')[0];
const systemPrompt = this.serverSettings.deeplAuthKey;
const userPrompt = `pls translate to ${targetLang} ${note.text}`;
const endpoint = this.serverSettings.deeplIsPro ? 'https://chatapi.neko.ci/api/qwen/chat' : 'https://chatapi.neko.ci/api/mthreads/chat';
const body = {
prompt: [systemPrompt, userPrompt],
temperature: 0.6,
top_p: 0.8,
max_tokens: 3000,
};
const res = await this.httpRequestService.send(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
body: JSON.stringify(body),
timeout: 120_000,
});
let translatedText = '';
if (res.body) {
const decoder = new TextDecoder();
let buffer = '';
try {
outer: for await (const chunk of res.body) {
buffer += decoder.decode(chunk, { stream: true });
const messages = buffer.split('\n\n');
buffer = messages.pop() || '';
for (const msg of messages) {
if (msg.includes('event: complete')) {
const dataLine = msg.split('\n').find(line => line.startsWith('data:'));
if (dataLine) {
try {
const data = JSON.parse(dataLine.substring(6));
if (Array.isArray(data) && data.length > 0) {
translatedText = data[0];
res.body.destroy();
break outer;
}
} catch { }
}
}
}
}
} catch { }
}
const fullToHalfMap: { [key: string]: string } = {
'': ',', '。': '.', '': '!', '': '?', '': ':', '': ';',
'': '(', '': ')', '“': '"', '”': '"', '': '\'', '': '\'',
'【': '[', '】': ']', '、': ',', ' ': ' ',
};
let processedText = translatedText.replace(/[,。!?:;()“”‘’【】、 ]/g, m => fullToHalfMap[m]);
if (processedText.trim().length > 0) {
processedText = '\u200B\n\u200B' + processedText;
}
return {
sourceLang: 'AI',
text: processedText,
};
});
}
}