Initial working translator implementation
This commit is contained in:
commit
9439858b11
35 changed files with 5365 additions and 0 deletions
7
frontend/env.d.ts
vendored
Normal file
7
frontend/env.d.ts
vendored
Normal 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
13
frontend/index.html
Normal 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
2980
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
20
frontend/src/App.vue
Normal file
20
frontend/src/App.vue
Normal 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
113
frontend/src/api.ts
Normal 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
|
||||
}
|
||||
80
frontend/src/components/ComparisonView.vue
Normal file
80
frontend/src/components/ComparisonView.vue
Normal 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>
|
||||
30
frontend/src/components/LLMSelector.vue
Normal file
30
frontend/src/components/LLMSelector.vue
Normal 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>
|
||||
80
frontend/src/components/ProperNounsEditor.vue
Normal file
80
frontend/src/components/ProperNounsEditor.vue
Normal 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
14
frontend/src/main.ts
Normal 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')
|
||||
30
frontend/src/stores/auth.ts
Normal file
30
frontend/src/stores/auth.ts
Normal 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 }
|
||||
})
|
||||
33
frontend/src/stores/theme.ts
Normal file
33
frontend/src/stores/theme.ts
Normal 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 }
|
||||
})
|
||||
335
frontend/src/stores/translation.ts
Normal file
335
frontend/src/stores/translation.ts
Normal 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
20
frontend/src/style.css
Normal 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
49
frontend/src/types.ts
Normal 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
|
||||
}
|
||||
74
frontend/src/views/LoginView.vue
Normal file
74
frontend/src/views/LoginView.vue
Normal 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>
|
||||
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>
|
||||
9
frontend/tailwind.config.js
Normal file
9
frontend/tailwind.config.js
Normal 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
24
frontend/tsconfig.json
Normal 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
15
frontend/vite.config.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue