diff --git a/.env.example b/.env.example index a5b2d1e..4abf643 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,14 @@ VITE_API_BASE_URL=http://localhost:4000 VITE_SUPABASE_URL="" 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" diff --git a/docker-compose.yml b/docker-compose.yml index 786e02c..4d85562 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,13 @@ services: environment: - PORT=4000 - 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: - "4000:4000" volumes: @@ -53,6 +60,13 @@ services: environment: - PORT=4000 - 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: - "4000:4000" profiles: diff --git a/server/index.js b/server/index.js index d88340b..5238788 100644 --- a/server/index.js +++ b/server/index.js @@ -9,7 +9,12 @@ import { promises as fs } from 'fs'; import { v4 as uuidV4 } from 'uuid'; 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) => { if (!process.env[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 JWT_SECRET = process.env.JWT_SECRET; import { supabase } from './src/services/supabaseClient.js'; +import { translateWithGlm } from './src/services/glmClient.js'; app.use( cors({ @@ -248,6 +254,21 @@ authRouter.post('/forgot-password', async (req, res) => { 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) => { const { text, meta, cover } = req.body || {}; if (!text || !text.trim()) { diff --git a/server/src/services/glmClient.js b/server/src/services/glmClient.js new file mode 100644 index 0000000..8919a4a --- /dev/null +++ b/server/src/services/glmClient.js @@ -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; +}; diff --git a/src/components/EpubStep.jsx b/src/components/EpubStep.jsx index c37e6c5..a210657 100644 --- a/src/components/EpubStep.jsx +++ b/src/components/EpubStep.jsx @@ -14,6 +14,9 @@ import { createEpubFromOcr } from '../utils/epubUtils'; const EpubStep = () => { const navigate = useNavigate(); 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 setGeneratedEpub = useAppStore((state) => state.setGeneratedEpub); const setError = useAppStore((state) => state.setError); @@ -21,14 +24,16 @@ const EpubStep = () => { const croppedCoverImage = useAppStore((state) => state.croppedCoverImage); const [processing, setProcessing] = useState(false); const needsCoverCrop = Boolean(coverImageId && !croppedCoverImage); + const translationBlocking = translationStatus === 'running'; + const exportText = translatedText?.trim() || ocrText; useEffect(() => { let cancelled = false; const run = async () => { - if (!ocrText?.trim() || generatedEpub || needsCoverCrop) return; + if (!exportText?.trim() || generatedEpub || needsCoverCrop || translationBlocking) return; setProcessing(true); try { - const epub = await createEpubFromOcr(ocrText, croppedCoverImage); + const epub = await createEpubFromOcr(exportText, croppedCoverImage); if (!cancelled) { setGeneratedEpub(epub); } @@ -46,7 +51,15 @@ const EpubStep = () => { return () => { cancelled = true; }; - }, [croppedCoverImage, generatedEpub, needsCoverCrop, ocrText, setError, setGeneratedEpub]); + }, [ + croppedCoverImage, + exportText, + generatedEpub, + needsCoverCrop, + setError, + setGeneratedEpub, + translationBlocking, + ]); if (!ocrText?.trim()) { return ( @@ -67,6 +80,23 @@ const EpubStep = () => { OCR sonucundaki tüm metinleri tek bir EPUB dosyasında topluyoruz. + {translationBlocking && ( + + Türkçe çeviri tamamlanana kadar EPUB üretimi bekletiliyor. Lütfen çeviri ekranındaki işlemin bitmesini + bekle. + + )} + {translationStatus === 'done' && translatedText && ( + + Çeviri tamamlandı. EPUB, Türkçe metinle oluşturuluyor. + + )} + {translationStatus === 'error' && ( + + Çeviri sırasında bir sorun oluştu. EPUB üretiminde orijinal OCR metni kullanılacak. + {translationError ? ` (${translationError})` : ''} + + )} {needsCoverCrop && ( 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 = () => { + )} + + {translationStatus === 'running' && ( + + + + %{translationProgress} tamamlandı + + + )} + {translationStatus === 'done' && translatedText && ( + + Çeviri tamamlandı. EPUB üretiminde Türkçe içerik kullanılacak. + + )} + {translationStatus === 'error' && translationError && ( + + {translationError} + + )} + + {translatedText || 'Çeviri bekleniyor...'} + + @@ -246,3 +380,37 @@ const 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; +}; diff --git a/src/components/UploadStep.jsx b/src/components/UploadStep.jsx index 42bf326..db9e9ee 100644 --- a/src/components/UploadStep.jsx +++ b/src/components/UploadStep.jsx @@ -94,7 +94,7 @@ const UploadStep = () => { {uploadedImages.map((image) => ( - + ({ coverCropConfig: createEmptyCropConfig(), croppedCoverImage: null, ocrText: '', + translatedText: '', + translationStatus: 'idle', + translationError: null, + translationProgress: 0, generatedEpub: null, authToken: null, currentUser: null, @@ -68,6 +72,17 @@ export const useAppStore = create((set) => ({ return { croppedCoverImage: image }; }), 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) => set((state) => { if (state.generatedEpub?.url) { @@ -132,6 +147,10 @@ export const useAppStore = create((set) => ({ draft.coverCropConfig = createEmptyCropConfig(); draft.croppedCoverImage = null; draft.ocrText = ''; + draft.translatedText = ''; + draft.translationStatus = 'idle'; + draft.translationError = null; + draft.translationProgress = 0; if (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)); draft.croppedImages = []; draft.ocrText = ''; + draft.translatedText = ''; + draft.translationStatus = 'idle'; + draft.translationError = null; + draft.translationProgress = 0; if (state.generatedEpub?.url) { URL.revokeObjectURL(state.generatedEpub.url); } @@ -148,6 +171,10 @@ export const useAppStore = create((set) => ({ } if (step === 'ocr') { draft.ocrText = ''; + draft.translatedText = ''; + draft.translationStatus = 'idle'; + draft.translationError = null; + draft.translationProgress = 0; if (state.generatedEpub?.url) { URL.revokeObjectURL(state.generatedEpub.url); } diff --git a/src/utils/translationUtils.js b/src/utils/translationUtils.js new file mode 100644 index 0000000..1f2c6c9 --- /dev/null +++ b/src/utils/translationUtils.js @@ -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(); +};