Compare commits
10 Commits
8f98e54b18
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7d0dfdfb6 | |||
| 171b40f525 | |||
| 41d2aa7295 | |||
| 38c3e3e2cb | |||
| b0f7f30f17 | |||
| c795ef5a54 | |||
| a435e8c749 | |||
| 7a30d14f0f | |||
| 2354ddf9be | |||
| 466459373a |
50
Dockerfile
50
Dockerfile
@@ -1,30 +1,58 @@
|
|||||||
# https://nextjs.org/docs/deployment#docker-image
|
# ── Stage 1: install dependencies ──────────────────────────────────────────
|
||||||
|
|
||||||
FROM node:lts-alpine AS deps
|
FROM node:lts-alpine AS deps
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install --legacy-peer-deps
|
# Skip Cypress binary download — not needed in production
|
||||||
|
ENV CYPRESS_INSTALL_BINARY=0
|
||||||
|
# Use ci to install exact versions from package-lock.json
|
||||||
|
RUN npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
# ── Stage 2: build the Next.js app ──────────────────────────────────────────
|
||||||
FROM node:lts-alpine AS builder
|
FROM node:lts-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nextjs -u 1001
|
||||||
|
|
||||||
|
# Copy source and deps
|
||||||
|
COPY --chown=nextjs:nodejs . .
|
||||||
|
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Remove yarn.lock so nothing accidentally invokes yarn
|
||||||
|
RUN rm -f yarn.lock
|
||||||
|
|
||||||
|
# Give nextjs user ownership of the entire workdir (WORKDIR creates it as root)
|
||||||
|
RUN chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
|
# Build the app at image build time (not at container start)
|
||||||
|
USER nextjs
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 3: production runner ───────────────────────────────────────────────
|
||||||
|
FROM node:lts-alpine AS runner
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
adduser -S nextjs -u 1001
|
adduser -S nextjs -u 1001
|
||||||
|
|
||||||
COPY --chown=nextjs:nodejs . .
|
# Copy only what's needed to run
|
||||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/next.config.js ./next.config.js
|
||||||
|
|
||||||
# Ensure the data directory exists and is writable by the nextjs user
|
|
||||||
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data && chmod 755 /app/data
|
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data && chmod 755 /app/data
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
ENV NODE_ENV=production
|
||||||
ENV NODE_ENV production
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=1m --timeout=3s CMD curl -f http://localhost:3000/ || exit 1
|
HEALTHCHECK --interval=1m --timeout=3s CMD curl -f http://localhost:3000/ || exit 1
|
||||||
|
|
||||||
@@ -32,4 +60,4 @@ CMD NEXT_PUBLIC_SITE_DOMAIN=$site_domain \
|
|||||||
NEXT_PUBLIC_FORCE_DEFAULT_THEME=$force_default_theme \
|
NEXT_PUBLIC_FORCE_DEFAULT_THEME=$force_default_theme \
|
||||||
NEXT_PUBLIC_DEFAULT_SOURCE_LANG=$default_source_lang \
|
NEXT_PUBLIC_DEFAULT_SOURCE_LANG=$default_source_lang \
|
||||||
NEXT_PUBLIC_DEFAULT_TARGET_LANG=$default_target_lang \
|
NEXT_PUBLIC_DEFAULT_TARGET_LANG=$default_target_lang \
|
||||||
npm run build && npm start
|
npm start
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FC, useState, useCallback, useRef } from "react";
|
import { FC, useState, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Box, Button, VStack, HStack, Text, Input, Alert, AlertIcon,
|
Box, Button, VStack, HStack, Text, Input, Alert, AlertIcon,
|
||||||
Progress, Select, Badge, Icon, useColorModeValue
|
Select, Badge, Icon, useColorModeValue
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { FiUpload, FiDownload, FiFile } from "react-icons/fi";
|
import { FiUpload, FiDownload, FiFile } from "react-icons/fi";
|
||||||
import { languageList, LangCode } from "lingva-scraper";
|
import { languageList, LangCode } from "lingva-scraper";
|
||||||
@@ -140,7 +140,7 @@ const DocumentTranslator: FC = () => {
|
|||||||
textAlign="center"
|
textAlign="center"
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onDragOver={e => e.preventDefault()}
|
onDragOver={(e: React.DragEvent) => e.preventDefault()}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
_hover={{ borderColor: "lingva.400" }}
|
_hover={{ borderColor: "lingva.400" }}
|
||||||
transition="border-color 0.2s"
|
transition="border-color 0.2s"
|
||||||
@@ -150,7 +150,7 @@ const DocumentTranslator: FC = () => {
|
|||||||
type="file"
|
type="file"
|
||||||
display="none"
|
display="none"
|
||||||
accept=".pdf,.docx,.xlsx,.xls,.csv"
|
accept=".pdf,.docx,.xlsx,.xls,.csv"
|
||||||
onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||||
/>
|
/>
|
||||||
{file ? (
|
{file ? (
|
||||||
<VStack spacing={1}>
|
<VStack spacing={1}>
|
||||||
@@ -177,7 +177,7 @@ const DocumentTranslator: FC = () => {
|
|||||||
<Box flex={1}>
|
<Box flex={1}>
|
||||||
<Select
|
<Select
|
||||||
value={target}
|
value={target}
|
||||||
onChange={e => setTarget(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setTarget(e.target.value)}
|
||||||
size="md"
|
size="md"
|
||||||
aria-label="Target language"
|
aria-label="Target language"
|
||||||
>
|
>
|
||||||
@@ -211,7 +211,9 @@ const DocumentTranslator: FC = () => {
|
|||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
{stage === "translating" && (
|
{stage === "translating" && (
|
||||||
<Box w="full">
|
<Box w="full">
|
||||||
<Progress value={progress} colorScheme="lingva" borderRadius="full" hasStripe isAnimated />
|
<Box w="full" h="8px" bg="gray.200" borderRadius="full" overflow="hidden">
|
||||||
|
<Box h="full" w={`${progress}%`} bg="lingva.400" borderRadius="full" transition="width 0.4s ease" />
|
||||||
|
</Box>
|
||||||
<Text fontSize="sm" textAlign="center" mt={1} color="gray.500">
|
<Text fontSize="sm" textAlign="center" mt={1} color="gray.500">
|
||||||
Translating… this may take a moment for large documents.
|
Translating… this may take a moment for large documents.
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -20,12 +20,16 @@ const Footer: FC<Props> = (props) => (
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Link href="https://github.com/thedaviddelta/lingva-translate/blob/main/LICENSE" isExternal={true}>
|
<Link href="https://github.com/thedaviddelta/lingva-translate/blob/main/LICENSE" isExternal={true}>
|
||||||
<Text as="span">© 2021 thedaviddelta & contributors</Text>
|
<Text as="span">© 2021–{new Date().getFullYear()} thedaviddelta & contributors</Text>
|
||||||
</Link>
|
</Link>
|
||||||
<Text as="span" display={["none", null, "unset"]}>·</Text>
|
<Text as="span" display={["none", null, "unset"]}>·</Text>
|
||||||
<Link href="https://www.gnu.org/licenses/agpl-3.0.html" isExternal={true}>
|
<Link href="https://www.gnu.org/licenses/agpl-3.0.html" isExternal={true}>
|
||||||
<Text as="span">Licensed under AGPLv3</Text>
|
<Text as="span">Licensed under AGPLv3</Text>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Text as="span" display={["none", null, "unset"]}>·</Text>
|
||||||
|
<Link href="https://cloudhost.es" isExternal={true}>
|
||||||
|
<Text as="span">AI enhancements by Cloud Host</Text>
|
||||||
|
</Link>
|
||||||
{vercelSponsor && (
|
{vercelSponsor && (
|
||||||
<>
|
<>
|
||||||
<Text as="span" display={["none", null, "unset"]}>·</Text>
|
<Text as="span" display={["none", null, "unset"]}>·</Text>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import NextLink from "next/link";
|
import NextLink from "next/link";
|
||||||
import { Flex, HStack, IconButton, Link, useColorModeValue } from "@chakra-ui/react";
|
import { Flex, HStack, IconButton, Link, Text, VStack, useColorModeValue } from "@chakra-ui/react";
|
||||||
import { FaGithub } from "react-icons/fa";
|
import { FaGithub } from "react-icons/fa";
|
||||||
import { FiSettings } from "react-icons/fi";
|
import { FiSettings } from "react-icons/fi";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -28,13 +28,18 @@ const Header: FC<Props> = (props) => (
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<NextLink href="/" passHref={true}>
|
<NextLink href="/" passHref={true}>
|
||||||
<Link display="flex">
|
<Link display="flex" alignItems="center">
|
||||||
<Image
|
<VStack spacing={0} align="flex-start">
|
||||||
src={useColorModeValue("/banner_light.svg", "/banner_dark.svg")}
|
<Image
|
||||||
alt="Logo"
|
src={useColorModeValue("/banner_light.svg", "/banner_dark.svg")}
|
||||||
width={110}
|
alt="Logo"
|
||||||
height={64}
|
width={110}
|
||||||
/>
|
height={64}
|
||||||
|
/>
|
||||||
|
<Text fontSize="xs" fontStyle="italic" opacity={0.7} ml={1}>
|
||||||
|
+ AI enhancements
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
</Link>
|
</Link>
|
||||||
</NextLink>
|
</NextLink>
|
||||||
<HStack spacing={3}>
|
<HStack spacing={3}>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const AdminPage: NextPage = () => {
|
|||||||
const [testResult, setTestResult] = useState<string | null>(null);
|
const [testResult, setTestResult] = useState<string | null>(null);
|
||||||
const [testLoading, setTestLoading] = useState(false);
|
const [testLoading, setTestLoading] = useState(false);
|
||||||
|
|
||||||
|
const pageBg = useColorModeValue("gray.50", "gray.900");
|
||||||
const cardBg = useColorModeValue("white", "gray.800");
|
const cardBg = useColorModeValue("white", "gray.800");
|
||||||
const borderCol = useColorModeValue("gray.200", "gray.600");
|
const borderCol = useColorModeValue("gray.200", "gray.600");
|
||||||
const codeBg = useColorModeValue("gray.100", "gray.700");
|
const codeBg = useColorModeValue("gray.100", "gray.700");
|
||||||
@@ -140,7 +141,7 @@ const AdminPage: NextPage = () => {
|
|||||||
|
|
||||||
if (authed === null) {
|
if (authed === null) {
|
||||||
return (
|
return (
|
||||||
<Box w="full" display="flex" justifyContent="center" pt={20}>
|
<Box w="full" minH="100%" bg={pageBg} display="flex" justifyContent="center" pt={20}>
|
||||||
<Spinner size="xl" color="lingva.400" />
|
<Spinner size="xl" color="lingva.400" />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -150,6 +151,7 @@ const AdminPage: NextPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomHead home={false} />
|
<CustomHead home={false} />
|
||||||
|
<Box w="full" minH="100%" bg={pageBg}>
|
||||||
<Box
|
<Box
|
||||||
w="full" maxW="400px" mx="auto" mt={10} p={8}
|
w="full" maxW="400px" mx="auto" mt={10} p={8}
|
||||||
bg={cardBg} borderWidth={1} borderColor={borderCol}
|
bg={cardBg} borderWidth={1} borderColor={borderCol}
|
||||||
@@ -181,6 +183,7 @@ const AdminPage: NextPage = () => {
|
|||||||
</NextLink>
|
</NextLink>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,7 +191,8 @@ const AdminPage: NextPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomHead home={false} />
|
<CustomHead home={false} />
|
||||||
<Box w="full" maxW="700px" mx="auto" px={4} pb={10}>
|
<Box w="full" minH="100%" bg={pageBg}>
|
||||||
|
<Box w="full" maxW="700px" mx="auto" px={4} pb={10} pt={2}>
|
||||||
<HStack justify="space-between" mb={6}>
|
<HStack justify="space-between" mb={6}>
|
||||||
<NextLink href="/" passHref>
|
<NextLink href="/" passHref>
|
||||||
<Button as={Link} leftIcon={<FiArrowLeft />} variant="ghost" size="sm">Back</Button>
|
<Button as={Link} leftIcon={<FiArrowLeft />} variant="ghost" size="sm">Back</Button>
|
||||||
@@ -384,6 +388,7 @@ const AdminPage: NextPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,12 +90,13 @@ async function translateViaCloud(
|
|||||||
return extractText(data.output as ReplicateOutput);
|
return extractText(data.output as ReplicateOutput);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function translateViaLocal(
|
// The local Cog model handles exactly one prediction at a time.
|
||||||
text: string,
|
// This queue ensures calls are strictly serialised regardless of
|
||||||
targetLanguage: string
|
// how many batches / concurrent requests come in.
|
||||||
): Promise<string> {
|
let localQueue: Promise<void> = Promise.resolve();
|
||||||
const settings = readSettings();
|
|
||||||
|
|
||||||
|
async function callLocalModel(text: string, targetLanguage: string): Promise<string> {
|
||||||
|
const settings = readSettings();
|
||||||
if (!settings.jigsawApiKey) throw new Error("JigsawStack API key not configured");
|
if (!settings.jigsawApiKey) throw new Error("JigsawStack API key not configured");
|
||||||
|
|
||||||
const endpoint = (settings.localEndpoint || "http://localhost:5030/predictions").replace(/\/$/, "");
|
const endpoint = (settings.localEndpoint || "http://localhost:5030/predictions").replace(/\/$/, "");
|
||||||
@@ -118,14 +119,24 @@ async function translateViaLocal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data: CogOutput = await response.json();
|
const data: CogOutput = await response.json();
|
||||||
|
|
||||||
if (data.error) throw new Error(`Local model error: ${data.error}`);
|
if (data.error) throw new Error(`Local model error: ${data.error}`);
|
||||||
|
|
||||||
// Cog wraps output in { status, output } or returns output directly
|
|
||||||
const output = data.output !== undefined ? data.output : data;
|
const output = data.output !== undefined ? data.output : data;
|
||||||
return extractText(output);
|
return extractText(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function translateViaLocal(
|
||||||
|
text: string,
|
||||||
|
targetLanguage: string
|
||||||
|
): Promise<string> {
|
||||||
|
// Enqueue — wait for any in-flight local call to finish first
|
||||||
|
let resolve!: () => void;
|
||||||
|
const slot = new Promise<void>(r => { resolve = r; });
|
||||||
|
const turn = localQueue.then(() => callLocalModel(text, targetLanguage));
|
||||||
|
localQueue = turn.then(resolve, resolve); // advance queue whether success or error
|
||||||
|
return turn;
|
||||||
|
}
|
||||||
|
|
||||||
export async function replicateTranslate(
|
export async function replicateTranslate(
|
||||||
text: string,
|
text: string,
|
||||||
targetLanguage: string
|
targetLanguage: string
|
||||||
@@ -136,24 +147,98 @@ export async function replicateTranslate(
|
|||||||
: translateViaCloud(text, targetLanguage);
|
: translateViaCloud(text, targetLanguage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch translate using separator trick to minimize API calls
|
|
||||||
const SEPARATOR = "\n{{SEP}}\n";
|
const SEPARATOR = "\n{{SEP}}\n";
|
||||||
|
const MAX_CHARS = 4800; // JigsawStack limit is 5000 — leave headroom
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a single text that exceeds MAX_CHARS into translatable chunks,
|
||||||
|
* preferring paragraph/sentence boundaries.
|
||||||
|
*/
|
||||||
|
async function translateLongText(text: string, targetLanguage: string): Promise<string> {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let remaining = text;
|
||||||
|
|
||||||
|
while (remaining.length > MAX_CHARS) {
|
||||||
|
let splitAt = MAX_CHARS;
|
||||||
|
const para = remaining.lastIndexOf("\n", MAX_CHARS);
|
||||||
|
const sentence = remaining.lastIndexOf(". ", MAX_CHARS);
|
||||||
|
if (para > MAX_CHARS * 0.5) splitAt = para + 1;
|
||||||
|
else if (sentence > MAX_CHARS * 0.5) splitAt = sentence + 2;
|
||||||
|
chunks.push(remaining.slice(0, splitAt));
|
||||||
|
remaining = remaining.slice(splitAt);
|
||||||
|
}
|
||||||
|
if (remaining.trim()) chunks.push(remaining);
|
||||||
|
|
||||||
|
const results = await Promise.all(chunks.map(c => replicateTranslate(c, targetLanguage)));
|
||||||
|
return results.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build groups of texts that fit within MAX_CHARS when joined with SEPARATOR.
|
||||||
|
* Texts that individually exceed MAX_CHARS are kept alone for chunked translation.
|
||||||
|
*/
|
||||||
|
function buildBatches(texts: string[]): { indices: number[]; long: boolean }[] {
|
||||||
|
const batches: { indices: number[]; long: boolean }[] = [];
|
||||||
|
let current: number[] = [];
|
||||||
|
let currentLen = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < texts.length; i++) {
|
||||||
|
const t = texts[i];
|
||||||
|
|
||||||
|
if (t.length > MAX_CHARS) {
|
||||||
|
// Flush current group first
|
||||||
|
if (current.length > 0) { batches.push({ indices: current, long: false }); current = []; currentLen = 0; }
|
||||||
|
batches.push({ indices: [i], long: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const added = currentLen === 0 ? t.length : currentLen + SEPARATOR.length + t.length;
|
||||||
|
if (added > MAX_CHARS && current.length > 0) {
|
||||||
|
batches.push({ indices: current, long: false });
|
||||||
|
current = [i];
|
||||||
|
currentLen = t.length;
|
||||||
|
} else {
|
||||||
|
current.push(i);
|
||||||
|
currentLen = added;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.length > 0) batches.push({ indices: current, long: false });
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
export async function replicateTranslateBatch(
|
export async function replicateTranslateBatch(
|
||||||
texts: string[],
|
texts: string[],
|
||||||
targetLanguage: string
|
targetLanguage: string
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
if (texts.length === 0) return [];
|
if (texts.length === 0) return [];
|
||||||
if (texts.length === 1) {
|
|
||||||
return [await replicateTranslate(texts[0], targetLanguage)];
|
const results: string[] = new Array(texts.length);
|
||||||
|
const batches = buildBatches(texts);
|
||||||
|
|
||||||
|
// Process batches sequentially to avoid hammering the model
|
||||||
|
for (const batch of batches) {
|
||||||
|
if (batch.long) {
|
||||||
|
// Single oversized text — chunk it
|
||||||
|
results[batch.indices[0]] = await translateLongText(texts[batch.indices[0]], targetLanguage);
|
||||||
|
} else if (batch.indices.length === 1) {
|
||||||
|
results[batch.indices[0]] = await replicateTranslate(texts[batch.indices[0]], targetLanguage);
|
||||||
|
} else {
|
||||||
|
// Multi-text batch within limit
|
||||||
|
const joined = batch.indices.map(i => texts[i]).join(SEPARATOR);
|
||||||
|
const translated = await replicateTranslate(joined, targetLanguage);
|
||||||
|
const parts = translated.split(SEPARATOR);
|
||||||
|
|
||||||
|
if (parts.length === batch.indices.length) {
|
||||||
|
batch.indices.forEach((idx, i) => { results[idx] = parts[i]; });
|
||||||
|
} else {
|
||||||
|
// Separator got translated — fall back to individual calls
|
||||||
|
const individual = await Promise.all(
|
||||||
|
batch.indices.map(i => replicateTranslate(texts[i], targetLanguage))
|
||||||
|
);
|
||||||
|
batch.indices.forEach((idx, i) => { results[idx] = individual[i]; });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const joined = texts.join(SEPARATOR);
|
return results;
|
||||||
const translated = await replicateTranslate(joined, targetLanguage);
|
|
||||||
|
|
||||||
const parts = translated.split(SEPARATOR);
|
|
||||||
if (parts.length === texts.length) return parts;
|
|
||||||
|
|
||||||
// Fallback: translate individually if separator got mangled
|
|
||||||
return Promise.all(texts.map(t => replicateTranslate(t, targetLanguage)));
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user