Türkçe çeviri özelliği eklendi (GLM 4.6 ile çeviri yapılıyor)
This commit is contained in:
11
.env.example
11
.env.example
@@ -1,3 +1,14 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:4000
|
VITE_API_BASE_URL=http://localhost:4000
|
||||||
VITE_SUPABASE_URL=""
|
VITE_SUPABASE_URL=""
|
||||||
VITE_SUPABASE_ANON_KEY=""
|
VITE_SUPABASE_ANON_KEY=""
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
SUPABASE_URL=""
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=""
|
||||||
|
SUPABASE_USERS_TABLE="users"
|
||||||
|
JWT_SECRET="change-me"
|
||||||
|
|
||||||
|
# GLM / Anthropic çeviri servisi
|
||||||
|
ZAI_GLM_API_KEY="YOUR_ZAI_GLM_API_KEY"
|
||||||
|
ZAI_GLM_MODEL="glm-4.6"
|
||||||
|
ZAI_GLM_API_URL="https://api.z.ai/api/anthropic"
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PORT=4000
|
- PORT=4000
|
||||||
- CLIENT_ORIGIN=http://localhost:5173
|
- CLIENT_ORIGIN=http://localhost:5173
|
||||||
|
- SUPABASE_URL=${SUPABASE_URL}
|
||||||
|
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
|
||||||
|
- SUPABASE_USERS_TABLE=${SUPABASE_USERS_TABLE:-users}
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- ZAI_GLM_API_KEY=${ZAI_GLM_API_KEY}
|
||||||
|
- ZAI_GLM_MODEL=${ZAI_GLM_MODEL:-glm-4.6}
|
||||||
|
- ZAI_GLM_API_URL=${ZAI_GLM_API_URL:-https://api.z.ai/api/anthropic}
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -53,6 +60,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PORT=4000
|
- PORT=4000
|
||||||
- CLIENT_ORIGIN=http://localhost:4173
|
- CLIENT_ORIGIN=http://localhost:4173
|
||||||
|
- SUPABASE_URL=${SUPABASE_URL}
|
||||||
|
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
|
||||||
|
- SUPABASE_USERS_TABLE=${SUPABASE_USERS_TABLE:-users}
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- ZAI_GLM_API_KEY=${ZAI_GLM_API_KEY}
|
||||||
|
- ZAI_GLM_MODEL=${ZAI_GLM_MODEL:-glm-4.6}
|
||||||
|
- ZAI_GLM_API_URL=${ZAI_GLM_API_URL:-https://api.z.ai/api/anthropic}
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
profiles:
|
profiles:
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import { promises as fs } from 'fs';
|
|||||||
import { v4 as uuidV4 } from 'uuid';
|
import { v4 as uuidV4 } from 'uuid';
|
||||||
import Epub from 'epub-gen';
|
import Epub from 'epub-gen';
|
||||||
|
|
||||||
const requiredEnv = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'JWT_SECRET'];
|
const requiredEnv = [
|
||||||
|
'SUPABASE_URL',
|
||||||
|
'SUPABASE_SERVICE_ROLE_KEY',
|
||||||
|
'JWT_SECRET',
|
||||||
|
'ZAI_GLM_API_KEY',
|
||||||
|
];
|
||||||
requiredEnv.forEach((key) => {
|
requiredEnv.forEach((key) => {
|
||||||
if (!process.env[key]) {
|
if (!process.env[key]) {
|
||||||
console.error(`Missing required environment variable: ${key}`);
|
console.error(`Missing required environment variable: ${key}`);
|
||||||
@@ -25,6 +30,7 @@ const allowedOrigins = ORIGIN.split(',').map((origin) => origin.trim());
|
|||||||
const USERS_TABLE = process.env.SUPABASE_USERS_TABLE || 'users';
|
const USERS_TABLE = process.env.SUPABASE_USERS_TABLE || 'users';
|
||||||
const JWT_SECRET = process.env.JWT_SECRET;
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
import { supabase } from './src/services/supabaseClient.js';
|
import { supabase } from './src/services/supabaseClient.js';
|
||||||
|
import { translateWithGlm } from './src/services/glmClient.js';
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
@@ -248,6 +254,21 @@ authRouter.post('/forgot-password', async (req, res) => {
|
|||||||
|
|
||||||
app.use('/auth', authRouter);
|
app.use('/auth', authRouter);
|
||||||
|
|
||||||
|
app.post('/translate', async (req, res) => {
|
||||||
|
const { text } = req.body || {};
|
||||||
|
if (!text || !text.trim()) {
|
||||||
|
return res.status(400).json({ message: 'Çevrilecek metin bulunamadı.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const translated = await translateWithGlm(text);
|
||||||
|
return res.json({ text: translated });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GLM çeviri hatası:', error);
|
||||||
|
return res.status(500).json({ message: error.message || 'Çeviri tamamlanamadı.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/generate-epub', async (req, res) => {
|
app.post('/generate-epub', async (req, res) => {
|
||||||
const { text, meta, cover } = req.body || {};
|
const { text, meta, cover } = req.body || {};
|
||||||
if (!text || !text.trim()) {
|
if (!text || !text.trim()) {
|
||||||
|
|||||||
142
server/src/services/glmClient.js
Normal file
142
server/src/services/glmClient.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
const GLM_API_KEY = process.env.ZAI_GLM_API_KEY || process.env.ANTHROPIC_API_KEY;
|
||||||
|
const GLM_MODEL = process.env.ANTHROPIC_MODEL || process.env.ZAI_GLM_MODEL || 'glm-4.6';
|
||||||
|
const resolveEndpoint = () => {
|
||||||
|
const base =
|
||||||
|
process.env.ANTHROPIC_BASE_URL || process.env.ZAI_GLM_API_URL || 'https://api.z.ai/api/anthropic';
|
||||||
|
const normalized = base.replace(/\/$/, '');
|
||||||
|
if (/\/messages$/.test(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
if (/\/v\d+$/.test(normalized)) {
|
||||||
|
return `${normalized}/messages`;
|
||||||
|
}
|
||||||
|
if (/\/v\d+\/.+/.test(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return `${normalized}/v1/messages`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GLM_API_URL = resolveEndpoint();
|
||||||
|
const IS_ANTHROPIC_STYLE = /anthropic/.test(GLM_API_URL);
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'You are a professional localization editor. Translate any given English or mixed-language text into fluent, publication-ready Turkish. Keep the original meaning, respect formatting, and avoid adding explanations.';
|
||||||
|
|
||||||
|
const extractContent = (payload) => {
|
||||||
|
if (!payload) return '';
|
||||||
|
if (Array.isArray(payload.content)) {
|
||||||
|
return payload.content
|
||||||
|
.map((item) => {
|
||||||
|
if (!item) return '';
|
||||||
|
if (typeof item === 'string') return item;
|
||||||
|
if (Array.isArray(item.text)) {
|
||||||
|
return item.text.map((inner) => inner?.text || inner || '').join('');
|
||||||
|
}
|
||||||
|
if (typeof item.text === 'string') return item.text;
|
||||||
|
if (Array.isArray(item.content)) {
|
||||||
|
return item.content
|
||||||
|
.map((inner) => (typeof inner === 'string' ? inner : inner?.text || ''))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.output)) {
|
||||||
|
return payload.output
|
||||||
|
.map((item) => item.content || item.text || '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.choices) && payload.choices.length > 0) {
|
||||||
|
const choice = payload.choices[0];
|
||||||
|
if (choice.message?.content) {
|
||||||
|
if (Array.isArray(choice.message.content)) {
|
||||||
|
return choice.message.content
|
||||||
|
.map((c) => (typeof c === 'string' ? c : c.text || ''))
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
return `${choice.message.content}`.trim();
|
||||||
|
}
|
||||||
|
if (choice.text) {
|
||||||
|
return `${choice.text}`.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload.data?.output_text?.length) {
|
||||||
|
return payload.data.output_text.join('\n').trim();
|
||||||
|
}
|
||||||
|
if (typeof payload.content === 'string') {
|
||||||
|
return payload.content.trim();
|
||||||
|
}
|
||||||
|
if (typeof payload.text === 'string') {
|
||||||
|
return payload.text.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const translateWithGlm = async (text) => {
|
||||||
|
if (!GLM_API_KEY) {
|
||||||
|
throw new Error('ZAI_GLM_API_KEY veya ANTHROPIC_API_KEY tanımlı değil.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt =
|
||||||
|
`Aşağıdaki metni Türkçe'ye çevir. Yalnızca çeviriyi döndür:\n\n"""${text}"""`;
|
||||||
|
|
||||||
|
let body;
|
||||||
|
if (IS_ANTHROPIC_STYLE) {
|
||||||
|
body = {
|
||||||
|
model: GLM_MODEL,
|
||||||
|
max_tokens: 1024,
|
||||||
|
temperature: 0.1,
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: prompt }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
body = {
|
||||||
|
model: GLM_MODEL,
|
||||||
|
max_tokens: 1024,
|
||||||
|
temperature: 0.1,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: prompt },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(GLM_API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${GLM_API_KEY}`,
|
||||||
|
...(IS_ANTHROPIC_STYLE && {
|
||||||
|
'x-api-key': GLM_API_KEY,
|
||||||
|
'anthropic-version': process.env.ANTHROPIC_VERSION || '2023-06-01',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
payload?.error?.message ||
|
||||||
|
payload?.msg ||
|
||||||
|
`GLM isteği başarısız oldu (status: ${response.status})`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const translated = extractContent(payload);
|
||||||
|
if (!translated) {
|
||||||
|
throw new Error('GLM çıktısı boş döndü.');
|
||||||
|
}
|
||||||
|
return translated;
|
||||||
|
};
|
||||||
@@ -14,6 +14,9 @@ import { createEpubFromOcr } from '../utils/epubUtils';
|
|||||||
const EpubStep = () => {
|
const EpubStep = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const ocrText = useAppStore((state) => state.ocrText);
|
const ocrText = useAppStore((state) => state.ocrText);
|
||||||
|
const translatedText = useAppStore((state) => state.translatedText);
|
||||||
|
const translationStatus = useAppStore((state) => state.translationStatus);
|
||||||
|
const translationError = useAppStore((state) => state.translationError);
|
||||||
const generatedEpub = useAppStore((state) => state.generatedEpub);
|
const generatedEpub = useAppStore((state) => state.generatedEpub);
|
||||||
const setGeneratedEpub = useAppStore((state) => state.setGeneratedEpub);
|
const setGeneratedEpub = useAppStore((state) => state.setGeneratedEpub);
|
||||||
const setError = useAppStore((state) => state.setError);
|
const setError = useAppStore((state) => state.setError);
|
||||||
@@ -21,14 +24,16 @@ const EpubStep = () => {
|
|||||||
const croppedCoverImage = useAppStore((state) => state.croppedCoverImage);
|
const croppedCoverImage = useAppStore((state) => state.croppedCoverImage);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const needsCoverCrop = Boolean(coverImageId && !croppedCoverImage);
|
const needsCoverCrop = Boolean(coverImageId && !croppedCoverImage);
|
||||||
|
const translationBlocking = translationStatus === 'running';
|
||||||
|
const exportText = translatedText?.trim() || ocrText;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
if (!ocrText?.trim() || generatedEpub || needsCoverCrop) return;
|
if (!exportText?.trim() || generatedEpub || needsCoverCrop || translationBlocking) return;
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
try {
|
try {
|
||||||
const epub = await createEpubFromOcr(ocrText, croppedCoverImage);
|
const epub = await createEpubFromOcr(exportText, croppedCoverImage);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setGeneratedEpub(epub);
|
setGeneratedEpub(epub);
|
||||||
}
|
}
|
||||||
@@ -46,7 +51,15 @@ const EpubStep = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [croppedCoverImage, generatedEpub, needsCoverCrop, ocrText, setError, setGeneratedEpub]);
|
}, [
|
||||||
|
croppedCoverImage,
|
||||||
|
exportText,
|
||||||
|
generatedEpub,
|
||||||
|
needsCoverCrop,
|
||||||
|
setError,
|
||||||
|
setGeneratedEpub,
|
||||||
|
translationBlocking,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!ocrText?.trim()) {
|
if (!ocrText?.trim()) {
|
||||||
return (
|
return (
|
||||||
@@ -67,6 +80,23 @@ const EpubStep = () => {
|
|||||||
OCR sonucundaki tüm metinleri tek bir EPUB dosyasında topluyoruz.
|
OCR sonucundaki tüm metinleri tek bir EPUB dosyasında topluyoruz.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
{translationBlocking && (
|
||||||
|
<Alert severity="info">
|
||||||
|
Türkçe çeviri tamamlanana kadar EPUB üretimi bekletiliyor. Lütfen çeviri ekranındaki işlemin bitmesini
|
||||||
|
bekle.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{translationStatus === 'done' && translatedText && (
|
||||||
|
<Alert severity="success">
|
||||||
|
Çeviri tamamlandı. EPUB, Türkçe metinle oluşturuluyor.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{translationStatus === 'error' && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
Çeviri sırasında bir sorun oluştu. EPUB üretiminde orijinal OCR metni kullanılacak.
|
||||||
|
{translationError ? ` (${translationError})` : ''}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
{needsCoverCrop && (
|
{needsCoverCrop && (
|
||||||
<Alert severity="warning">
|
<Alert severity="warning">
|
||||||
Kapak olarak işaretlediğin görseli croplamalısın. Crop adımında kapak görselini kaydet ve tekrar dene.
|
Kapak olarak işaretlediğin görseli croplamalısın. Crop adımında kapak görselini kaydet ve tekrar dene.
|
||||||
@@ -108,7 +138,7 @@ const EpubStep = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={!generatedEpub || processing || needsCoverCrop}
|
disabled={!generatedEpub || processing || needsCoverCrop || translationBlocking}
|
||||||
onClick={() => navigate('/download')}
|
onClick={() => navigate('/download')}
|
||||||
>
|
>
|
||||||
EPUB'i indir
|
EPUB'i indir
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import Tesseract from 'tesseract.js';
|
import Tesseract from 'tesseract.js';
|
||||||
import { useAppStore } from '../store/useAppStore';
|
import { useAppStore } from '../store/useAppStore';
|
||||||
import { correctTurkishCharacters } from '../utils/ocrUtils';
|
import { correctTurkishCharacters } from '../utils/ocrUtils';
|
||||||
|
import { translateChunkToTurkish } from '../utils/translationUtils';
|
||||||
|
|
||||||
const OcrStep = () => {
|
const OcrStep = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -19,7 +20,17 @@ const OcrStep = () => {
|
|||||||
const ocrText = useAppStore((state) => state.ocrText);
|
const ocrText = useAppStore((state) => state.ocrText);
|
||||||
const setOcrText = useAppStore((state) => state.setOcrText);
|
const setOcrText = useAppStore((state) => state.setOcrText);
|
||||||
const setError = useAppStore((state) => state.setError);
|
const setError = useAppStore((state) => state.setError);
|
||||||
|
const translatedText = useAppStore((state) => state.translatedText);
|
||||||
|
const translationStatus = useAppStore((state) => state.translationStatus);
|
||||||
|
const translationError = useAppStore((state) => state.translationError);
|
||||||
|
const translationProgress = useAppStore((state) => state.translationProgress);
|
||||||
|
const setTranslatedText = useAppStore((state) => state.setTranslatedText);
|
||||||
|
const setTranslationStatus = useAppStore((state) => state.setTranslationStatus);
|
||||||
|
const setTranslationError = useAppStore((state) => state.setTranslationError);
|
||||||
|
const setTranslationProgress = useAppStore((state) => state.setTranslationProgress);
|
||||||
|
const clearTranslation = useAppStore((state) => state.clearTranslation);
|
||||||
const [status, setStatus] = useState('idle');
|
const [status, setStatus] = useState('idle');
|
||||||
|
const [translationTrigger, setTranslationTrigger] = useState(0);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [previewText, setPreviewText] = useState('');
|
const [previewText, setPreviewText] = useState('');
|
||||||
const total = croppedImages.length;
|
const total = croppedImages.length;
|
||||||
@@ -36,6 +47,7 @@ const OcrStep = () => {
|
|||||||
const workerRef = useRef(null);
|
const workerRef = useRef(null);
|
||||||
const [workerReady, setWorkerReady] = useState(false);
|
const [workerReady, setWorkerReady] = useState(false);
|
||||||
const previewRef = useRef(null);
|
const previewRef = useRef(null);
|
||||||
|
const translationPreviewRef = useRef(null);
|
||||||
|
|
||||||
const orderedImages = useMemo(
|
const orderedImages = useMemo(
|
||||||
() => [...croppedImages].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
() => [...croppedImages].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||||
@@ -120,13 +132,19 @@ const OcrStep = () => {
|
|||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setPreviewText('');
|
setPreviewText('');
|
||||||
setOcrText('');
|
setOcrText('');
|
||||||
}, [orderedImages, setOcrText]);
|
clearTranslation();
|
||||||
|
}, [clearTranslation, orderedImages, setOcrText]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previewRef.current) {
|
if (previewRef.current) {
|
||||||
previewRef.current.scrollTop = previewRef.current.scrollHeight;
|
previewRef.current.scrollTop = previewRef.current.scrollHeight;
|
||||||
}
|
}
|
||||||
}, [previewText]);
|
}, [previewText]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (translationPreviewRef.current) {
|
||||||
|
translationPreviewRef.current.scrollTop = translationPreviewRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [translatedText]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!total || status === 'done' || !workerReady) return;
|
if (!total || status === 'done' || !workerReady) return;
|
||||||
@@ -171,6 +189,59 @@ const OcrStep = () => {
|
|||||||
};
|
};
|
||||||
}, [orderedImages, setError, setOcrText, status, total, workerReady]);
|
}, [orderedImages, setError, setOcrText, status, total, workerReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'done') return;
|
||||||
|
if (!ocrText?.trim()) return;
|
||||||
|
if (translationStatus === 'running' || translationStatus === 'done') return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const sections = segmentOcrText(ocrText);
|
||||||
|
if (!sections.length) return;
|
||||||
|
|
||||||
|
const runTranslation = async () => {
|
||||||
|
setTranslationStatus('running');
|
||||||
|
setTranslationError(null);
|
||||||
|
setTranslationProgress(0);
|
||||||
|
setTranslatedText('');
|
||||||
|
try {
|
||||||
|
const translatedChunks = [];
|
||||||
|
for (let index = 0; index < sections.length; index += 1) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const chunk = sections[index];
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const translated = await translateChunkToTurkish(chunk);
|
||||||
|
if (cancelled) return;
|
||||||
|
translatedChunks[index] = translated;
|
||||||
|
const combined = translatedChunks.filter(Boolean).join('\n\n');
|
||||||
|
setTranslatedText(combined);
|
||||||
|
setTranslationProgress(Math.round(((index + 1) / sections.length) * 100));
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setTranslationStatus('done');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setTranslationStatus('error');
|
||||||
|
setTranslationError(error.message || 'Çeviri tamamlanamadı.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
runTranslation();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
ocrText,
|
||||||
|
setTranslatedText,
|
||||||
|
setTranslationError,
|
||||||
|
setTranslationProgress,
|
||||||
|
setTranslationStatus,
|
||||||
|
status,
|
||||||
|
translationTrigger,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!orderedImages.length) {
|
if (!orderedImages.length) {
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
@@ -228,6 +299,69 @@ const OcrStep = () => {
|
|||||||
{previewText || 'Metin bekleniyor'}
|
{previewText || 'Metin bekleniyor'}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box sx={{ p: 2, borderRadius: 2, bgcolor: 'background.default' }}>
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems="flex-start" justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1">Türkçe çeviriler</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
OCR metni parçalara ayrılıp GLM 4.6 ile çevriliyor.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{translationStatus === 'error' && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
clearTranslation();
|
||||||
|
setTranslationTrigger((prev) => prev + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tekrar dene
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
{translationStatus === 'running' && (
|
||||||
|
<Box mt={2}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={translationProgress}
|
||||||
|
sx={{ height: 8, borderRadius: 3 }}
|
||||||
|
/>
|
||||||
|
<Typography mt={1} color="text.secondary" variant="caption">
|
||||||
|
%{translationProgress} tamamlandı
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{translationStatus === 'done' && translatedText && (
|
||||||
|
<Alert severity="success" sx={{ mt: 2 }}>
|
||||||
|
Çeviri tamamlandı. EPUB üretiminde Türkçe içerik kullanılacak.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{translationStatus === 'error' && translationError && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
{translationError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
ref={translationPreviewRef}
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
maxHeight: '8.5em',
|
||||||
|
overflowY: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
color: 'text.secondary',
|
||||||
|
pr: 1,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
p: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{translatedText || 'Çeviri bekleniyor...'}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
|
||||||
<Button variant="contained" onClick={() => navigate('/bulk-crop')}>
|
<Button variant="contained" onClick={() => navigate('/bulk-crop')}>
|
||||||
@@ -236,7 +370,7 @@ const OcrStep = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => navigate('/epub')}
|
onClick={() => navigate('/epub')}
|
||||||
disabled={!hasResults}
|
disabled={!hasResults || translationStatus === 'running'}
|
||||||
>
|
>
|
||||||
EPUB oluştur
|
EPUB oluştur
|
||||||
</Button>
|
</Button>
|
||||||
@@ -246,3 +380,37 @@ const OcrStep = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default OcrStep;
|
export default OcrStep;
|
||||||
|
|
||||||
|
const MAX_CHUNK_LENGTH = 800;
|
||||||
|
|
||||||
|
const segmentOcrText = (text) => {
|
||||||
|
if (!text) return [];
|
||||||
|
const normalized = text.replace(/\r\n/g, '\n');
|
||||||
|
const paragraphs = normalized.split(/\n{2,}/).map((part) => part.trim()).filter(Boolean);
|
||||||
|
const chunks = [];
|
||||||
|
|
||||||
|
paragraphs.forEach((paragraph) => {
|
||||||
|
if (paragraph.length <= MAX_CHUNK_LENGTH) {
|
||||||
|
chunks.push(paragraph);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = paragraph;
|
||||||
|
while (remaining.length > MAX_CHUNK_LENGTH) {
|
||||||
|
let sliceIndex = remaining.lastIndexOf(' ', MAX_CHUNK_LENGTH);
|
||||||
|
if (sliceIndex === -1 || sliceIndex < MAX_CHUNK_LENGTH * 0.6) {
|
||||||
|
sliceIndex = MAX_CHUNK_LENGTH;
|
||||||
|
}
|
||||||
|
const chunk = remaining.slice(0, sliceIndex).trim();
|
||||||
|
if (chunk) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
remaining = remaining.slice(sliceIndex).trim();
|
||||||
|
}
|
||||||
|
if (remaining.length) {
|
||||||
|
chunks.push(remaining);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
};
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const UploadStep = () => {
|
|||||||
{uploadedImages.map((image) => (
|
{uploadedImages.map((image) => (
|
||||||
<Grid item xs={12} sm={6} md={4} lg={3} key={image.id}>
|
<Grid item xs={12} sm={6} md={4} lg={3} key={image.id}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardActionArea>
|
<CardActionArea component="div">
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
height="160"
|
height="160"
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export const useAppStore = create((set) => ({
|
|||||||
coverCropConfig: createEmptyCropConfig(),
|
coverCropConfig: createEmptyCropConfig(),
|
||||||
croppedCoverImage: null,
|
croppedCoverImage: null,
|
||||||
ocrText: '',
|
ocrText: '',
|
||||||
|
translatedText: '',
|
||||||
|
translationStatus: 'idle',
|
||||||
|
translationError: null,
|
||||||
|
translationProgress: 0,
|
||||||
generatedEpub: null,
|
generatedEpub: null,
|
||||||
authToken: null,
|
authToken: null,
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
@@ -68,6 +72,17 @@ export const useAppStore = create((set) => ({
|
|||||||
return { croppedCoverImage: image };
|
return { croppedCoverImage: image };
|
||||||
}),
|
}),
|
||||||
setOcrText: (text) => set({ ocrText: text }),
|
setOcrText: (text) => set({ ocrText: text }),
|
||||||
|
setTranslatedText: (text) => set({ translatedText: text }),
|
||||||
|
setTranslationStatus: (status) => set({ translationStatus: status }),
|
||||||
|
setTranslationError: (message) => set({ translationError: message }),
|
||||||
|
setTranslationProgress: (value) => set({ translationProgress: value }),
|
||||||
|
clearTranslation: () =>
|
||||||
|
set({
|
||||||
|
translatedText: '',
|
||||||
|
translationStatus: 'idle',
|
||||||
|
translationError: null,
|
||||||
|
translationProgress: 0,
|
||||||
|
}),
|
||||||
setGeneratedEpub: (epub) =>
|
setGeneratedEpub: (epub) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.generatedEpub?.url) {
|
if (state.generatedEpub?.url) {
|
||||||
@@ -132,6 +147,10 @@ export const useAppStore = create((set) => ({
|
|||||||
draft.coverCropConfig = createEmptyCropConfig();
|
draft.coverCropConfig = createEmptyCropConfig();
|
||||||
draft.croppedCoverImage = null;
|
draft.croppedCoverImage = null;
|
||||||
draft.ocrText = '';
|
draft.ocrText = '';
|
||||||
|
draft.translatedText = '';
|
||||||
|
draft.translationStatus = 'idle';
|
||||||
|
draft.translationError = null;
|
||||||
|
draft.translationProgress = 0;
|
||||||
if (state.generatedEpub?.url) {
|
if (state.generatedEpub?.url) {
|
||||||
URL.revokeObjectURL(state.generatedEpub.url);
|
URL.revokeObjectURL(state.generatedEpub.url);
|
||||||
}
|
}
|
||||||
@@ -141,6 +160,10 @@ export const useAppStore = create((set) => ({
|
|||||||
state.croppedImages.forEach((img) => img.url && URL.revokeObjectURL(img.url));
|
state.croppedImages.forEach((img) => img.url && URL.revokeObjectURL(img.url));
|
||||||
draft.croppedImages = [];
|
draft.croppedImages = [];
|
||||||
draft.ocrText = '';
|
draft.ocrText = '';
|
||||||
|
draft.translatedText = '';
|
||||||
|
draft.translationStatus = 'idle';
|
||||||
|
draft.translationError = null;
|
||||||
|
draft.translationProgress = 0;
|
||||||
if (state.generatedEpub?.url) {
|
if (state.generatedEpub?.url) {
|
||||||
URL.revokeObjectURL(state.generatedEpub.url);
|
URL.revokeObjectURL(state.generatedEpub.url);
|
||||||
}
|
}
|
||||||
@@ -148,6 +171,10 @@ export const useAppStore = create((set) => ({
|
|||||||
}
|
}
|
||||||
if (step === 'ocr') {
|
if (step === 'ocr') {
|
||||||
draft.ocrText = '';
|
draft.ocrText = '';
|
||||||
|
draft.translatedText = '';
|
||||||
|
draft.translationStatus = 'idle';
|
||||||
|
draft.translationError = null;
|
||||||
|
draft.translationProgress = 0;
|
||||||
if (state.generatedEpub?.url) {
|
if (state.generatedEpub?.url) {
|
||||||
URL.revokeObjectURL(state.generatedEpub.url);
|
URL.revokeObjectURL(state.generatedEpub.url);
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/utils/translationUtils.js
Normal file
25
src/utils/translationUtils.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
|
export const translateChunkToTurkish = async (text) => {
|
||||||
|
if (!text?.trim()) {
|
||||||
|
throw new Error('Çevrilecek metin bulunamadı.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/translate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text();
|
||||||
|
throw new Error(message || 'Çeviri isteği başarısız oldu.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!payload?.text) {
|
||||||
|
throw new Error('Çeviri yanıtı boş döndü.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.text.trim();
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user