Initial working translator implementation

This commit is contained in:
burnintuna 2026-07-30 14:53:36 +09:00
commit 9439858b11
35 changed files with 5365 additions and 0 deletions

7
frontend/env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LLM 번역기</title>
</head>
<body class="bg-gray-50 min-h-screen">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2980
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
frontend/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "llm-translator-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^2.3.0",
"vue": "^3.5.0",
"axios": "^1.7.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "~5.7.0",
"vite": "^6.1.0",
"vue-tsc": "^2.2.0"
},
"allowScripts": {
"esbuild@0.25.12": true,
"vue-demi@0.14.10": true
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

20
frontend/src/App.vue Normal file
View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAuthStore } from './stores/auth'
import LoginView from './views/LoginView.vue'
import TranslatorView from './views/TranslatorView.vue'
const auth = useAuthStore()
onMounted(() => {
auth.check()
})
</script>
<template>
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 dark:from-gray-900 dark:to-slate-800 transition-colors">
<!-- Show Login if not authenticated, otherwise Translator -->
<LoginView v-if="!auth.isLoggedIn" />
<TranslatorView v-else />
</div>
</template>

113
frontend/src/api.ts Normal file
View file

@ -0,0 +1,113 @@
/** Axios-based API client with JWT token management */
import axios from 'axios'
import type { LLMModel, SessionResponse } from './types'
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
})
// Attach JWT on every request
api.interceptors.request.use((config) => {
const token = localStorage.getItem('jwt_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Auto-redirect on 401
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('jwt_token')
window.location.hash = '#/login'
}
return Promise.reject(err)
},
)
// ── Auth ─────────────────────────────────────────────
export async function login(id: string, password: string): Promise<string> {
const res = await api.post('/auth/login', { id, password })
const token = res.data.access_token as string
localStorage.setItem('jwt_token', token)
return token
}
export async function logout(): Promise<void> {
localStorage.removeItem('jwt_token')
}
export async function me(): Promise<{ user_id: string }> {
const res = await api.get('/auth/me')
return res.data
}
// ── Models ───────────────────────────────────────────
export async function listModels(): Promise<LLMModel[]> {
const res = await api.get('/models')
return res.data
}
// ── Sessions ─────────────────────────────────────────
export async function createSession(params: {
source_text: string
source_language: string
target_language: string
model_phase1: string
model_phase2: string
model_phase3: string
model_phase4: string
}): Promise<{ session_id: string }> {
const res = await api.post('/translate/create', params)
return res.data
}
export async function getSession(sessionId: string): Promise<SessionResponse> {
const res = await api.get(`/sessions/${sessionId}`)
return res.data
}
export async function deleteSession(sessionId: string): Promise<void> {
await api.delete(`/sessions/${sessionId}`)
}
export async function updateSession(
sessionId: string,
patch: Record<string, any>,
): Promise<SessionResponse> {
const res = await api.patch(`/sessions/${sessionId}`, patch)
return res.data
}
// ── Phase Execution ──────────────────────────────────
export async function runPhase1(sessionId: string): Promise<SessionResponse> {
const res = await api.post(`/translate/${sessionId}/phase1`)
return res.data
}
export async function runPhase2(
sessionId: string,
proper_nouns: any[],
style: string,
): Promise<SessionResponse> {
const res = await api.post(`/translate/${sessionId}/phase2`, { proper_nouns, style })
return res.data
}
export async function runPhase3(sessionId: string): Promise<SessionResponse> {
const res = await api.post(`/translate/${sessionId}/phase3`)
return res.data
}
export async function runPhase4(sessionId: string): Promise<SessionResponse> {
const res = await api.post(`/translate/${sessionId}/phase4`)
return res.data
}

View file

