Initial working translator implementation
This commit is contained in:
commit
9439858b11
35 changed files with 5365 additions and 0 deletions
297
frontend/src/views/TranslatorView.vue
Normal file
297
frontend/src/views/TranslatorView.vue
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useTranslationStore } from '../stores/translation'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
import LLMSelector from '../components/LLMSelector.vue'
|
||||
import ProperNounsEditor from '../components/ProperNounsEditor.vue'
|
||||
import ComparisonView from '../components/ComparisonView.vue'
|
||||
|
||||
const store = useTranslationStore()
|
||||
const auth = useAuthStore()
|
||||
const theme = useThemeStore()
|
||||
const showHistorySidebar = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadModels()
|
||||
store.newSession()
|
||||
store.loadHistory()
|
||||
})
|
||||
|
||||
// Language options
|
||||
const languages = [
|
||||
{ value: 'auto', label: '자동 판단' },
|
||||
{ value: '영어', label: '영어' },
|
||||
{ value: '중국어', label: '중국어(간체)' },
|
||||
{ value: '중문(번체)', label: '중국어(번체)' },
|
||||
{ value: '일본어', label: '일본어' },
|
||||
{ value: '프랑스어', label: '프랑스어' },
|
||||
{ value: '독일어', label: '독일어' },
|
||||
{ value: '스페인어', label: '스페인어' },
|
||||
{ value: '러시아어', label: '러시아어' },
|
||||
]
|
||||
|
||||
const targetLanguages = [
|
||||
...languages.filter(l => l.value !== 'auto'),
|
||||
{ value: '한국어', label: '한국어' },
|
||||
]
|
||||
|
||||
function phaseLabel(n: number) {
|
||||
const labels = ['', '초벌번역', '고유명사확인', '재번역', '마무리']
|
||||
return labels[n] || ''
|
||||
}
|
||||
|
||||
async function runPhase(phase: number) {
|
||||
switch (phase) {
|
||||
case 1: await store.executePhase1(); break
|
||||
case 2: await store.executePhase2(); break
|
||||
case 3: await store.executePhase3(); break
|
||||
case 4: await store.executePhase4(); break
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewTranslation() {
|
||||
if (store.phase1Result || store.phase3Result || store.phase4Result) {
|
||||
if (!confirm('현재 번역을 새로운 번역으로 교체하시겠습니까?')) return
|
||||
}
|
||||
store.newSession()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<!-- Top Bar -->
|
||||
<header class="bg-white/90 dark:bg-gray-800/90 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10 shadow-sm backdrop-blur">
|
||||
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-lg font-bold text-gray-800 dark:text-gray-100">LLM 번역기</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button
|
||||
@click="theme.toggle()"
|
||||
class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors flex items-center gap-1.5"
|
||||
:title="theme.isDark ? '라이트모드' : '다크모드'"
|
||||
>
|
||||
{{ theme.isDark ? '☀️' : '🌙' }}
|
||||
</button>
|
||||
|
||||
<!-- History Button -->
|
||||
<button
|
||||
@click="showHistorySidebar = true"
|
||||
class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors flex items-center gap-1.5"
|
||||
title="히스토리"
|
||||
>
|
||||
🕐 히스토리 ({{ store.history.length }})
|
||||
</button>
|
||||
|
||||
<!-- User Info & Logout -->
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ auth.user_id }}</span>
|
||||
<button @click="auth.logout()" class="px-3 py-1.5 text-sm bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 text-red-600 rounded-lg transition-colors">
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<!-- Model Selection Row -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<LLMSelector label="Phase 1 — 초벌번역" v-model:value="store.modelPhase1" :models="store.models" />
|
||||
<LLMSelector label="Phase 2 — 고유명사" v-model:value="store.modelPhase2" :models="store.models" />
|
||||
<LLMSelector label="Phase 3 — 재번역" v-model:value="store.modelPhase3" :models="store.models" />
|
||||
<LLMSelector label="Phase 4 — 마무리" v-model:value="store.modelPhase4" :models="store.models" />
|
||||
|
||||
<!-- Language Selectors -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">출발어</label>
|
||||
<select
|
||||
v-model="store.sourceLanguage"
|
||||
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option v-for="lang in languages" :key="lang.value" :value="lang.value">
|
||||
{{ lang.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end pb-2 text-gray-400">→</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">도착어</label>
|
||||
<select
|
||||
v-model="store.targetLanguage"
|
||||
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option v-for="lang in targetLanguages" :key="lang.value" :value="lang.value">
|
||||
{{ lang.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- New Translation Button -->
|
||||
<button @click="handleNewTranslation()" class="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
||||
🔄 새 번역
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Source Text Input -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-200">📝 원문 입력</h2>
|
||||
<span class="text-xs text-gray-400">{{ store.sourceText.length }}자</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="store.sourceText"
|
||||
rows="10"
|
||||
placeholder="번역할 원문을 여기에 붙여넣으세요..."
|
||||
class="w-full px-4 py-3 border border-gray-200 dark:border-gray-600 rounded-lg text-sm leading-relaxed resize-y bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
<!-- Phase Buttons -->
|
||||
<section class="flex flex-wrap gap-3">
|
||||
<button
|
||||
@click="runPhase(1)"
|
||||
:disabled="store.loading || !store.sourceText.trim() || !store.modelPhase1"
|
||||
class="px-5 py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 1">⏳</span>
|
||||
<span v-else>▶</span>
|
||||
Phase 1 — 초벌번역
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="runPhase(2)"
|
||||
:disabled="store.loading || !store.phase2Ready"
|
||||
class="px-5 py-2.5 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 2">⏳</span>
|
||||
<span v-else>✓</span>
|
||||
Phase 2 — 고유명사 확인
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="runPhase(3)"
|
||||
:disabled="store.loading || !store.phase3Ready"
|
||||
class="px-5 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 3">⏳</span>
|
||||
<span v-else>▶</span>
|
||||
Phase 3 — 재번역
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="runPhase(4)"
|
||||
:disabled="store.loading || !store.phase4Ready"
|
||||
class="px-5 py-2.5 bg-pink-600 text-white rounded-lg font-medium hover:bg-pink-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 4">⏳</span>
|
||||
<span v-else>✨</span>
|
||||
Phase 4 — 마무리 다듬기
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="store.error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
|
||||
⚠️ {{ store.error }}
|
||||
<button @click="store.error = null" class="ml-3 underline hover:no-underline">닫기</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<div v-if="store.loading && !store.error" class="flex items-center gap-2 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 px-4 py-3 rounded-lg text-sm">
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{{ phaseLabel(store.currentPhaseLoading || 0) }} 진행 중...
|
||||
</div>
|
||||
|
||||
<!-- Phase 2: Proper Noun Editor -->
|
||||
<section v-if="store.phase1Done">
|
||||
<ProperNounsEditor
|
||||
:proper_nouns="store.phase2ProperNouns"
|
||||
:style="store.phase2Style"
|
||||
@update:proper_nouns="(v) => store.phase2ProperNouns = v"
|
||||
@update:style="(v) => store.phase2Style = v"
|
||||
/>
|
||||
|
||||
<!-- Phase 1 Summary (read-only display) -->
|
||||
<div v-if="store.phase1Result?.summary" class="mt-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">📋 내용 요약</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">{{ store.phase1Result.summary }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Phase 1 Result Preview -->
|
||||
<div v-if="store.phase1Result?.translated" class="mt-4 bg-white dark:bg-gray-800 rounded-xl border border-blue-200 dark:border-blue-700 p-5">
|
||||
<h4 class="text-sm font-semibold text-blue-700 dark:text-blue-300 mb-2">초벌 번역 결과 미리보기</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">{{ store.phase1Result.translated }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Phase Comparison View -->
|
||||
<ComparisonView />
|
||||
</main>
|
||||
|
||||
<!-- History Sidebar (overlay) -->
|
||||
<Transition name="slide">
|
||||
<div v-if="showHistorySidebar" class="fixed inset-y-0 right-0 w-80 bg-white dark:bg-gray-800 shadow-2xl z-50 p-6 overflow-y-auto border-l border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center justify-between">
|
||||
번역 히스토리
|
||||
<button @click="showHistorySidebar = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">✕</button>
|
||||
</h3>
|
||||
|
||||
<!-- History List -->
|
||||
<div v-if="store.history.length === 0" class="text-sm text-gray-400 italic mt-4">
|
||||
아직 번역 기록이 없습니다.
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-2">
|
||||
<li v-for="(entry, idx) in store.history" :key="idx">
|
||||
<button
|
||||
@click="store.restoreFromHistory(entry); showHistorySidebar = false"
|
||||
class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-100 dark:border-gray-600 transition-colors"
|
||||
>
|
||||
<div class="text-xs text-gray-400 mb-1">
|
||||
{{ new Date(entry.timestamp).toLocaleString('ko-KR') }}
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-700 dark:text-gray-200 line-clamp-2">
|
||||
{{ entry.source_preview || '(빈 원문)' }}
|
||||
</div>
|
||||
<div class="text-xs text-blue-500 mt-1">→ {{ entry.target_language }}</div>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Overlay background for sidebar -->
|
||||
<div v-if="showHistorySidebar" @click="showHistorySidebar = false" class="fixed inset-0 bg-black/20 z-40"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue