refactor: add kv cache for translation backend (#1184)

This commit is contained in:
Huang Xin
2025-05-18 23:16:44 +08:00
committed by GitHub
parent e49dd4accb
commit 4523437e34
8 changed files with 150 additions and 70 deletions
@@ -4,7 +4,6 @@ import { Position } from '@/utils/sel';
import { useAuth } from '@/context/AuthContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { getAPIBaseUrl } from '@/services/environment';
import { TRANSLATED_LANGS } from '@/services/constants';
@@ -119,17 +118,13 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, token, sourceLang, targetLang]);
const popupPadding = useResponsiveSize(10);
const maxHeight = window.innerHeight - 2 * popupPadding;
const popupMaxHeight = Math.min(360, maxHeight);
return (
<div>
<Popup
trianglePosition={trianglePosition}
width={popupWidth}
minHeight={popupHeight}
maxHeight={popupMaxHeight}
maxHeight={720}
position={position}
className='grid h-full select-text grid-rows-[1fr,auto,1fr] bg-gray-600 text-white'
triangleClassName='text-gray-600'
+15 -1
View File
@@ -1,5 +1,7 @@
import clsx from 'clsx';
import { Position } from '@/utils/sel';
import { useEffect, useRef, useState } from 'react';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
const Popup = ({
width,
@@ -28,6 +30,18 @@ const Popup = ({
const [adjustedPosition, setAdjustedPosition] = useState(position);
const [childrenHeight, setChildrenHeight] = useState(height || minHeight || 0);
const popupPadding = useResponsiveSize(10);
let availableHeight = window.innerHeight - 2 * popupPadding;
if (trianglePosition?.dir === 'up') {
availableHeight = trianglePosition.point.y - popupPadding;
} else if (trianglePosition?.dir === 'down') {
availableHeight = window.innerHeight - trianglePosition.point.y - popupPadding;
}
maxHeight = Math.min(maxHeight || availableHeight, availableHeight);
if (minHeight) {
minHeight = Math.min(minHeight, availableHeight);
}
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
@@ -69,7 +83,7 @@ const Popup = ({
<div
id='popup-container'
ref={containerRef}
className={`bg-base-300 absolute rounded-lg font-sans shadow-xl ${className}`}
className={clsx('bg-base-300 absolute rounded-lg font-sans shadow-xl', className)}
style={{
width: `${width}px`,
height: height ? `${height}px` : 'auto',
+117 -40
View File
@@ -1,12 +1,22 @@
import crypto from 'crypto';
import { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { supabase } from '@/utils/supabase';
import { getUserPlan } from '@/utils/access';
import { query as deeplQuery } from '@/utils/deepl';
const DEFAULT_DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
const DEFAULT_DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
interface KVNamespace {
get(key: string): Promise<string | null>;
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
delete(key: string): Promise<void>;
}
interface CloudflareEnv {
TRANSLATIONS_KV?: KVNamespace;
}
const getUserAndToken = async (authHeader: string | undefined) => {
if (!authHeader) return {};
@@ -27,7 +37,13 @@ const LANG_V2_V1_MAP: Record<string, string> = {
const getDeepLAPIKey = (keys: string | undefined) => {
const keyArray = keys?.split(',') ?? [];
return keyArray.length ? keyArray[Math.floor(Math.random() * keyArray.length)] : '';
return keyArray.length ? keyArray[Math.floor(Math.random() * keyArray.length)]! : '';
};
const generateCacheKey = (text: string, sourceLang: string, targetLang: string): string => {
const inputString = `${sourceLang}:${targetLang}:${text}`;
const hash = crypto.createHash('sha1').update(inputString).digest('hex');
return `tr:${hash}`;
};
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
@@ -37,6 +53,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(405).json({ error: 'Method not allowed' });
}
const env = (req.env || {}) as CloudflareEnv;
const hasKVCache = !!env['TRANSLATIONS_KV'];
const { user, token } = await getUserAndToken(req.headers['authorization']);
const { DEEPL_PRO_API, DEEPL_FREE_API } = process.env;
const deepFreeApiUrl = DEEPL_FREE_API || DEFAULT_DEEPL_FREE_API;
@@ -47,8 +66,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (user && token) {
userPlan = getUserPlan(token);
if (userPlan === 'pro') deeplApiUrl = deeplProApiUrl;
} else {
res.status(403).json({ error: 'Not authenticated' });
}
const deeplAuthKey =
deeplApiUrl === deeplProApiUrl
@@ -59,46 +76,106 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
text,
source_lang: sourceLang = 'AUTO',
target_lang: targetLang = 'EN',
}: { text: string[]; source_lang: string; target_lang: string } = req.body;
use_cache: useCache = false,
}: { text: string[]; source_lang: string; target_lang: string; use_cache: boolean } = req.body;
try {
if (user && token) {
const isV2Api = deeplApiUrl.endsWith('/v2/translate');
const requestBody = {
text: isV2Api ? text : (text[0] ?? ''),
source_lang: isV2Api ? sourceLang : (LANG_V2_V1_MAP[sourceLang] ?? sourceLang),
target_lang: isV2Api ? targetLang : (LANG_V2_V1_MAP[targetLang] ?? targetLang),
};
const response = await fetch(deeplApiUrl, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${deeplAuthKey}`,
'x-fingerprint': process.env['DEEPL_X_FINGERPRINT'] || '',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
const data = await response.json();
const result = {
...data,
translations: data.translations || [
{
text: data.data,
},
],
};
res.status(response.status).json(result);
} else {
const result = await deeplQuery({
text: text[0] ?? '',
sourceLang,
targetLang,
});
res.status(200).json(result);
}
const translations = await Promise.all(
text.map(async (singleText) => {
if (!singleText?.trim()) {
return { text: '' };
}
if (useCache && hasKVCache) {
try {
const cacheKey = generateCacheKey(singleText, sourceLang, targetLang);
const cachedTranslation = await env['TRANSLATIONS_KV']!.get(cacheKey);
if (cachedTranslation) {
return {
text: cachedTranslation,
detected_source_language: sourceLang,
};
}
} catch (cacheError) {
console.error('Cache retrieval error:', cacheError);
}
}
return await callDeepLAPI(
singleText,
sourceLang,
targetLang,
deeplApiUrl,
deeplAuthKey,
env['TRANSLATIONS_KV'],
useCache,
);
}),
);
return res.status(200).json({ translations });
} catch (error) {
console.error('Error proxying DeepL request:', error);
res.status(500).json({ error: 'Internal Server Error' });
return res.status(500).json({ error: 'Internal Server Error' });
}
};
async function callDeepLAPI(
text: string,
sourceLang: string,
targetLang: string,
apiUrl: string,
authKey: string,
translationsKV: KVNamespace | undefined,
useCache: boolean,
) {
const isV2Api = apiUrl.endsWith('/v2/translate');
const requestBody = {
text: isV2Api ? [text] : text,
source_lang: isV2Api ? sourceLang : (LANG_V2_V1_MAP[sourceLang] ?? sourceLang),
target_lang: isV2Api ? targetLang : (LANG_V2_V1_MAP[targetLang] ?? targetLang),
};
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${authKey}`,
'x-fingerprint': process.env['DEEPL_X_FINGERPRINT'] || '',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`DeepL API error (${response.status}): ${errorText}`);
}
const data = await response.json();
let translatedText = '';
let detectedSourceLanguage = '';
if (data.translations && data.translations.length > 0) {
translatedText = data.translations[0].text;
detectedSourceLanguage = data.translations[0].detected_source_language || '';
} else if (data.data) {
translatedText = data.data;
}
if (useCache && translationsKV && translatedText) {
try {
const cacheKey = generateCacheKey(text, sourceLang, targetLang);
await translationsKV.put(cacheKey, translatedText, { expirationTtl: 86400 * 90 });
} catch (cacheError) {
console.error('Cache storage error:', cacheError);
}
}
return {
text: translatedText,
detected_source_language: detectedSourceLanguage,
};
}
export default handler;
@@ -1,11 +1,9 @@
import type { Transformer } from './types';
import { footnoteTransformer } from './footnote';
import { translateTransformer } from './translate';
import { punctuationTransformer } from './punctuation';
export const availableTransformers: Transformer[] = [
punctuationTransformer,
translateTransformer,
footnoteTransformer,
// Add more transformers here
];
@@ -1,9 +0,0 @@
import type { Transformer } from './types';
export const translateTransformer: Transformer = {
name: 'translate',
transform: async (ctx) => {
return ctx.content;
},
};
+5
View File
@@ -122,6 +122,11 @@ export const getPosition = (
return start.point.y > window.innerHeight - end.point.y ? start : end;
};
// The popup will be positioned based on the triangle position and the direction
// up: above the triangle
// down: below the triangle
// left: to the left of the triangle
// right: to the right of the triangle
export const getPopupPosition = (
position: Position,
boundingReact: Rect,
-12
View File
@@ -1,12 +0,0 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "readest-web",
"compatibility_date": "2025-02-04",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"kv_namespaces": []
}
+12
View File
@@ -0,0 +1,12 @@
name = "readest-web"
main = ".open-next/worker.js"
compatibility_date = "2025-02-04"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = ".open-next/assets"
binding = "ASSETS"
[[kv_namespaces]]
binding = "TRANSLATIONS_KV"
id = "${TRANSLATIONS_KV_ID}"