fix: resolve 422 from Replicate + add local Cog Docker mode
- replicate-translate: parse owner/model:hash correctly — extract only
the hash portion for the version field, and use the model endpoint
(POST /v1/models/{owner}/{model}/predictions) which avoids 422
'Invalid version' errors when sending the full owner/model:hash string.
- Add local Cog mode: when replicateMode="local", calls the local Docker
container directly (no Replicate API key needed), default endpoint
http://localhost:5030/predictions (host port 5030 → container port 5000).
- settings-store: add replicateMode ("cloud"|"local") and localEndpoint
fields with env var fallbacks REPLICATE_MODE and LOCAL_MODEL_ENDPOINT.
- admin panel: Radio selector for Cloud vs Local mode; shows docker run
command snippet and local endpoint URL field when local is selected;
hides Replicate API token field in local mode (not needed).
Local model startup:
docker run -d -p 5030:5000 \
r8.im/jigsawstack/text-translate@sha256:454df4c...
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,21 +3,26 @@ import { NextPage } from "next";
|
|||||||
import NextLink from "next/link";
|
import NextLink from "next/link";
|
||||||
import {
|
import {
|
||||||
Box, VStack, HStack, Heading, Text, Input, Button, Switch,
|
Box, VStack, HStack, Heading, Text, Input, Button, Switch,
|
||||||
FormControl, FormLabel, FormHelperText, Divider, Alert, AlertIcon,
|
FormControl, FormLabel, FormHelperText, Alert, AlertIcon,
|
||||||
InputGroup, InputRightElement, IconButton, Badge, Link,
|
InputGroup, InputRightElement, IconButton, Badge, Link,
|
||||||
useColorModeValue, Spinner, Textarea
|
useColorModeValue, Spinner, Textarea, RadioGroup, Radio, Code
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { FiEye, FiEyeOff, FiSave, FiLogOut, FiArrowLeft } from "react-icons/fi";
|
import { FiEye, FiEyeOff, FiSave, FiLogOut, FiArrowLeft } from "react-icons/fi";
|
||||||
import { CustomHead } from "@components";
|
import { CustomHead } from "@components";
|
||||||
|
|
||||||
|
type ReplicateMode = "cloud" | "local";
|
||||||
|
|
||||||
type Settings = {
|
type Settings = {
|
||||||
replicateApiToken: string;
|
replicateApiToken: string;
|
||||||
jigsawApiKey: string;
|
jigsawApiKey: string;
|
||||||
modelVersion: string;
|
modelVersion: string;
|
||||||
replicateEnabled: boolean;
|
replicateEnabled: boolean;
|
||||||
|
replicateMode: ReplicateMode;
|
||||||
|
localEndpoint: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MODEL = "jigsawstack/text-translate:454df4c49941c05dea05175bd37686d0872c73c1f9366d1c2505db32ade52a89";
|
const DEFAULT_MODEL = "jigsawstack/text-translate:454df4c49941c05dea05175bd37686d0872c73c1f9366d1c2505db32ade52a89";
|
||||||
|
const DEFAULT_LOCAL_ENDPOINT = "http://localhost:5030/predictions";
|
||||||
|
|
||||||
const AdminPage: NextPage = () => {
|
const AdminPage: NextPage = () => {
|
||||||
const [authed, setAuthed] = useState<boolean | null>(null);
|
const [authed, setAuthed] = useState<boolean | null>(null);
|
||||||
@@ -29,7 +34,9 @@ const AdminPage: NextPage = () => {
|
|||||||
replicateApiToken: "",
|
replicateApiToken: "",
|
||||||
jigsawApiKey: "",
|
jigsawApiKey: "",
|
||||||
modelVersion: DEFAULT_MODEL,
|
modelVersion: DEFAULT_MODEL,
|
||||||
replicateEnabled: false
|
replicateEnabled: false,
|
||||||
|
replicateMode: "cloud",
|
||||||
|
localEndpoint: DEFAULT_LOCAL_ENDPOINT
|
||||||
});
|
});
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [saveMsg, setSaveMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
const [saveMsg, setSaveMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
@@ -41,8 +48,8 @@ const AdminPage: NextPage = () => {
|
|||||||
|
|
||||||
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");
|
||||||
|
|
||||||
// Check auth on load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin/auth")
|
fetch("/api/admin/auth")
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
@@ -50,7 +57,6 @@ const AdminPage: NextPage = () => {
|
|||||||
.catch(() => setAuthed(false));
|
.catch(() => setAuthed(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load settings when authed
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authed) return;
|
if (!authed) return;
|
||||||
fetch("/api/admin/settings")
|
fetch("/api/admin/settings")
|
||||||
@@ -69,9 +75,8 @@ const AdminPage: NextPage = () => {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ password })
|
body: JSON.stringify({ password })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) setAuthed(true);
|
||||||
setAuthed(true);
|
else {
|
||||||
} else {
|
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
setLoginError(d.error ?? "Login failed");
|
setLoginError(d.error ?? "Login failed");
|
||||||
}
|
}
|
||||||
@@ -125,11 +130,7 @@ const AdminPage: NextPage = () => {
|
|||||||
body: JSON.stringify({ text: "Hello, world!", targetLanguage: "es" })
|
body: JSON.stringify({ text: "Hello, world!", targetLanguage: "es" })
|
||||||
});
|
});
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (res.ok) {
|
setTestResult(res.ok ? `✓ "${d.translation}"` : `✗ ${d.error}`);
|
||||||
setTestResult(`✓ Success: "${d.translation}"`);
|
|
||||||
} else {
|
|
||||||
setTestResult(`✗ Error: ${d.error}`);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
setTestResult("✗ Network error");
|
setTestResult("✗ Network error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -137,7 +138,6 @@ const AdminPage: NextPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (authed === null) {
|
if (authed === null) {
|
||||||
return (
|
return (
|
||||||
<Box w="full" display="flex" justifyContent="center" pt={20}>
|
<Box w="full" display="flex" justifyContent="center" pt={20}>
|
||||||
@@ -146,22 +146,14 @@ const AdminPage: NextPage = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login form
|
|
||||||
if (!authed) {
|
if (!authed) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomHead home={false} />
|
<CustomHead home={false} />
|
||||||
<Box
|
<Box
|
||||||
w="full"
|
w="full" maxW="400px" mx="auto" mt={10} p={8}
|
||||||
maxW="400px"
|
bg={cardBg} borderWidth={1} borderColor={borderCol}
|
||||||
mx="auto"
|
borderRadius="xl" boxShadow="md"
|
||||||
mt={10}
|
|
||||||
p={8}
|
|
||||||
bg={cardBg}
|
|
||||||
borderWidth={1}
|
|
||||||
borderColor={borderCol}
|
|
||||||
borderRadius="xl"
|
|
||||||
boxShadow="md"
|
|
||||||
>
|
>
|
||||||
<VStack spacing={6} as="form" onSubmit={login}>
|
<VStack spacing={6} as="form" onSubmit={login}>
|
||||||
<Heading size="md">Admin Login</Heading>
|
<Heading size="md">Admin Login</Heading>
|
||||||
@@ -181,12 +173,7 @@ const AdminPage: NextPage = () => {
|
|||||||
/>
|
/>
|
||||||
<FormHelperText>Default: admin (set ADMIN_PASSWORD env var)</FormHelperText>
|
<FormHelperText>Default: admin (set ADMIN_PASSWORD env var)</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Button
|
<Button type="submit" colorScheme="lingva" w="full" isLoading={loginLoading}>
|
||||||
type="submit"
|
|
||||||
colorScheme="lingva"
|
|
||||||
w="full"
|
|
||||||
isLoading={loginLoading}
|
|
||||||
>
|
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
<NextLink href="/" passHref>
|
<NextLink href="/" passHref>
|
||||||
@@ -198,25 +185,21 @@ const AdminPage: NextPage = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin panel
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomHead home={false} />
|
<CustomHead home={false} />
|
||||||
<Box w="full" maxW="700px" mx="auto" px={4} pb={10}>
|
<Box w="full" maxW="700px" mx="auto" px={4} pb={10}>
|
||||||
<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">
|
<Button as={Link} leftIcon={<FiArrowLeft />} variant="ghost" size="sm">Back</Button>
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
</NextLink>
|
</NextLink>
|
||||||
<Heading size="md">Admin Settings</Heading>
|
<Heading size="md">Admin Settings</Heading>
|
||||||
<Button leftIcon={<FiLogOut />} variant="ghost" size="sm" onClick={logout}>
|
<Button leftIcon={<FiLogOut />} variant="ghost" size="sm" onClick={logout}>Logout</Button>
|
||||||
Logout
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<VStack spacing={6} as="form" onSubmit={save}>
|
<VStack spacing={6} as="form" onSubmit={save}>
|
||||||
{/* Replicate Section */}
|
|
||||||
|
{/* ── Replicate Section ── */}
|
||||||
<Box w="full" p={6} bg={cardBg} borderWidth={1} borderColor={borderCol} borderRadius="xl">
|
<Box w="full" p={6} bg={cardBg} borderWidth={1} borderColor={borderCol} borderRadius="xl">
|
||||||
<HStack mb={4}>
|
<HStack mb={4}>
|
||||||
<Heading size="sm">Replicate AI Translation</Heading>
|
<Heading size="sm">Replicate AI Translation</Heading>
|
||||||
@@ -235,35 +218,103 @@ const AdminPage: NextPage = () => {
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
{/* Mode selector */}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>Replicate API Token</FormLabel>
|
<FormLabel>Translation Backend</FormLabel>
|
||||||
<InputGroup>
|
<RadioGroup
|
||||||
<Input
|
value={settings.replicateMode}
|
||||||
type={showToken ? "text" : "password"}
|
onChange={v => setSettings(s => ({ ...s, replicateMode: v as ReplicateMode }))}
|
||||||
value={settings.replicateApiToken}
|
>
|
||||||
onChange={e => setSettings(s => ({ ...s, replicateApiToken: e.target.value }))}
|
<HStack spacing={6}>
|
||||||
placeholder="r8_..."
|
<Radio value="cloud" colorScheme="lingva">
|
||||||
fontFamily="mono"
|
<VStack align="start" spacing={0}>
|
||||||
fontSize="sm"
|
<Text fontSize="sm" fontWeight="semibold">Replicate Cloud</Text>
|
||||||
/>
|
<Text fontSize="xs" color="gray.500">Uses replicate.com API</Text>
|
||||||
<InputRightElement>
|
</VStack>
|
||||||
<IconButton
|
</Radio>
|
||||||
size="xs"
|
<Radio value="local" colorScheme="lingva">
|
||||||
variant="ghost"
|
<VStack align="start" spacing={0}>
|
||||||
aria-label="Toggle visibility"
|
<Text fontSize="sm" fontWeight="semibold">Local Docker (Cog)</Text>
|
||||||
icon={showToken ? <FiEyeOff /> : <FiEye />}
|
<Text fontSize="xs" color="gray.500">Uses local container</Text>
|
||||||
onClick={() => setShowToken(v => !v)}
|
</VStack>
|
||||||
/>
|
</Radio>
|
||||||
</InputRightElement>
|
</HStack>
|
||||||
</InputGroup>
|
</RadioGroup>
|
||||||
<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>
|
||||||
|
|
||||||
|
{/* 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>
|
<FormControl>
|
||||||
<FormLabel>JigsawStack API Key</FormLabel>
|
<FormLabel>JigsawStack API Key</FormLabel>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
@@ -276,9 +327,7 @@ const AdminPage: NextPage = () => {
|
|||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
/>
|
/>
|
||||||
<InputRightElement>
|
<InputRightElement>
|
||||||
<IconButton
|
<IconButton size="xs" variant="ghost"
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label="Toggle visibility"
|
aria-label="Toggle visibility"
|
||||||
icon={showJigsawKey ? <FiEyeOff /> : <FiEye />}
|
icon={showJigsawKey ? <FiEyeOff /> : <FiEye />}
|
||||||
onClick={() => setShowJigsawKey(v => !v)}
|
onClick={() => setShowJigsawKey(v => !v)}
|
||||||
@@ -286,44 +335,21 @@ const AdminPage: NextPage = () => {
|
|||||||
</InputRightElement>
|
</InputRightElement>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
Required for the JigsawStack translation model. Get yours at{" "}
|
Required by the JigsawStack model in both cloud and local modes.{" "}
|
||||||
<Link href="https://jigsawstack.com" isExternal color="lingva.400">
|
<Link href="https://jigsawstack.com" isExternal color="lingva.400">Get yours →</Link>
|
||||||
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.
|
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<HStack>
|
<HStack>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm" variant="outline" colorScheme="lingva"
|
||||||
variant="outline"
|
onClick={testTranslation} isLoading={testLoading}
|
||||||
colorScheme="lingva"
|
isDisabled={!settings.replicateEnabled || !settings.jigsawApiKey}
|
||||||
onClick={testTranslation}
|
|
||||||
isLoading={testLoading}
|
|
||||||
isDisabled={!settings.replicateEnabled || !settings.replicateApiToken}
|
|
||||||
>
|
>
|
||||||
Test Translation
|
Test Translation
|
||||||
</Button>
|
</Button>
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<Text
|
<Text fontSize="sm" color={testResult.startsWith("✓") ? "green.500" : "red.500"}>
|
||||||
fontSize="sm"
|
|
||||||
color={testResult.startsWith("✓") ? "green.500" : "red.500"}
|
|
||||||
>
|
|
||||||
{testResult}
|
{testResult}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -331,7 +357,7 @@ const AdminPage: NextPage = () => {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Security Section */}
|
{/* ── Security ── */}
|
||||||
<Box w="full" p={6} bg={cardBg} borderWidth={1} borderColor={borderCol} borderRadius="xl">
|
<Box w="full" p={6} bg={cardBg} borderWidth={1} borderColor={borderCol} borderRadius="xl">
|
||||||
<Heading size="sm" mb={4}>Security</Heading>
|
<Heading size="sm" mb={4}>Security</Heading>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -343,7 +369,7 @@ const AdminPage: NextPage = () => {
|
|||||||
placeholder="Leave blank to keep current"
|
placeholder="Leave blank to keep current"
|
||||||
minLength={6}
|
minLength={6}
|
||||||
/>
|
/>
|
||||||
<FormHelperText>Minimum 6 characters. Leave blank to keep current password.</FormHelperText>
|
<FormHelperText>Minimum 6 characters. Leave blank to keep current.</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -353,14 +379,7 @@ const AdminPage: NextPage = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button type="submit" colorScheme="lingva" leftIcon={<FiSave />} isLoading={saving} size="lg" w="full">
|
||||||
type="submit"
|
|
||||||
colorScheme="lingva"
|
|
||||||
leftIcon={<FiSave />}
|
|
||||||
isLoading={saving}
|
|
||||||
size="lg"
|
|
||||||
w="full"
|
|
||||||
>
|
|
||||||
Save Settings
|
Save Settings
|
||||||
</Button>
|
</Button>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const handler: NextApiHandler = async (req, res) => {
|
|||||||
jigsawApiKey,
|
jigsawApiKey,
|
||||||
modelVersion,
|
modelVersion,
|
||||||
replicateEnabled,
|
replicateEnabled,
|
||||||
|
replicateMode,
|
||||||
|
localEndpoint,
|
||||||
newPassword
|
newPassword
|
||||||
} = req.body ?? {};
|
} = req.body ?? {};
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ const handler: NextApiHandler = async (req, res) => {
|
|||||||
if (jigsawApiKey !== undefined) updates.jigsawApiKey = jigsawApiKey;
|
if (jigsawApiKey !== undefined) updates.jigsawApiKey = jigsawApiKey;
|
||||||
if (modelVersion !== undefined) updates.modelVersion = modelVersion;
|
if (modelVersion !== undefined) updates.modelVersion = modelVersion;
|
||||||
if (replicateEnabled !== undefined) updates.replicateEnabled = Boolean(replicateEnabled);
|
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) {
|
if (newPassword && typeof newPassword === "string" && newPassword.length >= 6) {
|
||||||
updates.adminPasswordHash = newPassword;
|
updates.adminPasswordHash = newPassword;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,75 @@
|
|||||||
import { readSettings } from "./settings-store";
|
import { readSettings } from "./settings-store";
|
||||||
|
|
||||||
|
type CogOutput = {
|
||||||
|
status?: string;
|
||||||
|
output?: unknown;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ReplicateOutput = string | string[] | { translation?: string; translated_text?: string; output?: 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,
|
text: string,
|
||||||
targetLanguage: string
|
targetLanguage: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const settings = readSettings();
|
const settings = readSettings();
|
||||||
|
|
||||||
if (!settings.replicateApiToken) {
|
if (!settings.replicateApiToken) throw new Error("Replicate API token not configured");
|
||||||
throw new Error("Replicate API token not configured");
|
if (!settings.jigsawApiKey) throw new Error("JigsawStack API key not configured");
|
||||||
}
|
|
||||||
if (!settings.jigsawApiKey) {
|
const { owner, model, hash } = parseModelVersion(settings.modelVersion);
|
||||||
throw new Error("JigsawStack API key not configured");
|
|
||||||
|
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 = {
|
const response = await fetch(url, {
|
||||||
version: settings.modelVersion,
|
|
||||||
input: {
|
|
||||||
text,
|
|
||||||
api_key: settings.jigsawApiKey,
|
|
||||||
target_language: targetLanguage
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch("https://api.replicate.com/v1/predictions", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${settings.replicateApiToken}`,
|
"Authorization": `Bearer ${settings.replicateApiToken}`,
|
||||||
@@ -40,21 +85,55 @@ export async function replicateTranslate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (data.error) throw new Error(`Replicate model error: ${data.error}`);
|
||||||
|
|
||||||
if (data.error) {
|
return extractText(data.output as ReplicateOutput);
|
||||||
throw new Error(`Replicate model error: ${data.error}`);
|
}
|
||||||
|
|
||||||
|
async function translateViaLocal(
|
||||||
|
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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract translated text from various output formats
|
const data: CogOutput = await response.json();
|
||||||
const output: ReplicateOutput = data.output;
|
|
||||||
|
|
||||||
if (typeof output === "string") return output;
|
if (data.error) throw new Error(`Local model error: ${data.error}`);
|
||||||
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");
|
// Cog wraps output in { status, output } or returns output directly
|
||||||
|
const output = data.output !== undefined ? data.output : data;
|
||||||
|
return extractText(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Batch translate using separator trick to minimize API calls
|
||||||
@@ -72,12 +151,9 @@ export async function replicateTranslateBatch(
|
|||||||
const joined = texts.join(SEPARATOR);
|
const joined = texts.join(SEPARATOR);
|
||||||
const translated = await replicateTranslate(joined, targetLanguage);
|
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);
|
const parts = translated.split(SEPARATOR);
|
||||||
if (parts.length === texts.length) {
|
if (parts.length === texts.length) return parts;
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: translate individually
|
// Fallback: translate individually if separator got mangled
|
||||||
return Promise.all(texts.map(t => replicateTranslate(t, targetLanguage)));
|
return Promise.all(texts.map(t => replicateTranslate(t, targetLanguage)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
export type ReplicateMode = "cloud" | "local";
|
||||||
|
|
||||||
export type Settings = {
|
export type Settings = {
|
||||||
replicateApiToken: string;
|
replicateApiToken: string;
|
||||||
jigsawApiKey: string;
|
jigsawApiKey: string;
|
||||||
modelVersion: string;
|
modelVersion: string;
|
||||||
replicateEnabled: boolean;
|
replicateEnabled: boolean;
|
||||||
|
replicateMode: ReplicateMode;
|
||||||
|
localEndpoint: string;
|
||||||
adminPasswordHash: string;
|
adminPasswordHash: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,6 +18,8 @@ const DEFAULT_SETTINGS: Settings = {
|
|||||||
jigsawApiKey: process.env["JIGSAWSTACK_API_KEY"] ?? "",
|
jigsawApiKey: process.env["JIGSAWSTACK_API_KEY"] ?? "",
|
||||||
modelVersion: "jigsawstack/text-translate:454df4c49941c05dea05175bd37686d0872c73c1f9366d1c2505db32ade52a89",
|
modelVersion: "jigsawstack/text-translate:454df4c49941c05dea05175bd37686d0872c73c1f9366d1c2505db32ade52a89",
|
||||||
replicateEnabled: false,
|
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"
|
adminPasswordHash: process.env["ADMIN_PASSWORD"] ?? "admin"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user