Compare commits
14 Commits
0799101da3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7d0dfdfb6 | |||
| 171b40f525 | |||
| 41d2aa7295 | |||
| 38c3e3e2cb | |||
| b0f7f30f17 | |||
| c795ef5a54 | |||
| a435e8c749 | |||
| 7a30d14f0f | |||
| 2354ddf9be | |||
| 466459373a | |||
| 8f98e54b18 | |||
| a7ca88cabb | |||
| 0be8b0b0f0 | |||
| e034771087 |
62
Dockerfile
62
Dockerfile
@@ -1,33 +1,63 @@
|
||||
# https://nextjs.org/docs/deployment#docker-image
|
||||
|
||||
# ── Stage 1: install dependencies ──────────────────────────────────────────
|
||||
FROM node:lts-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY package.json package-lock.json* ./
|
||||
# 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
|
||||
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
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nextjs -u 1001
|
||||
COPY --chown=nextjs:nodejs . .
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
RUN chown nextjs:nodejs .
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nextjs -u 1001
|
||||
|
||||
# Copy only what's needed to run
|
||||
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
|
||||
|
||||
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data && chmod 755 /app/data
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
HEALTHCHECK --interval=1m --timeout=3s CMD curl -f http://localhost:3000/ || exit 1
|
||||
|
||||
CMD NEXT_PUBLIC_SITE_DOMAIN=$site_domain\
|
||||
CMD NEXT_PUBLIC_SITE_DOMAIN=$site_domain \
|
||||
NEXT_PUBLIC_FORCE_DEFAULT_THEME=$force_default_theme \
|
||||
NEXT_PUBLIC_DEFAULT_SOURCE_LANG=$default_source_lang \
|
||||
NEXT_PUBLIC_DEFAULT_TARGET_LANG=$default_target_lang \
|
||||
yarn build && yarn start
|
||||
npm start
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
Box, Button, VStack, HStack, Text, Input, Alert, AlertIcon,
|
||||
Progress, Select, Badge, Icon, useColorModeValue
|
||||
Select, Badge, Icon, useColorModeValue
|
||||
} from "@chakra-ui/react";
|
||||
import { FiUpload, FiDownload, FiFile } from "react-icons/fi";
|
||||
import { languageList, LangCode } from "lingva-scraper";
|
||||
@@ -140,7 +140,7 @@ const DocumentTranslator: FC = () => {
|
||||
textAlign="center"
|
||||
cursor="pointer"
|
||||
onDrop={onDrop}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDragOver={(e: React.DragEvent) => e.preventDefault()}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
_hover={{ borderColor: "lingva.400" }}
|
||||
transition="border-color 0.2s"
|
||||
@@ -150,7 +150,7 @@ const DocumentTranslator: FC = () => {
|
||||
type="file"
|
||||
display="none"
|
||||
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 ? (
|
||||
<VStack spacing={1}>
|
||||
@@ -177,7 +177,7 @@ const DocumentTranslator: FC = () => {
|
||||
<Box flex={1}>
|
||||
<Select
|
||||
value={target}
|
||||
onChange={e => setTarget(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setTarget(e.target.value)}
|
||||
size="md"
|
||||
aria-label="Target language"
|
||||
>
|
||||
@@ -211,7 +211,9 @@ const DocumentTranslator: FC = () => {
|
||||
{/* Progress */}
|
||||
{stage === "translating" && (
|
||||
<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">
|
||||
Translating… this may take a moment for large documents.
|
||||
</Text>
|
||||
|
||||
@@ -20,12 +20,16 @@ const Footer: FC<Props> = (props) => (
|
||||
{...props}
|
||||
>
|
||||
<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>
|
||||
<Text as="span" display={["none", null, "unset"]}>·</Text>
|
||||
<Link href="https://www.gnu.org/licenses/agpl-3.0.html" isExternal={true}>
|
||||
<Text as="span">Licensed under AGPLv3</Text>
|
||||
</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 && (
|
||||
<>
|
||||
<Text as="span" display={["none", null, "unset"]}>·</Text>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC } from "react";
|
||||
import Head from "next/head";
|
||||
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 { FiSettings } from "react-icons/fi";
|
||||
import Image from "next/image";
|
||||
@@ -28,13 +28,18 @@ const Header: FC<Props> = (props) => (
|
||||
{...props}
|
||||
>
|
||||
<NextLink href="/" passHref={true}>
|
||||
<Link display="flex">
|
||||
<Image
|
||||
src={useColorModeValue("/banner_light.svg", "/banner_dark.svg")}
|
||||
alt="Logo"
|
||||
width={110}
|
||||
height={64}
|
||||
/>
|
||||
<Link display="flex" alignItems="center">
|
||||
<VStack spacing={0} align="flex-start">
|
||||
<Image
|
||||
src={useColorModeValue("/banner_light.svg", "/banner_dark.svg")}
|
||||
alt="Logo"
|
||||
width={110}
|
||||
height={64}
|
||||
/>
|
||||
<Text fontSize="xs" fontStyle="italic" opacity={0.7} ml={1}>
|
||||
+ AI enhancements
|
||||
</Text>
|
||||
</VStack>
|
||||
</Link>
|
||||
</NextLink>
|
||||
<HStack spacing={3}>
|
||||
|
||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
lingvai:
|
||||
build: .
|
||||
container_name: lingvai-app
|
||||
ports:
|
||||
- "3030:3000"
|
||||
environment:
|
||||
- ADMIN_PASSWORD=MuieSteaua09
|
||||
- ADMIN_JWT_SECRET=the3-29-mu-ar3rwfd-dsfhui7
|
||||
- LOCAL_MODEL_ENDPOINT=http://lingvai-translator:5000/predictions
|
||||
- REPLICATE_MODE=local
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- translator
|
||||
networks:
|
||||
- lingvai-net
|
||||
|
||||
translator:
|
||||
image: r8.im/jigsawstack/text-translate@sha256:454df4c49941c05dea05175bd37686d0872c73c1f9366d1c2505db32ade52a89
|
||||
container_name: lingvai-translator
|
||||
ports:
|
||||
- "5030:5000"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- lingvai-net
|
||||
|
||||
networks:
|
||||
lingvai-net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
driver: default
|
||||
config:
|
||||
- subnet: 10.100.50.0/24
|
||||
gateway: 10.100.50.1
|
||||
@@ -3,21 +3,26 @@ import { NextPage } from "next";
|
||||
import NextLink from "next/link";
|
||||
import {
|
||||
Box, VStack, HStack, Heading, Text, Input, Button, Switch,
|
||||
FormControl, FormLabel, FormHelperText, Divider, Alert, AlertIcon,
|
||||
FormControl, FormLabel, FormHelperText, Alert, AlertIcon,
|
||||
InputGroup, InputRightElement, IconButton, Badge, Link,
|
||||
useColorModeValue, Spinner, Textarea
|
||||
useColorModeValue, Spinner, Textarea, RadioGroup, Radio, Code
|
||||
} from "@chakra-ui/react";
|
||||
import { FiEye, FiEyeOff, FiSave, FiLogOut, FiArrowLeft } from "react-icons/fi";
|
||||
import { CustomHead } from "@components";
|
||||
|
||||
type ReplicateMode = "cloud" | "local";
|
||||
|
||||
type Settings = {
|
||||
replicateApiToken: string;
|
||||
jigsawApiKey: string;
|
||||
modelVersion: string;
|
||||
replicateEnabled: boolean;
|
||||
replicateMode: ReplicateMode;
|
||||
localEndpoint: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL = "jigsawstack/text-translate:454df4c49941c05dea05175bd37686d0872c73c1f9366d1c2505db32ade52a89";
|
||||
const DEFAULT_LOCAL_ENDPOINT = "http://localhost:5030/predictions";
|
||||
|
||||
const AdminPage: NextPage = () => {
|
||||
const [authed, setAuthed] = useState<boolean | null>(null);
|
||||
@@ -29,7 +34,9 @@ const AdminPage: NextPage = () => {
|
||||
replicateApiToken: "",
|
||||
jigsawApiKey: "",
|
||||
modelVersion: DEFAULT_MODEL,
|
||||
replicateEnabled: false
|
||||
replicateEnabled: false,
|
||||
replicateMode: "cloud",
|
||||
localEndpoint: DEFAULT_LOCAL_ENDPOINT
|
||||
});
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [saveMsg, setSaveMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
@@ -39,10 +46,11 @@ const AdminPage: NextPage = () => {
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
|
||||
const pageBg = useColorModeValue("gray.50", "gray.900");
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderCol = useColorModeValue("gray.200", "gray.600");
|
||||
const codeBg = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
// Check auth on load
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/auth")
|
||||
.then(r => r.json())
|
||||
@@ -50,7 +58,6 @@ const AdminPage: NextPage = () => {
|
||||
.catch(() => setAuthed(false));
|
||||
}, []);
|
||||
|
||||
// Load settings when authed
|
||||
useEffect(() => {
|
||||
if (!authed) return;
|
||||
fetch("/api/admin/settings")
|
||||
@@ -69,9 +76,8 @@ const AdminPage: NextPage = () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
if (res.ok) {
|
||||
setAuthed(true);
|
||||
} else {
|
||||
if (res.ok) setAuthed(true);
|
||||
else {
|
||||
const d = await res.json();
|
||||
setLoginError(d.error ?? "Login failed");
|
||||
}
|
||||
@@ -125,11 +131,7 @@ const AdminPage: NextPage = () => {
|
||||
body: JSON.stringify({ text: "Hello, world!", targetLanguage: "es" })
|
||||
});
|
||||
const d = await res.json();
|
||||
if (res.ok) {
|
||||
setTestResult(`✓ Success: "${d.translation}"`);
|
||||
} else {
|
||||
setTestResult(`✗ Error: ${d.error}`);
|
||||
}
|
||||
setTestResult(res.ok ? `✓ "${d.translation}"` : `✗ ${d.error}`);
|
||||
} catch {
|
||||
setTestResult("✗ Network error");
|
||||
} finally {
|
||||
@@ -137,31 +139,23 @@ const AdminPage: NextPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (authed === null) {
|
||||
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" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Login form
|
||||
if (!authed) {
|
||||
return (
|
||||
<>
|
||||
<CustomHead home={false} />
|
||||
<Box w="full" minH="100%" bg={pageBg}>
|
||||
<Box
|
||||
w="full"
|
||||
maxW="400px"
|
||||
mx="auto"
|
||||
mt={10}
|
||||
p={8}
|
||||
bg={cardBg}
|
||||
borderWidth={1}
|
||||
borderColor={borderCol}
|
||||
borderRadius="xl"
|
||||
boxShadow="md"
|
||||
w="full" maxW="400px" mx="auto" mt={10} p={8}
|
||||
bg={cardBg} borderWidth={1} borderColor={borderCol}
|
||||
borderRadius="xl" boxShadow="md"
|
||||
>
|
||||
<VStack spacing={6} as="form" onSubmit={login}>
|
||||
<Heading size="md">Admin Login</Heading>
|
||||
@@ -181,12 +175,7 @@ const AdminPage: NextPage = () => {
|
||||
/>
|
||||
<FormHelperText>Default: admin (set ADMIN_PASSWORD env var)</FormHelperText>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="lingva"
|
||||
w="full"
|
||||
isLoading={loginLoading}
|
||||
>
|
||||
<Button type="submit" colorScheme="lingva" w="full" isLoading={loginLoading}>
|
||||
Login
|
||||
</Button>
|
||||
<NextLink href="/" passHref>
|
||||
@@ -194,29 +183,27 @@ const AdminPage: NextPage = () => {
|
||||
</NextLink>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Admin panel
|
||||
return (
|
||||
<>
|
||||
<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}>
|
||||
<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>
|
||||
</NextLink>
|
||||
<Heading size="md">Admin Settings</Heading>
|
||||
<Button leftIcon={<FiLogOut />} variant="ghost" size="sm" onClick={logout}>
|
||||
Logout
|
||||
</Button>
|
||||
<Button leftIcon={<FiLogOut />} variant="ghost" size="sm" onClick={logout}>Logout</Button>
|
||||
</HStack>
|
||||
|
||||
<VStack spacing={6} as="form" onSubmit={save}>
|
||||
{/* Replicate Section */}
|
||||
|
||||
{/* ── Replicate Section ── */}
|
||||
<Box w="full" p={6} bg={cardBg} borderWidth={1} borderColor={borderCol} borderRadius="xl">
|
||||
<HStack mb={4}>
|
||||
<Heading size="sm">Replicate AI Translation</Heading>
|
||||
@@ -235,35 +222,103 @@ const AdminPage: NextPage = () => {
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Mode selector */}
|
||||
<FormControl>
|
||||
<FormLabel>Replicate API Token</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
type={showToken ? "text" : "password"}
|
||||
value={settings.replicateApiToken}
|
||||
onChange={e => setSettings(s => ({ ...s, replicateApiToken: e.target.value }))}
|
||||
placeholder="r8_..."
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="Toggle visibility"
|
||||
icon={showToken ? <FiEyeOff /> : <FiEye />}
|
||||
onClick={() => setShowToken(v => !v)}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<FormHelperText>
|
||||
Get your token at{" "}
|
||||
<Link href="https://replicate.com/account/api-tokens" isExternal color="lingva.400">
|
||||
replicate.com/account/api-tokens
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
<FormLabel>Translation Backend</FormLabel>
|
||||
<RadioGroup
|
||||
value={settings.replicateMode}
|
||||
onChange={v => setSettings(s => ({ ...s, replicateMode: v as ReplicateMode }))}
|
||||
>
|
||||
<HStack spacing={6}>
|
||||
<Radio value="cloud" colorScheme="lingva">
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontSize="sm" fontWeight="semibold">Replicate Cloud</Text>
|
||||
<Text fontSize="xs" color="gray.500">Uses replicate.com API</Text>
|
||||
</VStack>
|
||||
</Radio>
|
||||
<Radio value="local" colorScheme="lingva">
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontSize="sm" fontWeight="semibold">Local Docker (Cog)</Text>
|
||||
<Text fontSize="xs" color="gray.500">Uses local container</Text>
|
||||
</VStack>
|
||||
</Radio>
|
||||
</HStack>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{/* Cloud fields */}
|
||||
{settings.replicateMode === "cloud" && (
|
||||
<>
|
||||
<FormControl>
|
||||
<FormLabel>Replicate API Token</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
type={showToken ? "text" : "password"}
|
||||
value={settings.replicateApiToken}
|
||||
onChange={e => setSettings(s => ({ ...s, replicateApiToken: e.target.value }))}
|
||||
placeholder="r8_..."
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton size="xs" variant="ghost"
|
||||
aria-label="Toggle visibility"
|
||||
icon={showToken ? <FiEyeOff /> : <FiEye />}
|
||||
onClick={() => setShowToken(v => !v)}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<FormHelperText>
|
||||
Get your token at{" "}
|
||||
<Link href="https://replicate.com/account/api-tokens" isExternal color="lingva.400">
|
||||
replicate.com/account/api-tokens
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Model Version</FormLabel>
|
||||
<Textarea
|
||||
value={settings.modelVersion}
|
||||
onChange={e => setSettings(s => ({ ...s, modelVersion: e.target.value }))}
|
||||
fontFamily="mono"
|
||||
fontSize="xs"
|
||||
rows={2}
|
||||
placeholder={DEFAULT_MODEL}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Format: <Code fontSize="xs">owner/model:hash</Code> — the hash after the colon is extracted automatically.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Local fields */}
|
||||
{settings.replicateMode === "local" && (
|
||||
<>
|
||||
<Box bg={codeBg} p={4} borderRadius="md" fontSize="sm">
|
||||
<Text fontWeight="semibold" mb={2}>Start the local model container:</Text>
|
||||
<Code display="block" fontSize="xs" whiteSpace="pre" overflowX="auto">
|
||||
{`docker run -d -p 5030:5000 \\\n r8.im/jigsawstack/text-translate@sha256:454df4c49941c05dea05175bd37686d0872c73c1f9366d1c2505db32ade52a89`}
|
||||
</Code>
|
||||
</Box>
|
||||
<FormControl>
|
||||
<FormLabel>Local Model Endpoint</FormLabel>
|
||||
<Input
|
||||
value={settings.localEndpoint}
|
||||
onChange={e => setSettings(s => ({ ...s, localEndpoint: e.target.value }))}
|
||||
placeholder={DEFAULT_LOCAL_ENDPOINT}
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
/>
|
||||
<FormHelperText>
|
||||
Default: <Code fontSize="xs">{DEFAULT_LOCAL_ENDPOINT}</Code> (container port 5000 mapped to host 5030)
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* JigsawStack key — needed in both modes */}
|
||||
<FormControl>
|
||||
<FormLabel>JigsawStack API Key</FormLabel>
|
||||
<InputGroup>
|
||||
@@ -276,9 +331,7 @@ const AdminPage: NextPage = () => {
|
||||
fontSize="sm"
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
<IconButton size="xs" variant="ghost"
|
||||
aria-label="Toggle visibility"
|
||||
icon={showJigsawKey ? <FiEyeOff /> : <FiEye />}
|
||||
onClick={() => setShowJigsawKey(v => !v)}
|
||||
@@ -286,44 +339,21 @@ const AdminPage: NextPage = () => {
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<FormHelperText>
|
||||
Required for the JigsawStack translation model. Get yours at{" "}
|
||||
<Link href="https://jigsawstack.com" isExternal color="lingva.400">
|
||||
jigsawstack.com
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Model Version</FormLabel>
|
||||
<Textarea
|
||||
value={settings.modelVersion}
|
||||
onChange={e => setSettings(s => ({ ...s, modelVersion: e.target.value }))}
|
||||
fontFamily="mono"
|
||||
fontSize="xs"
|
||||
rows={2}
|
||||
placeholder={DEFAULT_MODEL}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Replicate model version string. Default uses the JigsawStack text-translate model.
|
||||
Required by the JigsawStack model in both cloud and local modes.{" "}
|
||||
<Link href="https://jigsawstack.com" isExternal color="lingva.400">Get yours →</Link>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="lingva"
|
||||
onClick={testTranslation}
|
||||
isLoading={testLoading}
|
||||
isDisabled={!settings.replicateEnabled || !settings.replicateApiToken}
|
||||
size="sm" variant="outline" colorScheme="lingva"
|
||||
onClick={testTranslation} isLoading={testLoading}
|
||||
isDisabled={!settings.replicateEnabled || !settings.jigsawApiKey}
|
||||
>
|
||||
Test Translation
|
||||
</Button>
|
||||
{testResult && (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={testResult.startsWith("✓") ? "green.500" : "red.500"}
|
||||
>
|
||||
<Text fontSize="sm" color={testResult.startsWith("✓") ? "green.500" : "red.500"}>
|
||||
{testResult}
|
||||
</Text>
|
||||
)}
|
||||
@@ -331,7 +361,7 @@ const AdminPage: NextPage = () => {
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Security Section */}
|
||||
{/* ── Security ── */}
|
||||
<Box w="full" p={6} bg={cardBg} borderWidth={1} borderColor={borderCol} borderRadius="xl">
|
||||
<Heading size="sm" mb={4}>Security</Heading>
|
||||
<FormControl>
|
||||
@@ -343,7 +373,7 @@ const AdminPage: NextPage = () => {
|
||||
placeholder="Leave blank to keep current"
|
||||
minLength={6}
|
||||
/>
|
||||
<FormHelperText>Minimum 6 characters. Leave blank to keep current password.</FormHelperText>
|
||||
<FormHelperText>Minimum 6 characters. Leave blank to keep current.</FormHelperText>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
@@ -353,18 +383,12 @@ const AdminPage: NextPage = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="lingva"
|
||||
leftIcon={<FiSave />}
|
||||
isLoading={saving}
|
||||
size="lg"
|
||||
w="full"
|
||||
>
|
||||
<Button type="submit" colorScheme="lingva" leftIcon={<FiSave />} isLoading={saving} size="lg" w="full">
|
||||
Save Settings
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ const handler: NextApiHandler = async (req, res) => {
|
||||
jigsawApiKey,
|
||||
modelVersion,
|
||||
replicateEnabled,
|
||||
replicateMode,
|
||||
localEndpoint,
|
||||
newPassword
|
||||
} = req.body ?? {};
|
||||
|
||||
@@ -26,6 +28,8 @@ const handler: NextApiHandler = async (req, res) => {
|
||||
if (jigsawApiKey !== undefined) updates.jigsawApiKey = jigsawApiKey;
|
||||
if (modelVersion !== undefined) updates.modelVersion = modelVersion;
|
||||
if (replicateEnabled !== undefined) updates.replicateEnabled = Boolean(replicateEnabled);
|
||||
if (replicateMode === "cloud" || replicateMode === "local") updates.replicateMode = replicateMode;
|
||||
if (localEndpoint !== undefined) updates.localEndpoint = localEndpoint;
|
||||
if (newPassword && typeof newPassword === "string" && newPassword.length >= 6) {
|
||||
updates.adminPasswordHash = newPassword;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,75 @@
|
||||
import { readSettings } from "./settings-store";
|
||||
|
||||
type CogOutput = {
|
||||
status?: string;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ReplicateOutput = string | string[] | { translation?: string; translated_text?: string; output?: string };
|
||||
|
||||
export async function replicateTranslate(
|
||||
function extractText(output: unknown): string {
|
||||
if (typeof output === "string") return output;
|
||||
if (Array.isArray(output)) return output.join("");
|
||||
if (output && typeof output === "object") {
|
||||
const o = output as { translation?: string; translated_text?: string; output?: string };
|
||||
return o.translation ?? o.translated_text ?? o.output ?? JSON.stringify(output);
|
||||
}
|
||||
throw new Error("Unexpected output format from model");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse modelVersion string into components.
|
||||
* Accepts:
|
||||
* "jigsawstack/text-translate:454df4c..." → { owner: "jigsawstack", model: "text-translate", hash: "454df4c..." }
|
||||
* "jigsawstack/text-translate" → { owner: "jigsawstack", model: "text-translate", hash: undefined }
|
||||
* "454df4c..." → { owner: undefined, model: undefined, hash: "454df4c..." }
|
||||
*/
|
||||
function parseModelVersion(mv: string) {
|
||||
const [ownerModel, hash] = mv.split(":");
|
||||
if (ownerModel.includes("/")) {
|
||||
const [owner, model] = ownerModel.split("/");
|
||||
return { owner, model, hash };
|
||||
}
|
||||
// Bare hash
|
||||
return { owner: undefined, model: undefined, hash: ownerModel };
|
||||
}
|
||||
|
||||
async function translateViaCloud(
|
||||
text: string,
|
||||
targetLanguage: string
|
||||
): Promise<string> {
|
||||
const settings = readSettings();
|
||||
|
||||
if (!settings.replicateApiToken) {
|
||||
throw new Error("Replicate API token not configured");
|
||||
}
|
||||
if (!settings.jigsawApiKey) {
|
||||
throw new Error("JigsawStack API key not configured");
|
||||
if (!settings.replicateApiToken) throw new Error("Replicate API token not configured");
|
||||
if (!settings.jigsawApiKey) throw new Error("JigsawStack API key not configured");
|
||||
|
||||
const { owner, model, hash } = parseModelVersion(settings.modelVersion);
|
||||
|
||||
let url: string;
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
if (owner && model && !hash) {
|
||||
// No version pinned — use the model's deployment endpoint (latest)
|
||||
url = `https://api.replicate.com/v1/models/${owner}/${model}/predictions`;
|
||||
body = { input: { text, api_key: settings.jigsawApiKey, target_language: targetLanguage } };
|
||||
} else if (owner && model && hash) {
|
||||
// Pinned version — use model endpoint with version (avoids 422 from bare /predictions)
|
||||
url = `https://api.replicate.com/v1/models/${owner}/${model}/predictions`;
|
||||
body = {
|
||||
version: hash,
|
||||
input: { text, api_key: settings.jigsawApiKey, target_language: targetLanguage }
|
||||
};
|
||||
} else {
|
||||
// Bare hash only — use the generic predictions endpoint
|
||||
url = "https://api.replicate.com/v1/predictions";
|
||||
body = {
|
||||
version: hash,
|
||||
input: { text, api_key: settings.jigsawApiKey, target_language: targetLanguage }
|
||||
};
|
||||
}
|
||||
|
||||
const body = {
|
||||
version: settings.modelVersion,
|
||||
input: {
|
||||
text,
|
||||
api_key: settings.jigsawApiKey,
|
||||
target_language: targetLanguage
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch("https://api.replicate.com/v1/predictions", {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${settings.replicateApiToken}`,
|
||||
@@ -40,44 +85,160 @@ export async function replicateTranslate(
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) throw new Error(`Replicate model error: ${data.error}`);
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(`Replicate model error: ${data.error}`);
|
||||
}
|
||||
|
||||
// Extract translated text from various output formats
|
||||
const output: ReplicateOutput = data.output;
|
||||
|
||||
if (typeof output === "string") return output;
|
||||
if (Array.isArray(output)) return output.join("");
|
||||
if (output && typeof output === "object") {
|
||||
return output.translation ?? output.translated_text ?? output.output ?? String(output);
|
||||
}
|
||||
|
||||
throw new Error("Unexpected output format from Replicate");
|
||||
return extractText(data.output as ReplicateOutput);
|
||||
}
|
||||
|
||||
// The local Cog model handles exactly one prediction at a time.
|
||||
// This queue ensures calls are strictly serialised regardless of
|
||||
// how many batches / concurrent requests come in.
|
||||
let localQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
async function callLocalModel(text: string, targetLanguage: string): Promise<string> {
|
||||
const settings = readSettings();
|
||||
if (!settings.jigsawApiKey) throw new Error("JigsawStack API key not configured");
|
||||
|
||||
const endpoint = (settings.localEndpoint || "http://localhost:5030/predictions").replace(/\/$/, "");
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
input: {
|
||||
text,
|
||||
api_key: settings.jigsawApiKey,
|
||||
target_language: targetLanguage
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Local model error: ${response.status} ${err}`);
|
||||
}
|
||||
|
||||
const data: CogOutput = await response.json();
|
||||
if (data.error) throw new Error(`Local model error: ${data.error}`);
|
||||
|
||||
const output = data.output !== undefined ? data.output : data;
|
||||
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(
|
||||
text: string,
|
||||
targetLanguage: string
|
||||
): Promise<string> {
|
||||
const { replicateMode } = readSettings();
|
||||
return replicateMode === "local"
|
||||
? translateViaLocal(text, targetLanguage)
|
||||
: translateViaCloud(text, targetLanguage);
|
||||
}
|
||||
|
||||
// Batch translate using separator trick to minimize API calls
|
||||
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(
|
||||
texts: string[],
|
||||
targetLanguage: string
|
||||
): Promise<string[]> {
|
||||
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);
|
||||
const translated = await replicateTranslate(joined, targetLanguage);
|
||||
|
||||
// Try to split on the separator; fall back to individual calls if it got translated
|
||||
const parts = translated.split(SEPARATOR);
|
||||
if (parts.length === texts.length) {
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Fallback: translate individually
|
||||
return Promise.all(texts.map(t => replicateTranslate(t, targetLanguage)));
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export type ReplicateMode = "cloud" | "local";
|
||||
|
||||
export type Settings = {
|
||||
replicateApiToken: string;
|
||||
jigsawApiKey: string;
|
||||
modelVersion: string;
|
||||
replicateEnabled: boolean;
|
||||
replicateMode: ReplicateMode;
|
||||
localEndpoint: string;
|
||||
adminPasswordHash: string;
|
||||
};
|
||||
|
||||
@@ -14,21 +18,48 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
jigsawApiKey: process.env["JIGSAWSTACK_API_KEY"] ?? "",
|
||||
modelVersion: "jigsawstack/text-translate:454df4c49941c05dea05175bd37686d0872c73c1f9366d1c2505db32ade52a89",
|
||||
replicateEnabled: false,
|
||||
replicateMode: (process.env["REPLICATE_MODE"] as ReplicateMode) ?? "cloud",
|
||||
localEndpoint: process.env["LOCAL_MODEL_ENDPOINT"] ?? "http://localhost:5030/predictions",
|
||||
adminPasswordHash: process.env["ADMIN_PASSWORD"] ?? "admin"
|
||||
};
|
||||
|
||||
const SETTINGS_PATH = path.join(process.cwd(), "data", "settings.json");
|
||||
|
||||
function ensureDataDir() {
|
||||
const dir = path.dirname(SETTINGS_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
/**
|
||||
* Resolve a writable path for settings.json.
|
||||
* Priority:
|
||||
* 1. SETTINGS_PATH env var (explicit override)
|
||||
* 2. <cwd>/data/settings.json (default, works when data/ is writable)
|
||||
* 3. /tmp/lingvai-settings.json (fallback for read-only containers)
|
||||
*/
|
||||
function resolveSettingsPath(): string {
|
||||
if (process.env["SETTINGS_PATH"]) {
|
||||
return process.env["SETTINGS_PATH"];
|
||||
}
|
||||
const primary = path.join(process.cwd(), "data", "settings.json");
|
||||
try {
|
||||
const dir = path.dirname(primary);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// Test write access by opening with 'a' (append/create without truncating)
|
||||
const fd = fs.openSync(primary, "a");
|
||||
fs.closeSync(fd);
|
||||
return primary;
|
||||
} catch {
|
||||
return path.join("/tmp", "lingvai-settings.json");
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve once at module load so every call uses the same path
|
||||
const SETTINGS_PATH = resolveSettingsPath();
|
||||
|
||||
if (SETTINGS_PATH.startsWith("/tmp")) {
|
||||
console.warn(
|
||||
`[lingvai] data/settings.json is not writable. ` +
|
||||
`Settings will be stored at ${SETTINGS_PATH}. ` +
|
||||
`Mount a writable volume at /app/data or set SETTINGS_PATH to persist across restarts.`
|
||||
);
|
||||
}
|
||||
|
||||
export function readSettings(): Settings {
|
||||
try {
|
||||
ensureDataDir();
|
||||
if (!fs.existsSync(SETTINGS_PATH)) {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
@@ -40,9 +71,10 @@ export function readSettings(): Settings {
|
||||
}
|
||||
|
||||
export function writeSettings(updates: Partial<Settings>): Settings {
|
||||
ensureDataDir();
|
||||
const current = readSettings();
|
||||
const next = { ...current, ...updates };
|
||||
const dir = path.dirname(SETTINGS_PATH);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(next, null, 2), "utf-8");
|
||||
return next;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user