Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 157 additions & 64 deletions components/TranslationInput.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"use client";

import { Box, VStack, Textarea, Select, Button, Spinner, HStack, Grid, GridItem, Collapse, Flex, useToast, Text, Accordion, AccordionItem, AccordionButton, AccordionPanel, AccordionIcon } from "@chakra-ui/react";
import { useState, useEffect } from "react";
import axios from "axios";
import { useTranslationContext } from "../contexts/TranslationContext";
import { Box, VStack, Textarea, Select, Button, Spinner, HStack, Grid, GridItem, Flex, useToast, Text } from "@chakra-ui/react";
import { useState } from "react";
// Removed axios import, will use fetch
import { useTranslationContext, CustomAPI as CustomAPIType } from "../contexts/TranslationContext";
import { CopyIcon } from "@chakra-ui/icons";
import { IconButton } from "@chakra-ui/react";
import { v4 as uuidv4 } from 'uuid'; // 用于生成唯一ID
Expand All @@ -13,9 +13,9 @@ const TranslationInput = () => {
const [text, setText] = useState<string>("");
const [sourceLang, setSourceLang] = useState<string>("auto");
const [targetLang, setTargetLang] = useState<string>("en");
const { services, customAPIs, addHistory } = useTranslationContext();
const [translations, setTranslations] = useState<{ service: string; text: string; name: string }[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const { services, customAPIs, addHistory, presetServiceConfigs } = useTranslationContext(); // Added presetServiceConfigs
const [translations, setTranslations] = useState<{ service: string; text: string; name: string; streaming?: boolean }[]>([]);
const [loadingServices, setLoadingServices] = useState<Record<string, boolean>>({}); // Track loading state per service
const toast = useToast();

const handleTranslate = async () => {
Expand All @@ -31,62 +31,151 @@ const TranslationInput = () => {
return;
}

setLoading(true);
setTranslations([]);
setTranslations(services.map(serviceId => ({ // Initialize translations
service: serviceId,
text: "",
name: getServiceName(serviceId),
streaming: false,
})));

const newLoadingStates: Record<string, boolean> = {};
services.forEach(s => newLoadingStates[s] = true);
setLoadingServices(newLoadingStates);

try {
const promises = services.map(service =>
axios.post("/api/translate", {
services.forEach(async (serviceId, serviceIndex) => {
try {
const currentCustomAPI = serviceId.startsWith("custom_")
? customAPIs.find((api: CustomAPIType) => api.id === serviceId)
: null;

const effectiveSupportsStreaming = serviceId === 'openai' || (currentCustomAPI?.supportsStreaming);

const requestBody: any = {
text,
sourceLang: sourceLang === "auto" ? "" : sourceLang,
targetLang,
service,
customAPIs,
})
);

const results = await Promise.all(promises);
const newTranslations = results.map((res, index) => ({
service: services[index],
text: res.data.translatedText,
name: getServiceName(services[index]),
}));
setTranslations(newTranslations);

// 添加到历史记录
newTranslations.forEach(t => {
addHistory({
id: uuidv4(),
inputText: text,
sourceLang,
targetLang,
service: t.service,
translatedText: t.text,
timestamp: new Date().toISOString(),
service: serviceId,
customAPIs, // For custom_ service, backend uses this
};

// If it's a preset service, add its specific config if available
if (!serviceId.startsWith("custom_")) {
const currentServicePresetConfig = presetServiceConfigs[serviceId];
if (currentServicePresetConfig && Object.keys(currentServicePresetConfig).length > 0) {
requestBody.serviceConfig = currentServicePresetConfig;
}
}

const response = await fetch("/api/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
});
});

toast({
title: "翻译成功",
description: "您的文本已成功翻译。",
status: "success",
duration: 3000,
isClosable: true,
position: "top",
});
} catch (error: any) {
console.error(error);
toast({
title: "翻译失败",
description: error.response?.data?.error || "翻译失败,请稍后重试。",
status: "error",
duration: 3000,
isClosable: true,
position: "top",
});
} finally {
setLoading(false);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: "请求失败,无法解析错误信息。" }));
throw new Error(errorData.error || `HTTP error ${response.status}`);
}

const contentType = response.headers.get("content-type");

if (effectiveSupportsStreaming && contentType && contentType.includes("text/event-stream")) {
setTranslations(prev => prev.map((t, i) => i === serviceIndex ? { ...t, streaming: true } : t));

const reader = response.body?.getReader();
if (!reader) throw new Error("无法读取流。");
const decoder = new TextDecoder();
let accumulatedText = "";

while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n\n");

for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const jsonData = line.substring("data: ".length);
if (jsonData.trim() === "[DONE]") { // Check for a potential DONE signal if not part of JSON
break;
}
const parsed = JSON.parse(jsonData);
if (parsed.translatedText) {
accumulatedText += parsed.translatedText;
setTranslations(prev =>
prev.map((t, i) => i === serviceIndex ? { ...t, text: accumulatedText } : t)
);
} else if (parsed.error) {
console.error("Stream error from API:", parsed.error);
// Display error in the translation box or via toast
setTranslations(prev =>
prev.map((t, i) => i === serviceIndex ? { ...t, text: `错误: ${parsed.error}` } : t)
);
return; // Stop processing this stream
}
} catch (e) {
// Might be a non-JSON part of the chunk, or [DONE]
if (line.includes("[DONE]")) break; // More robust [DONE] check
console.warn("Non-JSON data in stream or malformed chunk:", line, e);
}
}
}
}
reader.releaseLock();
addHistory({
id: uuidv4(),
inputText: text,
sourceLang,
targetLang,
service: serviceId,
translatedText: accumulatedText,
timestamp: new Date().toISOString(),
});

} else { // Handle non-streaming response
const data = await response.json();
if (data.error) throw new Error(data.error);

setTranslations(prev =>
prev.map((t, i) => i === serviceIndex ? { ...t, text: data.translatedText } : t)
);
addHistory({
id: uuidv4(),
inputText: text,
sourceLang,
targetLang,
service: serviceId,
translatedText: data.translatedText,
timestamp: new Date().toISOString(),
});
}
toast({
title: `服务 "${getServiceName(serviceId)}" 翻译成功`,
status: "success",
duration: 2000,
isClosable: true,
position: "top",
});

} catch (error: any) {
console.error(`Error with service ${serviceId}:`, error);
const errorMessage = error.message || "翻译失败,请稍后重试。";
setTranslations(prev =>
prev.map((t, i) => i === serviceIndex ? { ...t, text: `错误: ${errorMessage}` } : t)
);
toast({
title: `服务 "${getServiceName(serviceId)}" 翻译失败`,
description: errorMessage,
status: "error",
duration: 3000,
isClosable: true,
position: "top",
});
} finally {
setLoadingServices(prev => ({ ...prev, [serviceId]: false }));
}
});
};

