Improve translation reliability and progress feedback

This commit is contained in:
burnintuna 2026-07-30 17:07:02 +09:00
parent 9439858b11
commit 51a6e845d1
20 changed files with 1194 additions and 216 deletions

View file

@ -1,7 +1,9 @@
/** Axios-based API client with JWT token management */
import axios from 'axios'
import type { LLMModel, SessionResponse } from './types'
import type { LLMModel, PhaseProgress, ProperNoun, SessionConfig, SessionResponse } from './types'
export const AUTH_UNAUTHORIZED_EVENT = 'llm-translator:unauthorized'
const api = axios.create({
baseURL: '/api',
@ -17,13 +19,13 @@ api.interceptors.request.use((config) => {
return config
})
// Auto-redirect on 401
// Notify the auth store so App.vue can switch views without a router.
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('jwt_token')
window.location.hash = '#/login'
window.dispatchEvent(new Event(AUTH_UNAUTHORIZED_EVENT))
}
return Promise.reject(err)
},
@ -56,15 +58,7 @@ export async function listModels(): Promise<LLMModel[]> {
// ── 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 }> {
export async function createSession(params: SessionConfig): Promise<{ session_id: string }> {
const res = await api.post('/translate/create', params)
return res.data
}
@ -74,13 +68,20 @@ export async function getSession(sessionId: string): Promise<SessionResponse> {
return res.data
}
export async function getSessionProgress(
sessionId: string,
): Promise<{ progress: PhaseProgress | null }> {
const res = await api.get(`/sessions/${sessionId}/progress`)
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>,
patch: Partial<SessionConfig>,
): Promise<SessionResponse> {
const res = await api.patch(`/sessions/${sessionId}`, patch)
return res.data
@ -95,7 +96,7 @@ export async function runPhase1(sessionId: string): Promise<SessionResponse> {
export async function runPhase2(
sessionId: string,
proper_nouns: any[],
proper_nouns: ProperNoun[],
style: string,
): Promise<SessionResponse> {
const res = await api.post(`/translate/${sessionId}/phase2`, { proper_nouns, style })

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { me as apiMe, logout as apiLogout } from '../api'
import { computed, onScopeDispose, ref } from 'vue'
import { AUTH_UNAUTHORIZED_EVENT, me as apiMe, logout as apiLogout } from '../api'
export const useAuthStore = defineStore('auth', () => {
const user_id = ref<string | null>(null)
@ -8,6 +8,14 @@ export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = computed(() => !!user_id.value && loaded.value)
function handleUnauthorized() {
user_id.value = null
loaded.value = true
}
window.addEventListener(AUTH_UNAUTHORIZED_EVENT, handleUnauthorized)
onScopeDispose(() => window.removeEventListener(AUTH_UNAUTHORIZED_EVENT, handleUnauthorized))
async function check() {
if (loaded.value) return
try {
@ -23,7 +31,6 @@ export const useAuthStore = defineStore('auth', () => {
async function logout() {
await apiLogout()
user_id.value = null
window.location.hash = '#/login'
}
return { user_id, loaded, isLoggedIn, check, logout }

View file

@ -1,24 +1,32 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ProperNoun, TranslationSessionData, HistoryEntry, LLMModel } from '../types'
import { computed, ref, watch } from 'vue'
import { isAxiosError } from 'axios'
import type { HistoryEntry, LLMModel, Phase1Result, PhaseProgress, ProperNoun, SessionConfig, TranslationSessionData } from '../types'
import * as api from '../api'
import { useAuthStore } from './auth'
const HISTORY_KEY = 'llm_translator_history'
const SESSION_KEY_PREFIX = 'llm_translator_session_'
const MODELS_KEY = 'llm_translator_models'
interface SavedModels {
p1?: string
p3?: string
p4?: string
}
export const useTranslationStore = defineStore('translation', () => {
const auth = useAuthStore()
// ── 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 phase1Result = ref<Phase1Result | null>(null)
const phase2ProperNouns = ref<ProperNoun[]>([])
const phase2Style = ref('')
const phase3Result = ref('')
@ -30,22 +38,51 @@ export const useTranslationStore = defineStore('translation', () => {
const error = ref<string | null>(null)
const models = ref<LLMModel[]>([])
const history = ref<HistoryEntry[]>([])
const historySnapshot = ref(false)
const progress = ref<PhaseProgress | null>(null)
let progressTimer: ReturnType<typeof setInterval> | null = null
let progressRequestPending = false
// ── 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)
const phase2Ready = computed(() => phase1Done.value && !historySnapshot.value)
const phase3Ready = computed(() => phase1Done.value && !historySnapshot.value)
const phase4Ready = computed(() => !!phase3Result.value && !historySnapshot.value)
// ── Actions ──────────────────────────────────────
function getErrorMessage(error: unknown, fallback: string): string {
if (isAxiosError<{ detail?: string }>(error)) {
return error.response?.data?.detail || error.message || fallback
}
return error instanceof Error ? error.message : fallback
}
function loadSavedModels(): SavedModels {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(MODELS_KEY) || '{}')
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
const value = parsed as Record<string, unknown>
return {
p1: typeof value.p1 === 'string' ? value.p1 : undefined,
p3: typeof value.p3 === 'string' ? value.p3 : undefined,
p4: typeof value.p4 === 'string' ? value.p4 : undefined,
}
} catch {
return {}
}
}
function validModel(alias: string | undefined, fallback: string): string {
if (models.value.length === 0) return alias || fallback
return alias && models.value.some(model => model.alias === alias) ? alias : fallback
}
async function loadModels() {
try {
models.value = await api.listModels()
} catch (e: any) {
console.error('Failed to load models:', e)
} catch (error: unknown) {
console.error('Failed to load models:', error)
}
}
@ -60,24 +97,47 @@ export const useTranslationStore = defineStore('translation', () => {
phase3Result.value = ''
phase4Result.value = ''
error.value = null
historySnapshot.value = false
// 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 || '')
const savedModels = loadSavedModels()
const defaultModel = models.value[0]?.alias || ''
modelPhase1.value = validModel(savedModels.p1, defaultModel)
modelPhase3.value = validModel(savedModels.p3, defaultModel)
modelPhase4.value = validModel(savedModels.p4, models.value[1]?.alias || defaultModel)
}
function saveModelSelections() {
localStorage.setItem('llm_translator_models', JSON.stringify({
p1: modelPhase1.value,
p2: modelPhase2.value,
p3: modelPhase3.value,
p4: modelPhase4.value,
}))
try {
localStorage.setItem(MODELS_KEY, JSON.stringify({
p1: modelPhase1.value,
p3: modelPhase3.value,
p4: modelPhase4.value,
}))
} catch (error) {
console.error('Failed to save model selections:', error)
}
}
watch([modelPhase1, modelPhase3, modelPhase4], saveModelSelections)
watch([sourceText, sourceLanguage, targetLanguage, modelPhase1], () => {
phase1Result.value = null
phase2ProperNouns.value = []
phase2Style.value = ''
phase3Result.value = ''
phase4Result.value = ''
}, { flush: 'sync' })
watch(modelPhase3, () => {
phase3Result.value = ''
phase4Result.value = ''
}, { flush: 'sync' })
watch(modelPhase4, () => {
phase4Result.value = ''
}, { flush: 'sync' })
watch([phase2ProperNouns, phase2Style], () => {
phase3Result.value = ''
phase4Result.value = ''
}, { deep: true, flush: 'sync' })
async function ensureSession() {
if (!sessionId.value) {
const res = await api.createSession(getSessionParams())
@ -85,13 +145,13 @@ export const useTranslationStore = defineStore('translation', () => {
}
}
function getSessionParams() {
function getSessionParams(): SessionConfig {
return {
source_text: sourceText.value,
source_language: sourceLanguage.value,
target_language: targetLanguage.value,
model_phase1: modelPhase1.value,
model_phase2: modelPhase2.value,
model_phase2: '',
model_phase3: modelPhase3.value,
model_phase4: modelPhase4.value,
}
@ -99,10 +159,44 @@ export const useTranslationStore = defineStore('translation', () => {
async function syncToServer() {
if (!sessionId.value) return
await api.updateSession(sessionId.value, getSessionParams())
}
async function submitPhase2() {
if (!sessionId.value) throw new Error('세션 생성 실패')
const res = await api.runPhase2(
sessionId.value,
phase2ProperNouns.value.map(pn => ({ ...pn, final: pn.final || pn.suggested })),
phase2Style.value,
)
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
phase2Style.value = res.data.phase2_style || ''
}
async function pollProgress() {
if (!sessionId.value || progressRequestPending) return
progressRequestPending = true
try {
await api.updateSession(sessionId.value, getSessionParams())
} catch (e: any) {
console.error('Sync failed:', e)
const response = await api.getSessionProgress(sessionId.value)
progress.value = response.progress
} catch {
// The phase request handles user-visible errors; polling is best-effort only.
} finally {
progressRequestPending = false
}
}
function startProgressPolling() {
stopProgressPolling()
progress.value = null
void pollProgress()
progressTimer = setInterval(() => void pollProgress(), 750)
}
function stopProgressPolling() {
if (progressTimer !== null) {
clearInterval(progressTimer)
progressTimer = null
}
}
@ -121,21 +215,25 @@ export const useTranslationStore = defineStore('translation', () => {
loading.value = true
currentPhaseLoading.value = 1
error.value = null
activeAbortController.value = new AbortController()
phase3Result.value = ''
phase4Result.value = ''
try {
await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
await syncToServer()
startProgressPolling()
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 실행 실패'
historySnapshot.value = false
saveToHistory()
} catch (caughtError: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 1 실행 실패')
} finally {
stopProgressPolling()
loading.value = false
currentPhaseLoading.value = null
activeAbortController.value = null
}
}
@ -146,18 +244,14 @@ export const useTranslationStore = defineStore('translation', () => {
loading.value = true
currentPhaseLoading.value = 2
error.value = null
phase3Result.value = ''
phase4Result.value = ''
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 확인 실패'
await submitPhase2()
saveToHistory()
} catch (caughtError: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 2 확인 실패')
} finally {
loading.value = false
currentPhaseLoading.value = null
@ -177,19 +271,25 @@ export const useTranslationStore = defineStore('translation', () => {
loading.value = true
currentPhaseLoading.value = 3
error.value = null
activeAbortController.value = new AbortController()
phase3Result.value = ''
phase4Result.value = ''
try {
await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
await syncToServer()
await submitPhase2()
saveToHistory()
startProgressPolling()
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 실행 실패'
saveToHistory()
} catch (caughtError: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 3 실행 실패')
} finally {
stopProgressPolling()
loading.value = false
currentPhaseLoading.value = null
activeAbortController.value = null
}
}
@ -202,19 +302,21 @@ export const useTranslationStore = defineStore('translation', () => {
loading.value = true
currentPhaseLoading.value = 4
error.value = null
activeAbortController.value = new AbortController()
try {
await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
await syncToServer()
startProgressPolling()
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 실행 실패'
saveToHistory()
} catch (caughtError: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 4 실행 실패')
} finally {
stopProgressPolling()
loading.value = false
currentPhaseLoading.value = null
activeAbortController.value = null
}
}
@ -239,32 +341,43 @@ export const useTranslationStore = defineStore('translation', () => {
// Keep max 50 entries
if (history.value.length > 50) history.value.pop()
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.value))
try {
localStorage.setItem(historyStorageKey(), JSON.stringify(history.value))
} catch (error) {
console.error('Failed to save translation history:', error)
}
}
function historyStorageKey(): string {
return `${HISTORY_KEY}:${auth.user_id || 'anonymous'}`
}
function loadHistory() {
try {
const raw = localStorage.getItem(HISTORY_KEY) || '[]'
const key = historyStorageKey()
const raw = localStorage.getItem(key) || '[]'
history.value = JSON.parse(raw) as HistoryEntry[]
localStorage.removeItem(HISTORY_KEY)
} catch {
history.value = []
}
}
async function restoreFromHistory(entry: HistoryEntry) {
sessionId.value = entry.id
function restoreFromHistory(entry: HistoryEntry) {
sessionId.value = null
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
const defaultModel = models.value[0]?.alias || ''
modelPhase1.value = validModel(entry.data.model_phase1, defaultModel)
modelPhase3.value = validModel(entry.data.model_phase3, defaultModel)
modelPhase4.value = validModel(entry.data.model_phase4, models.value[1]?.alias || defaultModel)
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 || ''
historySnapshot.value = true
// Save model selections for future sessions
saveModelSelections()
@ -276,7 +389,7 @@ export const useTranslationStore = defineStore('translation', () => {
source_language: sourceLanguage.value,
target_language: targetLanguage.value,
model_phase1: modelPhase1.value,
model_phase2: modelPhase2.value,
model_phase2: '',
model_phase3: modelPhase3.value,
model_phase4: modelPhase4.value,
phase1_result: phase1Result.value || null,
@ -309,7 +422,7 @@ export const useTranslationStore = defineStore('translation', () => {
// Add simple markdown formatting — frontmatter with metadata
const md = `---
source_language: ${sourceLanguage.value}
target_language: ${target_language.value}
target_language: ${targetLanguage.value}
timestamp: ${new Date().toISOString()}
---\n\n${content}`
downloadTxt(filename.replace('.txt', '.md'), md)
@ -318,10 +431,10 @@ timestamp: ${new Date().toISOString()}
return {
// State
sessionId, sourceText, sourceLanguage, targetLanguage,
modelPhase1, modelPhase2, modelPhase3, modelPhase4,
modelPhase1, modelPhase3, modelPhase4,
phase1Result, phase2ProperNouns, phase2Style,
phase3Result, phase4Result,
loading, currentPhaseLoading, error, models, history,
loading, currentPhaseLoading, error, models, history, progress,
// Computed
phase1Done, phase2Ready, phase3Ready, phase4Ready,

View file

@ -17,6 +17,15 @@ export interface Phase1Result {
proper_nouns: ProperNoun[]
summary: string
style: string
warnings?: string[]
}
export interface PhaseProgress {
phase: number
chunk: number
total_chunks: number
status: string
preview: string
}
export interface TranslationSessionData {
@ -28,12 +37,26 @@ export interface TranslationSessionData {
model_phase3: string
model_phase4: string
phase1_result: Phase1Result | null
phase1_chunks?: Array<{ source_text: string; translated: string }>
phase2_proper_nouns: ProperNoun[]
phase2_style: string
phase3_result: string
phase3_chunks?: string[]
phase4_result: string
progress?: PhaseProgress | null
}
export type SessionConfig = Pick<
TranslationSessionData,
| 'source_text'
| 'source_language'
| 'target_language'
| 'model_phase1'
| 'model_phase2'
| 'model_phase3'
| 'model_phase4'
>
export interface SessionResponse {
session_id: string
data: TranslationSessionData

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { isAxiosError } from 'axios'
import * as api from '../api'
const id = ref('')
@ -14,8 +15,10 @@ async function handleLogin() {
try {
await api.login(id.value, password.value)
window.location.reload()
} catch (e: any) {
errorMessage.value = e.response?.data?.detail || '로그인에 실패했습니다'
} catch (error: unknown) {
errorMessage.value = isAxiosError<{ detail?: string }>(error)
? error.response?.data?.detail || '로그인에 실패했습니다'
: '로그인에 실패했습니다'
} finally {
loading.value = false
}

View file

@ -106,7 +106,6 @@ function handleNewTranslation() {
<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" />
@ -208,16 +207,32 @@ function handleNewTranslation() {
</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 v-if="store.loading && !store.error" class="text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/20 px-4 py-3 rounded-lg text-sm space-y-3">
<div class="flex items-center gap-2">
<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>
<span>{{ phaseLabel(store.currentPhaseLoading || 0) }} 진행 </span>
<span v-if="store.progress" class="font-medium">
· 조각 {{ store.progress.chunk }}/{{ store.progress.total_chunks }} · {{ store.progress.status }}
</span>
<span v-else class="text-blue-500 dark:text-blue-400">· 서버 응답 대기 </span>
</div>
<pre
v-if="store.progress?.preview"
class="max-h-44 overflow-y-auto whitespace-pre-wrap break-words rounded-lg border border-blue-200 dark:border-blue-800 bg-white/70 dark:bg-gray-900/60 p-3 text-xs leading-relaxed text-gray-600 dark:text-gray-300"
>{{ store.progress.preview }}</pre>
</div>
<!-- Phase 2: Proper Noun Editor -->
<section v-if="store.phase1Done">
<div
v-if="store.phase1Result?.warnings?.length"
class="mb-4 rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-4 py-3 text-sm text-amber-800 dark:text-amber-300"
>
<p v-for="warning in store.phase1Result.warnings" :key="warning">{{ warning }}</p>
</div>
<ProperNounsEditor
:proper_nouns="store.phase2ProperNouns"
:style="store.phase2Style"