refactor: add kv cache for translation backend (#1184)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user