const handleCopy = (text: string) => {
Expand All @@ -111,7 +200,7 @@ const TranslationInput = () => {
});
};

const getServiceName = (serviceId: string) => {
const getServiceName = (serviceId: string): string => {
const serviceMap: { [key: string]: string } = {
google: "谷歌翻译",
openai: "OpenAI",
Expand Down Expand Up @@ -183,29 +272,33 @@ const TranslationInput = () => {
<Button
colorScheme="brand"
onClick={handleTranslate}
isDisabled={!text || loading}
isDisabled={!text || Object.values(loadingServices).some(Boolean)}
width="100%"
leftIcon={loading ? <Spinner size="sm" /> : undefined}
// Spinner logic can be per translation box or a global one
_hover={{ transform: "translateY(-2px)", boxShadow: "lg" }}
_active={{ transform: "translateY(1px)" }}
transition="all 0.3s ease"
>
{loading ? "翻译中..." : "翻译"}
{Object.values(loadingServices).some(Boolean) ? "翻译中..." : "翻译"}
</Button>

<VStack align="stretch" spacing={4}>
{translations.map((t, index) => (
<Box
key={index}
key={t.service} // Use service id as key
p={4}
bg="gray.700"
borderRadius="md"
boxShadow="md"
>
<Flex justify="space-between" align="center" mb={2}>
<Text fontWeight="bold" color="brand.200">{t.name}</Text>
<HStack>
<Text fontWeight="bold" color="brand.200">{t.name}</Text>
{loadingServices[t.service] && <Spinner size="sm" color="brand.300" />}
{t.streaming && <Text fontSize="xs" color="green.300">(流式)</Text>}
</HStack>
<IconButton
aria-label={`复制来自${t.name}的翻译文本`}
aria-label={`复制来自 ${t.name} 的翻译文本`}
icon={<CopyIcon />}
size="sm"
colorScheme="brand"
Expand Down
Loading
Loading