Translate: add support for Kagi Translate (#3416)

Co-authored-by: Vending Machine <vendicated@riseup.net>
This commit is contained in:
Ruben 2026-05-02 14:59:22 +02:00 • committed by GitHub
parent 14508c1537
commit 9794ab1cba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 523 additions and 31 deletions

0
scripts/build/build.mjs Executable file → Normal file
View file

View file

@ -107,7 +107,14 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
return <Forms.FormText>There are no settings for this plugin.</Forms.FormText>;
const options = Object.entries(plugin.options).map(([key, setting]) => {
if (setting.type === OptionType.CUSTOM || setting.hidden) return null;
if (setting.type === OptionType.CUSTOM) return null;
const shouldHide = "hidden" in setting && (
typeof setting.hidden === "function"
? setting.hidden.call(plugin.settings)
: setting.hidden
);
if (shouldHide) return null;
function onChange(newValue: any) {
const option = plugin.options?.[key];

View file

@ -63,9 +63,9 @@ let tooltipTimeout: any;
export default definePlugin({
name: "Translate",
description: "Translate messages with Google Translate or DeepL",
description: "Translate messages with Google Translate, DeepL or Kagi.",
tags: ["Chat", "Utility"],
authors: [Devs.Ven, Devs.AshtonMemer],
authors: [Devs.Ven, Devs.AshtonMemer, Devs.koish1],
settings,
contextMenus: {
"message": messageCtxPatch

View file

@ -33,6 +33,7 @@ copy(Object.fromEntries(
export type GoogleLanguage = keyof typeof GoogleLanguages;
export type DeeplLanguage = keyof typeof DeeplLanguages;
export type KagiLanguage = keyof typeof KagiLanguages;
export const GoogleLanguages = {
"auto": "Detect language",
@ -318,3 +319,392 @@ export function deeplLanguageToGoogleLanguage(language: string) {
return language;
}
}
// Generated using:
// curl https://translate.kagi.com/api/list-languages | jq 'map({(.language | ascii_downcase): .name}) | add | to_entries | sort_by(.value) | from_entries'
// Add "auto": "Detect language", as kagi has no "language" for Detect Language, however anything not
// in the list will work as auto. Using "auto" will have this work using Google Translate defaults.
// Retrieved 2025.06.16
export const KagiLanguages = {
"auto": "Detect language",
"om": "(Afan)/Oromoor/Oriya",
"ab": "Abkhazian",
"ace": "Acehnese",
"ach": "Acholi",
"aa": "Afar",
"af": "Afrikaans",
"ak": "Akan",
"sq": "Albanian",
"alz": "Alur",
"am": "Amharic",
"ar": "Arabic",
"ar_dz": "Arabic (Algeria)",
"ar_eg": "Arabic (Egypt)",
"ar_iq": "Arabic (Iraq)",
"ar_jo": "Arabic (Jordan)",
"ar_kw": "Arabic (Kuwait)",
"ar_lb": "Arabic (Lebanon)",
"ar_ly": "Arabic (Libya)",
"ar_ma": "Arabic (Morocco)",
"ar_qa": "Arabic (Qatar)",
"ar_sa": "Arabic (Saudi Arabia)",
"ar_sy": "Arabic (Syria)",
"ar_tn": "Arabic (Tunisia)",
"ar_ae": "Arabic (UAE)",
"an": "Aragonese",
"hy": "Armenian",
"as": "Assamese",
"av": "Avaric",
"ae": "Avestan",
"ava": "Awadhi",
"ay": "Aymara",
"az": "Azeri",
"ban": "Balinese",
"bal": "Baluchi",
"bm": "Bambara",
"bci": "Baoulé",
"ba": "Bashkir",
"eu": "Basque",
"btk": "Batak",
"be": "Belarusian",
"bem": "Bemba",
"bn": "Bengali",
"bn_bd": "Bengali (Bangladesh)",
"bn_in": "Bengali (India)",
"bew": "Betawi",
"bho": "Bhojpuri",
"dz": "Bhutani",
"bh": "Bihari",
"bcl": "Bikol",
"bi": "Bislama",
"bs": "Bosnian",
"br": "Breton",
"bg": "Bulgarian",
"my": "Burmese",
"bua": "Buryat",
"km": "Cambodian",
"yue": "Cantonese",
"ca": "Catalan",
"ceb": "Cebuano",
"ch": "Chamorro",
"ce": "Chechen",
"chr": "Cherokee",
"ny": "Chichewa",
"zh": "Chinese",
"zh_hk": "Chinese (Hong Kong)",
"zh_mo": "Chinese (Macau)",
"zh_cn": "Chinese (Simplified)",
"zh_sg": "Chinese (Singapore)",
"zh_tw": "Chinese (Traditional)",
"cu": "Church Slavonic",
"chk": "Chuukese",
"cv": "Chuvash",
"kw": "Cornish",
"co": "Corsican",
"cr": "Cree",
"crh": "Crimean Tatar",
"hr": "Croatian",
"hr_ba": "Croatian (Bosnia and Herzegovina)",
"hr_hr": "Croatian (Croatia)",
"cs": "Czech",
"da": "Danish",
"din": "Dinka",
"div": "Divehi",
"dv": "Divehi",
"doi": "Dogri",
"dov": "Dombe",
"nl": "Dutch",
"nl_aw": "Dutch (Aruba)",
"nl_be": "Dutch (Belgium)",
"nl_cw": "Dutch (Curaçao)",
"nl_nl": "Dutch (Netherlands)",
"nl_sr": "Dutch (Suriname)",
"dyu": "Dyula",
"elvish": "Elvish (Sindarin)",
"emoji": "Emoji Speak",
"en": "English",
"en_au": "English (Australia)",
"en_ca": "English (Canada)",
"en_in": "English (India)",
"en_ie": "English (Ireland)",
"en_nz": "English (New Zealand)",
"en_ph": "English (Philippines)",
"en_sg": "English (Singapore)",
"en_za": "English (South Africa)",
"en_gb": "English (UK)",
"en_us": "English (US)",
"eo": "Esperanto",
"et": "Estonian",
"ee": "Ewe",
"mk": "FYRO Macedonian",
"fo": "Faroese",
"fa": "Farsi",
"fj": "Fiji",
"fil": "Filipino",
"fi": "Finnish",
"fon": "Fon",
"fr": "French",
"fr_be": "French (Belgium)",
"fr_ca": "French (Canada)",
"fr_ci": "French (Côte d'Ivoire)",
"fr_fr": "French (France)",
"fr_sn": "French (Senegal)",
"fr_ch": "French (Switzerland)",
"fy": "Frisian",
"fur": "Friulian",
"ff": "Fulah",
"gaa": "Ga",
"gd": "Gaelic",
"gl": "Galician",
"lg": "Ganda",
"ka": "Georgian",
"de": "German",
"de_at": "German (Austria)",
"de_de": "German (Germany)",
"de_li": "German (Liechtenstein)",
"de_lu": "German (Luxembourg)",
"el": "Greek",
"kl": "Greenlandic",
"gn": "Guarani",
"gu": "Gujarati",
"ht": "Haitian",
"ha": "Hausa",
"haw": "Hawaiian",
"he": "Hebrew",
"hz": "Herero",
"hil": "Hiligaynon",
"hi": "Hindi",
"hi_fj": "Hindi (Fiji)",
"hi_in": "Hindi (India)",
"ho": "Hiri Motu",
"hu": "Hungarian",
"hrx": "Hunsrik",
"iba": "Iban",
"is": "Icelandic",
"io": "Ido",
"ig": "Igbo",
"ilo": "Ilocano",
"id": "Indonesian",
"ia": "Interlingua",
"ie": "Interlingue",
"iu": "Inuktitut",
"ik": "Inupiak",
"ga": "Irish",
"it": "Italian",
"it_it": "Italian (Italy)",
"it_sm": "Italian (San Marino)",
"it_ch": "Italian (Switzerland)",
"it_va": "Italian (Vatican City)",
"jam": "Jamaican Patois",
"ja": "Japanese",
"jv": "Javanese",
"cpe": "Jingpo",
"kn": "Kannada",
"kr": "Kanuri",
"ks": "Kashmiri",
"kk": "Kazakh",
"kha": "Khasi",
"ki": "Kikuyu",
"rw": "Kinyarwanda",
"ky": "Kirghiz",
"rn": "Kirundi",
"ktu": "Kituba",
"klingon": "Klingon",
"kv": "Komi",
"kg": "Kongo",
"kok": "Konkani",
"ko": "Korean",
"ko_kp": "Korean (North Korea)",
"ko_kr": "Korean (South Korea)",
"kj": "Kuanyama",
"ku": "Kurdish",
"ckb": "Kurdish (Sorani)",
"lo": "Laothian",
"ltg": "Latgalian",
"la": "Latin",
"lv": "Latvian",
"lij": "Ligurian",
"li": "Limburgan",
"lim": "Limburgish",
"ln": "Lingala",
"lt": "Lithuanian",
"lmo": "Lombard",
"lu": "Luba_Katanga",
"luo": "Luo",
"lb": "Luxembourgish",
"mad": "Madurese",
"mai": "Maithili",
"mak": "Makassar",
"mg": "Malagasy",
"ms": "Malay",
"ms_bn": "Malay (Brunei Darussalam)",
"ms_id": "Malay (Indonesia)",
"ms_arab": "Malay (Jawi)",
"ms_my": "Malay (Malaysia)",
"ms_sg": "Malay (Singapore)",
"ml": "Malayalam",
"mt": "Maltese",
"gv": "Manx",
"mi": "Maori",
"mr": "Marathi",
"mh": "Marshallese",
"mwr": "Marwadi",
"middle_english": "Middle English",
"mo": "Moldavian",
"mn": "Mongolian",
"nqo": "NKo",
"nhe": "Nahuatl",
"na": "Nauru",
"nv": "Navajo",
"ndc": "Ndau",
"ng": "Ndonga",
"new": "Nepalbhasa",
"ne": "Nepali",
"nd": "North Ndebele",
"ns": "Northern Sotho",
"nb": "Norwegian (Bokmål)",
"nn": "Norwegian (Nynorsk)",
"nus": "Nuer",
"oc": "Occitan",
"oj": "Ojibwa",
"or": "Oriya",
"os": "Ossetian",
"pi": "Pali",
"pag": "Pangasinan",
"pap": "Papiamento",
"ps": "Pashto/Pushto",
"per": "Persian",
"fa_af": "Persian (Afghanistan)",
"fa_ir": "Persian (Iran)",
"fa_tj": "Persian (Tajikistan)",
"pirate": "Pirate Speak",
"pl": "Polish",
"pt": "Portuguese",
"pt_ao": "Portuguese (Angola)",
"pt_br": "Portuguese (Brazil)",
"pt_cv": "Portuguese (Cape Verde)",
"pt_mz": "Portuguese (Mozambique)",
"pt_pt": "Portuguese (Portugal)",
"pa": "Punjabi",
"qu": "Quechua",
"qu_bo": "Quechua (Bolivia)",
"qu_ec": "Quechua (Ecuador)",
"qu_pe": "Quechua (Peru)",
"kek": "Qʼeqchiʼ",
"rm": "Rhaeto_Romanic",
"rom": "Romani",
"ro": "Romanian",
"ru": "Russian",
"ru_by": "Russian (Belarus)",
"ru_kz": "Russian (Kazakhstan)",
"ru_kg": "Russian (Kyrgyzstan)",
"ru_ru": "Russian (Russia)",
"se": "Sami",
"se_fi": "Sami (Finland)",
"se_no": "Sami (Norway)",
"se_se": "Sami (Sweden)",
"sm": "Samoan",
"sg": "Sangro",
"sa": "Sanskrit",
"sat": "Santali",
"sc": "Sardinian",
"sr": "Serbian",
"sr_ba": "Serbian (Bosnia and Herzegovina)",
"sr_sp": "Serbian (Serbia and Montenegro)",
"sh": "Serbo_Croatian",
"st": "Sesotho",
"shn": "Shan",
"sn": "Shona",
"ii": "Sichuan Yi",
"scn": "Sicilian",
"szl": "Silesian",
"sd": "Sindhi",
"si": "Singhalese",
"ss": "Siswati",
"sk": "Slovak",
"sl": "Slovenian",
"so": "Somali",
"sb": "Sorbian",
"nr": "South Ndebele",
"es": "Spanish",
"es_ar": "Spanish (Argentina)",
"es_bo": "Spanish (Bolivia)",
"es_cl": "Spanish (Chile)",
"es_co": "Spanish (Colombia)",
"es_cr": "Spanish (Costa Rica)",
"es_cu": "Spanish (Cuba)",
"es_do": "Spanish (Dominican Republic)",
"es_ec": "Spanish (Ecuador)",
"es_sv": "Spanish (El Salvador)",
"es_gq": "Spanish (Equatorial Guinea)",
"es_gt": "Spanish (Guatemala)",
"es_hn": "Spanish (Honduras)",
"es_mx": "Spanish (Mexico)",
"es_ni": "Spanish (Nicaragua)",
"es_pa": "Spanish (Panama)",
"es_py": "Spanish (Paraguay)",
"es_pe": "Spanish (Peru)",
"es_pr": "Spanish (Puerto Rico)",
"es_es": "Spanish (Spain)",
"es_uy": "Spanish (Uruguay)",
"es_ve": "Spanish (Venezuela)",
"su": "Sundanese",
"sus": "Susu",
"sx": "Sutu",
"sw": "Swahili",
"sv": "Swedish",
"sv_fi": "Swedish (Finland)",
"sv_se": "Swedish (Sweden)",
"gsw": "Swiss German",
"de_ch": "Swiss High German",
"syr": "Syriac",
"tl": "Tagalog",
"ty": "Tahitian",
"tg": "Tajik",
"ta": "Tamil",
"ta_in": "Tamil (India)",
"ta_my": "Tamil (Malaysia)",
"ta_sg": "Tamil (Singapore)",
"ta_lk": "Tamil (Sri Lanka)",
"tt": "Tatar",
"te": "Telugu",
"tet": "Tetum",
"th": "Thai",
"bo": "Tibetan",
"ti": "Tigrinya",
"tiv": "Tiv",
"tpi": "Tok Pisin",
"to": "Tonga",
"ts": "Tsonga",
"tn": "Tswana",
"tum": "Tumbuka",
"tr": "Turkish",
"tr_cy": "Turkish (Cyprus)",
"tr_tr": "Turkish (Turkey)",
"tk": "Turkmen",
"tyv": "Tuvan",
"tw": "Twi",
"udm": "Udmurt",
"ug": "Uighur",
"uk": "Ukrainian",
"ur": "Urdu",
"ur_in": "Urdu (India)",
"ur_pk": "Urdu (Pakistan)",
"uz": "Uzbek",
"ve": "Venda",
"vec": "Venetian",
"vi": "Vietnamese",
"vo": "Volapuk",
"wa": "Walloon",
"war": "Waray",
"cy": "Welsh",
"wo": "Wolof",
"xh": "Xhosa",
"sah": "Yakut",
"yi": "Yiddish",
"yo": "Yoruba",
"yua": "Yucatec Maya",
"zap": "Zapotec",
"za": "Zhuang",
"zu": "Zulu"
} as const;

View file

@ -27,3 +27,28 @@ export async function makeDeeplTranslateRequest(_: IpcMainInvokeEvent, pro: bool
return { status: -1, data: String(e) };
}
}
export async function makeKagiTranslateRequest(_: IpcMainInvokeEvent, token: string, text: string, sourceLang: string, targetLang: string) {
const url = "https://translate.kagi.com/api/translate";
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Cookie": `kagi_session=${token}`
},
body: JSON.stringify({
text,
from: sourceLang,
to: targetLang,
model: "standard"
}),
});
const data = await res.json();
return { status: res.status, data };
} catch (e) {
return { status: -1, data: String(e) };
}
}

View file

@ -44,24 +44,27 @@ export const settings = definePluginSettings({
default: "en",
hidden: true
},
service: {
type: OptionType.SELECT,
description: IS_WEB ? "Translation service (Not supported on Web!)" : "Translation service",
disabled: () => IS_WEB,
hidden: IS_WEB,
options: [
{ label: "Google Translate", value: "google", default: true },
{ label: "DeepL Free", value: "deepl" },
{ label: "DeepL Pro", value: "deepl-pro" }
{ label: "DeepL Free — API key required", value: "deepl" },
{ label: "DeepL Pro — API key required", value: "deepl-pro" },
{ label: "Kagi Translate — API key required", value: "kagi" }
] as const,
onChange: resetLanguageDefaults
},
deeplApiKey: {
type: OptionType.STRING,
description: "DeepL API key",
default: "",
placeholder: "Get your API key from https://deepl.com/your-account",
disabled: () => IS_WEB
description: "Get your API key from https://deepl.com/your-account",
default: ""
},
kagiSession: {
type: OptionType.STRING,
description: "Get your session token from https://kagi.com/settings?p=user_details",
default: ""
},
autoTranslate: {
type: OptionType.BOOLEAN,
@ -73,12 +76,19 @@ export const settings = definePluginSettings({
description: "Show a tooltip on the ChatBar button whenever a message is automatically translated",
default: true
},
}, {
deeplApiKey: {
hidden() { return this.store.service !== "deepl" && this.store.service !== "deepl-pro"; }
},
kagiSession: {
hidden() { return this.store.service !== "kagi"; }
}
}).withPrivateSettings<{
showAutoTranslateAlert: boolean;
}>();
export function resetLanguageDefaults() {
if (IS_WEB || settings.store.service === "google") {
if (IS_WEB || settings.store.service === "google" || settings.store.service === "kagi") {
settings.store.receivedInput = "auto";
settings.store.receivedOutput = "en";
settings.store.sentInput = "auto";

View file

@ -21,7 +21,7 @@ import { onlyOnce } from "@utils/onlyOnce";
import { PluginNative } from "@utils/types";
import { showToast, Toasts } from "@webpack/common";
import { DeeplLanguages, deeplLanguageToGoogleLanguage, GoogleLanguages } from "./languages";
import { DeeplLanguages, deeplLanguageToGoogleLanguage, GoogleLanguages, KagiLanguages } from "./languages";
import { resetLanguageDefaults, settings } from "./settings";
export const cl = classNameFactory("vc-trans-");
@ -40,19 +40,43 @@ interface DeeplData {
}[];
}
interface KagiData {
translation: string;
detected_language: {
label: string;
};
}
export interface TranslationValue {
sourceLanguage: string;
text: string;
}
export const getLanguages = () => IS_WEB || settings.store.service === "google"
? GoogleLanguages
: DeeplLanguages;
export const getLanguages = () => {
if (IS_WEB) {
return GoogleLanguages;
}
switch (settings.store.service) {
case "google":
return GoogleLanguages;
case "kagi":
return KagiLanguages;
default:
return DeeplLanguages;
}
};
export async function translate(kind: "received" | "sent", text: string): Promise<TranslationValue> {
const translate = IS_WEB || settings.store.service === "google"
? googleTranslate
: deeplTranslate;
const translate = IS_WEB ? googleTranslate : (() => {
switch (settings.store.service) {
case "google":
return googleTranslate;
case "kagi":
return kagiTranslate;
default:
return deeplTranslate;
}
})();
try {
return await translate(
@ -153,3 +177,25 @@ async function deeplTranslate(text: string, sourceLang: string, targetLang: stri
text: translations[0].text
};
}
async function kagiTranslate(text: string, sourceLang: string, targetLang: string): Promise<TranslationValue> {
const { status, data } = await Native.makeKagiTranslateRequest(
settings.store.kagiSession, text, sourceLang, targetLang
);
switch (status) {
case 200:
break;
case 401:
throw "Invalid or expired Kagi session token";
default:
throw new Error(`Failed to translate "${text}" (${sourceLang} -> ${targetLang})\n${status} ${data}`);
}
const { detected_language, translation }: KagiData = data;
return {
sourceLanguage: detected_language.label,
text: translation
};
}

View file

@ -82,7 +82,12 @@ export function buildPluginMenuEntries(includeEmpty = false) {
let hasAnyOption = false;
if (p.options) for (const [key, option] of Object.entries(p.options)) {
if ("hidden" in option && option.hidden) continue;
const shouldHide = "hidden" in option && (
typeof option.hidden === "function"
? option.hidden.call(p.settings)
: option.hidden
);
if (shouldHide) continue;
hasAnyOption = true;

View file

@ -609,6 +609,11 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "Cootshk",
id: 921605971577548820n
},
koish1: {
name: "koish1",
id: 291089948709486593n,
badge: false,
},
thororen: {
name: "thororen",
id: 848339671629299742n

View file

@ -259,8 +259,8 @@ export const enum OptionType {
export type SettingsDefinition = Record<string, PluginSettingDef>;
export type SettingsChecks<D extends SettingsDefinition> = {
[K in keyof D]?: D[K] extends PluginSettingComponentDef ? IsDisabled<DefinedSettings<D>> :
(IsDisabled<DefinedSettings<D>> & IsValid<PluginSettingType<D[K]>, DefinedSettings<D>>);
[K in keyof D]?: D[K] extends PluginSettingComponentDef ? IsDisabledOrHidden<DefinedSettings<D>> :
(IsDisabledOrHidden<DefinedSettings<D>> & IsValid<PluginSettingType<D[K]>, DefinedSettings<D>>);
};
export type PluginSettingDef =
@ -286,18 +286,22 @@ export interface PluginSettingCommon {
/**
* Hide this setting from the settings UI
*/
hidden?: boolean;
hidden?: boolean | (() => boolean);
/**
* Set this if the setting only works on Browser or Desktop, not both
*/
target?: "WEB" | "DESKTOP" | "BOTH";
}
interface IsDisabled<D = unknown> {
interface IsDisabledOrHidden<D = unknown> {
/**
* Checks if this setting should be disabled
*/
disabled?(this: D): boolean;
/**
* Checks if this setting should be hidden
*/
hidden?(this: D): boolean;
}
interface IsValid<T, D = unknown> {
@ -432,7 +436,7 @@ export type IpcRes<V = any> = { ok: true; value: V; } | { ok: false, error: any;
/* Legacy Options Types */
/* -------------------------------------------- */
export type PluginOptionBase = PluginSettingCommon & IsDisabled;
export type PluginOptionBase = PluginSettingCommon & IsDisabledOrHidden;
export type PluginOptionsItem =
| PluginOptionString
| PluginOptionNumber
@ -441,12 +445,12 @@ export type PluginOptionsItem =
| PluginOptionSlider
| PluginOptionComponent
| PluginOptionCustom;
export type PluginOptionString = PluginSettingStringDef & PluginSettingCommon & IsDisabled & IsValid<string>;
export type PluginOptionNumber = (PluginSettingNumberDef | PluginSettingBigIntDef) & PluginSettingCommon & IsDisabled & IsValid<number | BigInt>;
export type PluginOptionBoolean = PluginSettingBooleanDef & PluginSettingCommon & IsDisabled & IsValid<boolean>;
export type PluginOptionSelect = PluginSettingSelectDef & PluginSettingCommon & IsDisabled & IsValid<PluginSettingSelectOption>;
export type PluginOptionSlider = PluginSettingSliderDef & PluginSettingCommon & IsDisabled & IsValid<number>;
export type PluginOptionComponent = PluginSettingComponentDef & Omit<PluginSettingCommon, "description" | "placeholder">;
export type PluginOptionString = PluginSettingStringDef & PluginOptionBase & IsValid<string>;
export type PluginOptionNumber = (PluginSettingNumberDef | PluginSettingBigIntDef) & PluginOptionBase & IsValid<number | BigInt>;
export type PluginOptionBoolean = PluginSettingBooleanDef & PluginOptionBase & IsValid<boolean>;
export type PluginOptionSelect = PluginSettingSelectDef & PluginOptionBase & IsValid<PluginSettingSelectOption>;
export type PluginOptionSlider = PluginSettingSliderDef & PluginOptionBase & IsValid<number>;
export type PluginOptionComponent = PluginSettingComponentDef & Omit<PluginOptionBase, "description" | "placeholder">;
export type PluginOptionCustom = PluginSettingCustomDef & Pick<PluginSettingCommon, "onChange">;
export type PluginNative<PluginExports extends Record<string, (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any>> = {