@ -0,0 +1,80 @@
<script setup lang="ts">
import { useTranslationStore } from '../stores/translation'
import { computed } from 'vue'
const store = useTranslationStore()
const p1Text = computed(() => store.phase1Result?.translated || '')
const p3Text = computed(() => store.phase3Result || '')
const p4Text = computed(() => store.phase4Result || '')
function copy(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
function download(filename: string, content: string) {
const blob = new Blob([content], { type: 'text/plain; charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
</script>
<template>
<div class="space-y-4">
<!-- Export Bar -->
<div v-if="p4Text" class="flex gap-2 justify-end">
<button @click="copy(p4Text)" 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">
📋 최종 결과 복사
</button>
<button @click="download('translation_final.txt', p4Text)" 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">
📄 TXT 다운로드
</button>
<button @click="download('translation_final.md', p4Text)" class="px-3 py-1.5 text-sm bg-blue-100 dark:bg-blue-900/40 hover:bg-blue-200 dark:hover:bg-blue-900/60 rounded-lg transition-colors flex items-center gap-1.5">
📝 MD 다운로드
</button>
</div>
<!-- 3-Column Comparison -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Phase 1 Column -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-3 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/30 dark:to-indigo-900/30 border-b border-blue-100 dark:border-blue-800 flex justify-between items-center">
<h3 class="text-sm font-semibold text-blue-700 dark:text-blue-300">Phase 1 초벌 번역</h3>
<button v-if="p1Text" @click="copy(p1Text)" class="text-xs px-2 py-1 bg-white dark:bg-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors" title="복사">📋</button>
</div>
<div class="p-4 min-h-[200px] max-h-[600px] overflow-y-auto prose-sm text-gray-800 dark:text-gray-200">
<p v-if="!p1Text" class="text-gray-400 italic text-sm">Phase 1을 실행하면 결과가 표시됩니다</p>
<p v-else class="whitespace-pre-wrap leading-relaxed">{{ p1Text }}</p>
</div>
</div>
<!-- Phase 3 Column -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-3 bg-gradient-to-r from-emerald-50 to-teal-50 dark:from-emerald-900/30 dark:to-teal-900/30 border-b border-emerald-100 dark:border-emerald-800 flex justify-between items-center">
<h3 class="text-sm font-semibold text-emerald-700 dark:text-emerald-300">Phase 3 재번역</h3>
<button v-if="p3Text" @click="copy(p3Text)" class="text-xs px-2 py-1 bg-white dark:bg-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors" title="복사">📋</button>
</div>
<div class="p-4 min-h-[200px] max-h-[600px] overflow-y-auto prose-sm text-gray-800 dark:text-gray-200">
<p v-if="!p3Text" class="text-gray-400 italic text-sm">Phase 3을 실행하면 결과가 표시됩니다</p>
<p v-else class="whitespace-pre-wrap leading-relaxed">{{ p3Text }}</p>
</div>
</div>
<!-- Phase 4 Column -->
<div class="bg-white dark:bg-gray-800 rounded-xl border-2 border-purple-200 dark:border-purple-700 overflow-hidden shadow-lg">
<div class="px-4 py-3 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/30 dark:to-pink-900/30 border-b border-purple-100 dark:border-purple-800 flex justify-between items-center">
<h3 class="text-sm font-semibold text-purple-700 dark:text-purple-300">Phase 4 최종 결과 </h3>
<button v-if="p4Text" @click="copy(p4Text)" class="text-xs px-2 py-1 bg-white dark:bg-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors" title="복사">📋</button>
</div>
<div class="p-4 min-h-[200px] max-h-[600px] overflow-y-auto prose-sm text-gray-800 dark:text-gray-200">
<p v-if="!p4Text" class="text-gray-400 italic text-sm">Phase 4를 실행하면 최종 번역문이 표시됩니다</p>
<p v-else class="whitespace-pre-wrap leading-relaxed">{{ p4Text }}</p>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
import type { LLMModel } from '../types'
const props = defineProps<{
label: string
value: string
models: LLMModel[]
}>()
const emit = defineEmits<{ (e: 'update:value', v: string): void }>()
function update(v: string) {
emit('update:value', v)
}
</script>
<template>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ label }}</label>
<select
:value="value"
@change="update((($event.target as HTMLSelectElement).value))"
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 value="">-- 선택 --</option>
<option v-for="m in models" :key="m.alias" :value="m.alias">
{{ m.alias }} ({{ m.context_size.toLocaleString() }})
</option>
</select>
</div>
</template>

View file

@ -0,0 +1,80 @@
<script setup lang="ts">
import type { ProperNoun } from '../types'
const props = defineProps<{
proper_nouns: ProperNoun[]
style: string
}>()
const emit = defineEmits<{
(e: 'update:proper_nouns', v: ProperNoun[]): void
(e: 'update:style', v: string): void
}>()
import { ref, watch } from 'vue'
const nouns = ref<ProperNoun[]>([...props.proper_nouns])
const styleText = ref(props.style)
watch(() => props.proper_nouns, (v) => { nouns.value = [...v] }, { deep: true })
watch(() => props.style, (v) => { styleText.value = v })
function emitAll() {
emit('update:proper_nouns', nouns.value)
emit('update:style', styleText.value)
}
</script>
<template>
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<!-- Summary & Style -->
<div>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">📝 요약 문체</h4>
<p v-if="props.proper_nouns.length === 0 && !styleText" class="text-sm text-gray-400 italic">
Phase 1 결과를 확인하면 요약과 문체가 표시됩니다.
</p>
<div v-else class="space-y-3">
<!-- Summary is read-only, shown from phase1Result but we pass it as a prop somewhere else -->
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">문체 (수정 가능)</label>
<input
v-model="styleText"
@change="emitAll()"
type="text"
class="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
<!-- Proper Nouns Table -->
<div v-if="nouns.length > 0">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">🔤 고유명사 번역 (수정 가능)</h4>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 dark:border-gray-600">
<th class="text-left py-2 px-3 text-xs font-medium text-gray-500 dark:text-gray-400">원문</th>
<th class="text-left py-2 px-3 text-xs font-medium text-gray-500 dark:text-gray-400">제안 번역</th>
<th class="text-left py-2 px-3 text-xs font-medium text-gray-500 dark:text-gray-400">최종 번역 (수정)</th>
</tr>
</thead>
<tbody>
<tr v-for="(pn, idx) in nouns" :key="idx" class="border-b border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="py-2 px-3 font-medium text-gray-800 dark:text-gray-200">{{ pn.original }}</td>
<td class="py-2 px-3 text-gray-600 dark:text-gray-400">{{ pn.suggested }}</td>
<td class="py-1 px-2">
<input
v-model="nouns[idx].final"
@change="emitAll()"
type="text"
class="w-full px-2 py-1 border border-gray-200 dark:border-gray-600 rounded text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="text-sm text-gray-400 italic">고유명사가 없습니다</div>
</div>
</template>

14
frontend/src/main.ts Normal file
View file

@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './style.css'
// Apply dark class before Vue mounts to avoid flash
const saved = localStorage.getItem('llm_translator_theme')
if (saved === 'dark' || (saved === null && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
}
const app = createApp(App)
app.use(createPinia())
app.mount('#app')

View file

@ -0,0 +1,30 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { me as apiMe, logout as apiLogout } from '../api'
export const useAuthStore = defineStore('auth', () => {
const user_id = ref<string | null>(null)
const loaded = ref(false)
const isLoggedIn = computed(() => !!user_id.value && loaded.value)
async function check() {
if (loaded.value) return
try {
const data = await apiMe()
user_id.value = data.user_id
} catch {
user_id.value = null
} finally {
loaded.value = true
}
}
async function logout() {
await apiLogout()
user_id.value = null
window.location.hash = '#/login'
}
return { user_id, loaded, isLoggedIn, check, logout }
})

View file

@ -0,0 +1,33 @@
import { ref, watch, onMounted } from 'vue'
import { defineStore } from 'pinia'
export const useThemeStore = defineStore('theme', () => {
const isDark = ref(false)
function init() {
const saved = localStorage.getItem('llm_translator_theme')
if (saved === 'dark') {
isDark.value = true
} else if (saved === null && window.matchMedia('(prefers-color-scheme: dark)').matches) {
isDark.value = true
}
}
function toggle() {
isDark.value = !isDark.value
}
watch(isDark, (dark) => {
if (dark) {
document.documentElement.classList.add('dark')
localStorage.setItem('llm_translator_theme', 'dark')
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('llm_translator_theme', 'light')
}
})
onMounted(() => init())
return { isDark, toggle }
})

View file

@ -0,0 +1,335 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ProperNoun, TranslationSessionData, HistoryEntry, LLMModel } from '../types'
import * as api from '../api'
const HISTORY_KEY = 'llm_translator_history'
const SESSION_KEY_PREFIX = 'llm_translator_session_'
export const useTranslationStore = defineStore('translation', () => {
// ── State ────────────────────────────────────────
const sessionId = ref<string | null>(null)
const sourceText = ref('')
const sourceLanguage = ref('auto')
const targetLanguage = ref('한국어')
const modelPhase1 = ref('')
const modelPhase2 = ref('')
const modelPhase3 = ref('')
const modelPhase4 = ref('')
// Phase results
const phase1Result = ref<{ translated: string; proper_nouns: ProperNoun[]; summary: string; style: string } | null>(null)
const phase2ProperNouns = ref<ProperNoun[]>([])
const phase2Style = ref('')
const phase3Result = ref('')
const phase4Result = ref('')
// UI state
const loading = ref(false)
const currentPhaseLoading = ref<number | null>(null)
const error = ref<string | null>(null)
const models = ref<LLMModel[]>([])
const history = ref<HistoryEntry[]>([])
// ── Computed ─────────────────────────────────────
const phase1Done = computed(() => !!phase1Result.value)
const phase2Ready = computed(() => phase1Done.value) // can edit after P1
const phase3Ready = computed(() => phase1Done.value && (phase2ProperNouns.value.length > 0 || phase2Style.value))
const phase4Ready = computed(() => !!phase3Result.value)
const activeAbortController = ref<AbortController | null>(null)
// ── Actions ──────────────────────────────────────
async function loadModels() {
try {
models.value = await api.listModels()
} catch (e: any) {
console.error('Failed to load models:', e)
}
}
function newSession() {
sessionId.value = null
sourceText.value = ''
sourceLanguage.value = 'auto'
targetLanguage.value = '한국어'
phase1Result.value = null
phase2ProperNouns.value = []
phase2Style.value = ''
phase3Result.value = ''
phase4Result.value = ''
error.value = null
// Restore model selections from localStorage
const savedModels = JSON.parse(localStorage.getItem('llm_translator_models') || '{}')
modelPhase1.value = savedModels.p1 || models.value[0]?.alias || ''
modelPhase2.value = savedModels.p2 || models.value[0]?.alias || ''
modelPhase3.value = savedModels.p3 || models.value[0]?.alias || ''
modelPhase4.value = savedModels.p4 || (models.value[1]?.alias || models.value[0]?.alias || '')
}
function saveModelSelections() {
localStorage.setItem('llm_translator_models', JSON.stringify({
p1: modelPhase1.value,
p2: modelPhase2.value,
p3: modelPhase3.value,
p4: modelPhase4.value,
}))
}
async function ensureSession() {
if (!sessionId.value) {
const res = await api.createSession(getSessionParams())
sessionId.value = res.session_id
}
}
function getSessionParams() {
return {
source_text: sourceText.value,
source_language: sourceLanguage.value,
target_language: targetLanguage.value,
model_phase1: modelPhase1.value,
model_phase2: modelPhase2.value,
model_phase3: modelPhase3.value,
model_phase4: modelPhase4.value,
}
}
async function syncToServer() {
if (!sessionId.value) return
try {
await api.updateSession(sessionId.value, getSessionParams())
} catch (e: any) {
console.error('Sync failed:', e)
}
}
// ── Phase Execution ──────────────────────────────
async function executePhase1() {
if (!sourceText.value.trim()) {
error.value = '원문을 입력해주세요'
return
}
if (!modelPhase1.value) {
error.value = 'Phase 1 LLM 모델을 선택하세요'
return
}
loading.value = true
currentPhaseLoading.value = 1
error.value = null
activeAbortController.value = new AbortController()
try {
await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
const res = await api.runPhase1(sessionId.value)
phase1Result.value = res.data.phase1_result
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
phase2Style.value = res.data.phase2_style || ''
} catch (e: any) {
error.value = e.response?.data?.detail || e.message || 'Phase 1 실행 실패'
} finally {
loading.value = false
currentPhaseLoading.value = null
activeAbortController.value = null
}
}
async function executePhase2() {
// Phase 2 is a user confirmation step — we just save the edited proper nouns and style
if (!sessionId.value) return
loading.value = true
currentPhaseLoading.value = 2
error.value = null
try {
const res = await api.runPhase2(
sessionId.value,
phase2ProperNouns.value.map(pn => ({ ...pn, final: pn.final || pn.suggested })),
phase2Style.value,
)
// Update local state from server response
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
phase2Style.value = res.data.phase2_style || ''
} catch (e: any) {
error.value = e.response?.data?.detail || e.message || 'Phase 2 확인 실패'
} finally {
loading.value = false
currentPhaseLoading.value = null
}
}
async function executePhase3() {
if (!phase1Result.value) {
error.value = '먼저 Phase 1을 실행하세요'
return
}
if (!modelPhase3.value && !modelPhase1.value) {
error.value = 'Phase 3 LLM 모델을 선택하세요'
return
}
loading.value = true
currentPhaseLoading.value = 3
error.value = null
activeAbortController.value = new AbortController()
try {
await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
const res = await api.runPhase3(sessionId.value)
phase3Result.value = res.data.phase3_result || ''
} catch (e: any) {
error.value = e.response?.data?.detail || e.message || 'Phase 3 실행 실패'
} finally {
loading.value = false
currentPhaseLoading.value = null
activeAbortController.value = null
}
}
async function executePhase4() {
if (!phase3Result.value) {
error.value = '먼저 Phase 3을 실행하세요'
return
}
loading.value = true
currentPhaseLoading.value = 4
error.value = null
activeAbortController.value = new AbortController()
try {
await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
const res = await api.runPhase4(sessionId.value)
phase4Result.value = res.data.phase4_result || ''
} catch (e: any) {
error.value = e.response?.data?.detail || e.message || 'Phase 4 실행 실패'
} finally {
loading.value = false
currentPhaseLoading.value = null
activeAbortController.value = null
}
}
// ── History (localStorage) ───────────────────────
function saveToHistory() {
const entry: HistoryEntry = {
id: sessionId.value || Date.now().toString(),
source_preview: sourceText.value.slice(0, 100),
target_language: targetLanguage.value,
timestamp: Date.now(),
data: getSessionData(),
}
// Avoid duplicates
const idx = history.value.findIndex(h => h.id === entry.id)
if (idx >= 0) {
history.value[idx] = entry
} else {
history.value.unshift(entry)
}
// Keep max 50 entries
if (history.value.length > 50) history.value.pop()
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.value))
}
function loadHistory() {
try {
const raw = localStorage.getItem(HISTORY_KEY) || '[]'
history.value = JSON.parse(raw) as HistoryEntry[]
} catch {
history.value = []
}
}
async function restoreFromHistory(entry: HistoryEntry) {
sessionId.value = entry.id
sourceText.value = entry.data.source_text
sourceLanguage.value = entry.data.source_language
targetLanguage.value = entry.data.target_language
modelPhase1.value = entry.data.model_phase1
modelPhase2.value = entry.data.model_phase2
modelPhase3.value = entry.data.model_phase3
modelPhase4.value = entry.data.model_phase4
phase1Result.value = entry.data.phase1_result
phase2ProperNouns.value = entry.data.phase2_proper_nouns || []
phase2Style.value = entry.data.phase2_style || ''
phase3Result.value = entry.data.phase3_result || ''
phase4Result.value = entry.data.phase4_result || ''
// Save model selections for future sessions
saveModelSelections()
}
function getSessionData(): TranslationSessionData {
return {
source_text: sourceText.value,
source_language: sourceLanguage.value,
target_language: targetLanguage.value,
model_phase1: modelPhase1.value,
model_phase2: modelPhase2.value,
model_phase3: modelPhase3.value,
model_phase4: modelPhase4.value,
phase1_result: phase1Result.value || null,
phase2_proper_nouns: phase2ProperNouns.value,
phase2_style: phase2Style.value,
phase3_result: phase3Result.value,
phase4_result: phase4Result.value,
}
}
// ── Export ────────────────────────────────────────
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text).catch(() => {
error.value = '클립보드 복사에 실패했습니다'
})
}
function downloadTxt(filename: string, content: string): void {
const blob = new Blob([content], { type: 'text/plain; charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
function downloadMd(filename: string, content: string): void {
// Add simple markdown formatting — frontmatter with metadata
const md = `---
source_language: ${sourceLanguage.value}
target_language: ${target_language.value}
timestamp: ${new Date().toISOString()}
---\n\n${content}`
downloadTxt(filename.replace('.txt', '.md'), md)
}
return {
// State
sessionId, sourceText, sourceLanguage, targetLanguage,
modelPhase1, modelPhase2, modelPhase3, modelPhase4,
phase1Result, phase2ProperNouns, phase2Style,
phase3Result, phase4Result,
loading, currentPhaseLoading, error, models, history,
// Computed
phase1Done, phase2Ready, phase3Ready, phase4Ready,
// Actions
loadModels, newSession, saveModelSelections, ensureSession, syncToServer,
executePhase1, executePhase2, executePhase3, executePhase4,
saveToHistory, loadHistory, restoreFromHistory,
copyToClipboard, downloadTxt, downloadMd, getSessionData,
}
})

20
frontend/src/style.css Normal file
View file

@ -0,0 +1,20 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Dark mode body bg transition */
html {
transition: background-color 0.2s, color 0.2s;
}
/* Custom scrollbar (light) */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #f1f5f9; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
.dark ::-webkit-scrollbar-track { background: #1e293b; }
.dark ::-webkit-scrollbar-thumb { background: #475569; }
/* Selection color */
::selection { background-color: #93c5fd; }
.dark ::selection { background-color: #60a5fa; }

49
frontend/src/types.ts Normal file
View file

@ -0,0 +1,49 @@
/** TypeScript types mirroring the backend models */
export interface LLMModel {
alias: string
model: string
context_size: number
}
export interface ProperNoun {
original: string
suggested: string
final?: string
}
export interface Phase1Result {
translated: string
proper_nouns: ProperNoun[]
summary: string
style: string
}
export interface TranslationSessionData {
source_text: string
source_language: string
target_language: string
model_phase1: string
model_phase2: string
model_phase3: string
model_phase4: string
phase1_result: Phase1Result | null
phase2_proper_nouns: ProperNoun[]
phase2_style: string
phase3_result: string
phase4_result: string
}
export interface SessionResponse {
session_id: string
data: TranslationSessionData
}
/** A saved translation history entry (localStorage) */
export interface HistoryEntry {
id: string
source_preview: string
target_language: string
timestamp: number
data: TranslationSessionData
}

View file

@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue'
import * as api from '../api'
const id = ref('')
const password = ref('')
const errorMessage = ref('')
const loading = ref(false)
async function handleLogin() {
if (!id.value || !password.value) return
loading.value = true
errorMessage.value = ''
try {
await api.login(id.value, password.value)
window.location.reload()
} catch (e: any) {
errorMessage.value = e.response?.data?.detail || '로그인에 실패했습니다'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="w-full max-w-md bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 border border-gray-200 dark:border-gray-700">
<!-- Logo / Title -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/40 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-blue-600 dark:text-blue-400" 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-2xl font-bold text-gray-800 dark:text-gray-100">LLM 번역기</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">4단계 고품질 번역 파이프라인</p>
</div>
<!-- Login Form -->
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label for="id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">ID</label>
<input
id="id" v-model="id" type="text" autocomplete="username"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg 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"
placeholder="사용자 ID"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">비밀번호</label>
<input
id="password" v-model="password" type="password" autocomplete="current-password"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg 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"
placeholder="비밀번호"
/>
</div>
<div v-if="errorMessage" class="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
{{ errorMessage }}
</div>
<button
type="submit"
:disabled="loading || !id || !password"
class="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span v-if="loading">로그인 중...</span>
<span v-else>로그인</span>
</button>
</form>
</div>
</div>
</template>

View 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>

View file

@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}

24
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"]
}

15
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
},
},
build: {
outDir: 'dist',
},
})