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

17
.env.example Normal file
View file

@ -0,0 +1,17 @@
# JWT 시그니용 키 (변경 권장)
JWT_SECRET_KEY=my-super-secret-key-change-me
# LLM 설정 파일 경로 (기본: config/llms.json)
LLMS_CONFIG_PATH=config/llms.json
# 사용자 설정 파일 경로 (기본: config/users.json)
USERS_CONFIG_PATH=config/users.json
# JWT 만료 시간 (시간 단위, 기본 24시간)
JWT_EXPIRE_HOURS=24
# 서버 포트 (기본 8000)
PORT=8000
# 프론트엔드 빌드 폴더 경로 (기본: frontend/dist)
FRONTEND_DIST_PATH=frontend/dist

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Dependencies
node_modules/
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
# Build output
frontend/dist/
# Env files
.env
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

169
AGENTS.md Normal file
View file

@ -0,0 +1,169 @@
# LLM Translator — Agent Guide
## Project Overview
A 4-phase translation pipeline web app. Users input source text, then sequentially execute:
1. **Phase 1 (Rough Translation)** — LLM translates + extracts proper nouns, summary, style
2. **Phase 2 (Proper Noun Review)** — User reviews/edits extracted proper nouns and style
3. **Phase 3 (Re-translation)** — LLM re-translates with proper noun/style constraints applied
4. **Phase 4 (Polish)** — LLM polishes for readability while preserving proper nouns
Architecture: FastAPI backend + Vue 3 SPA (Vite build, served as static files by FastAPI).
Single-server deployment: `uvicorn backend.main:app` serves both API and frontend.
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Backend | Python 3.14+, FastAPI, Uvicorn |
| Auth | JWT (PyJWT), bcrypt (direct via `bcrypt` module) |
| LLM Client | OpenAI SDK (`openai`) — OpenAI-compatible API format |
| Frontend | Vue 3 + Composition API `<script setup>`, TypeScript, Pinia |
| Styling | Tailwind CSS v4 with `darkMode: 'class'` strategy |
| Build | Vite (frontend), no bundler for backend |
## Directory Structure
```
LLM-translator/
├── backend/ # FastAPI application
│ ├── main.py # App entry: routes, CORS, static file mounting
│ ├── auth.py # JWT auth + bcrypt password verification
│ ├── llm_client.py # OpenAI-compatible client (async)
│ ├── models.py # Pydantic schemas for all API I/O
│ ├── prompts.py # Phase 14 prompt templates
│ └── sessions.py # In-memory session store (TTL 24h, UUID keys)
├── frontend/ # Vue 3 SPA source
│ ├── src/
│ │ ├── main.ts # App entry + dark mode pre-mount class application
│ │ ├── App.vue # Root: LoginView | TranslatorView based on auth state
│ │ ├── views/
│ │ │ ├── LoginView.vue # ID/PW login form → localStorage JWT
│ │ │ └── TranslatorView.vue # Main UI: model selectors, phase buttons, comparison
│ │ ├── components/
│ │ │ ├── LLMSelector.vue # Model dropdown (dark mode aware)
│ │ │ ├── ProperNounsEditor.vue # Proper noun table + style input
│ │ │ └── ComparisonView.vue # 3-column Phase 1/3/4 result comparison
│ │ ├── stores/
│ │ │ ├── auth.ts # JWT state (check/login/logout)
│ │ │ ├── translation.ts # Pipeline state + localStorage persistence
│ │ │ └── theme.ts # Dark mode toggle + localStorage + system preference detection
│ │ ├── api.ts # Axios client with JWT interceptor
│ │ └── types.ts # TypeScript interfaces mirroring backend models
│ ├── tailwind.config.js # darkMode: 'class' configured here
│ ├── vite.config.ts # Dev proxy to /api → localhost:8000; build output → dist/
│ └── package.json
├── config/
│ ├── llms.json # LLM server configs [{alias, base_url, api_key, model, context_size}]
│ └── users.json # Users [{id, password_hash}] — hashed on first startup from plain-text
├── requirements.txt # Python deps: fastapi, uvicorn[standard], pyjwt, bcrypt, openai, python-dotenv
├── .env.example # Environment variables template
└── README.md # (TBD)
```
## Key Patterns & Conventions
### Backend
- **No `passlib`** — use `bcrypt` module directly (`bcrypt.hashpw`, `bcrypt.checkpw`). The project uses Python 3.14 which has compatibility issues with passlib's bcrypt wrapper.
- **LLM client caching**`llm_client.py` maintains a per-alias `{AsyncOpenAI, LLMConfig}` cache dict keyed by alias string. Clients are lazily instantiated on first use.
- **Session store** — simple in-memory dict (`sessions.py`). Key is UUID hex (16 chars). TTL 24h from last access. No persistence beyond process lifetime.
- **JSON extraction** — LLM responses may be wrapped in markdown code blocks. `_extract_json()` strips ```` ```json` ... ```` wrappers before `json.loads()`.
### Frontend
- **All Vue components use `<script setup lang="ts">`** with Composition API. No Options API.
- **State management via Pinia stores**`auth`, `translation`, `theme`. Stores persist model selections and history to `localStorage`.
- **Dark mode is class-based** (`<html class="dark">`). Applied in `main.ts` before mount to prevent flash, then toggled by `useThemeStore.toggle()`. Every UI component has corresponding `dark:` Tailwind classes.
- **API calls go through `/api` proxy** during dev (vite.config.ts). In production the same origin serves both API and static files.
### LLM Configuration
Models are defined in `config/llms.json`. Each entry:
```json
{
"alias": "Qwen3.6-35B", // Display name, used as modelPhase1..4 values
"base_url": "http://...", // OpenAI-compatible base URL (must end with /v1)
"api_key": "...", // API key for this server
"model": "hx370/qwen3.6-35b-a3b", // Exact model name as used by the LLM provider
"context_size": 65536 // Token context window size (for length warnings)
}
```
### Translation Pipeline Data Flow
```
source_text ──► Phase1(LLM) ──► {translated, proper_nouns[], summary, style}
▼ user edits in ProperNounsEditor
phase2_proper_nouns[], phase2_style
▼ Phase3(LLM): re-translate with constraints
phase3_result
▼ Phase4(LLM): polish for readability
phase4_result (final)
```
Phase buttons are always freely executable — user can jump to any phase independently. Dependencies are only logical: P2 needs P1 data, P3 needs P1 result, P4 needs P3 result.
### Theme / Dark Mode
- `theme.ts` store manages state in Pinia + localStorage. On init, checks saved value or falls back to system preference (`prefers-color-scheme`).
- Every component with background/border/text colors must include matching `dark:` variants.
- Default light theme: white/slate backgrounds, blue accents. Dark theme: gray-800/900 backgrounds, adjusted borders, preserved accent colors (blue, purple, emerald remain readable).
## Running the Project
```bash
# 1. Install dependencies
pip install -r requirements.txt
cd frontend && npm install && cd ..
# 2. Build frontend (required before serving in production mode)
cd frontend && npx vite build && cd ..
# 3. Configure LLM servers and users
# Edit config/llms.json and config/users.json directly
# 4. Start server
uvicorn backend.main:app --host 0.0.0.0 --port 8000
# Or with env vars for custom paths:
JWT_SECRET_KEY=your-secret uvicorn backend.main:app --reload
# 5. Open http://localhost:8000
```
**Default login**: `admin` / `changeme123`. First startup auto-hashes plain-text passwords in users.json and saves the file.
## Adding a New Phase or Modifying Prompts
1. Edit `backend/prompts.py`: add new prompt templates using f-strings with source data parameters
2. Add API endpoint in `backend/main.py` following the existing pattern:
```python
@app.post("/api/translate/{session_id}/phaseN")
async def run_phaseN(session_id: str):
session = session_store.get(session_id)
# ... call chat_complete(alias, system_prompt, user_content)
updated = session_store.update(session_id, {"phaseN_result": result_text})
return SessionResponse(session_id=session_id, data=updated).model_dump()
```
3. Add frontend component and button in `TranslatorView.vue`
4. Update `translation.ts` store with new execution method + state
## File I/O Notes
- **config/llms.json** — loaded once at startup by `llm_client.py._load_llm_configs()`. Not re-read on requests. Changes require server restart.
- **config/users.json** — loaded once at startup by `auth.py._load_users_file()`. Plain-text passwords are auto-migrated to bcrypt hashes and saved back on first run only.
## Testing Against LLM Server
```bash
# Quick connectivity test (use exact model name from llms.json)
curl -s http://btuna.net:45455/v1/chat/completions \
-H "Authorization: Bearer btuna-public-key" \
-H "Content-Type: application/json" \
-d '{"model":"hx370/qwen3.6-35b-a3b","messages":[{"role":"user","content":"test"}],"max_tokens":10}'
```
If you get `PermissionDeniedError`, the API key lacks access to that model — try a different model or contact server admin for correct permissions.

127
README.md Normal file
View file

@ -0,0 +1,127 @@
# LLM Translator — 4단계 고품질 번역 파이프라인
지인 공유용 웹 기반 번역기. OpenAI 호환 LLM 서버를 통해 원문을 도착 언어로 고품질 번역합니다.
## 특징
- **4단계 번역 파이프라인**: 초벌번역 → 고유명사/문체 분석 → 재번역 → 마무리 다듬기
- **Phase별 별도 LLM 모델 선택**: 각 단계마다 다른 모델을 지정 가능
- **고유명사 관리**: 추출된 고유명사를 사용자가 검토·수정 가능
- **세로 3열 비교 UI**: Phase 1 / Phase 3 / Phase 4 결과를 한눈에 비교
- **다크모드 지원**: 시스템 설정 자동 감지 + 수동 토글 (localStorage 지속화)
- **JWT 기반 인증**: config-file 기반 ID/PW 로그인
- **단일 서버 배포**: FastAPI가 API + 정적 프론트엔드 동시에 서빙
## 빠른 시작
```bash
# 1. 의존성 설치
pip install -r requirements.txt
cd frontend && npm install && cd ..
# 2. 프론트엔드 빌드 (프로덕션 모드)
npx vite build --prefix-links=false
# 3. 서버 실행
uvicorn backend.main:app --host 0.0.0.0 --port 8000
# 4. 브라우저에서 http://localhost:8000 접속
```
**기본 로그인**: `admin` / `changeme123` (첫 시작 시 비밀번호가 bcrypt 해시로 자동 변환됨)
## 설정 파일
### LLM 서버 (`config/llms.json`)
```json
[
{
"alias": "Qwen3.6-35B",
"base_url": "http://btuna.net:45455/v1",
"api_key": "...",
"model": "hx370/qwen3.6-35b-a3b",
"context_size": 65536
}
]
```
### 사용자 (`config/users.json`)
```json
[{"id": "admin", "password_plain": "changeme123"}]
```
첫 시작 시 `password_plain`이 자동으로 `password`(bcrypt hash)로 변환되어 파일에 저장됩니다.
## 환경 변수 (`.env.example` 참조)
| 변수 | 설명 | 기본값 |
|------|------|--------|
| `JWT_SECRET_KEY` | JWT 서명 키 | `dev-secret-key-change-in-production` |
| `JWT_EXPIRE_HOURS` | 토큰 만료 시간(시간) | `24` |
| `LLMS_CONFIG_PATH` | LLM 설정 파일 경로 | `config/llms.json` |
| `USERS_CONFIG_PATH` | 사용자 설정 파일 경로 | `config/users.json` |
| `PORT` | 서버 포트 | `8000` |
## 프로젝트 구조
```
├── backend/ # FastAPI 백엔드 (auth, llm_client, routes)
├── frontend/ # Vue 3 + TypeScript 프론트엔드 (Vite 빌드)
│ ├── dist/ # 빌드 출력물 (.gitignore 제외)
│ └── src/ # Vue 컴포넌트, Pinia store, API 클라이언트
├── config/ # LLM 서버 및 사용자 설정 파일
└── requirements.txt # Python 의존성
```
## 개발 모드 (프론트엔드 HMR)
```bash
# 터미널 1: 백엔드 실행
uvicorn backend.main:app --reload
# 터미널 2: 프론트엔드 dev 서버
cd frontend && npx vite # http://localhost:5173
# /api 요청은 자동으로 localhost:8000으로 프록시됨
```
## API 엔드포인트
| Method | Path | 설명 | 인증 |
|--------|------|------|------|
| `POST` | `/api/auth/login` | 로그인 → JWT 토큰 발급 | × |
| `GET` | `/api/auth/me` | 현재 사용자 정보 | ○ (JWT) |
| `GET` | `/api/models` | 사용 가능한 LLM 모델 목록 | × |
| `POST` | `/api/translate/create` | 새 번역 세션 생성 | ○ |
| `GET` | `/api/sessions/{id}` | 세션 상태 조회 | ○ |
| `PATCH`| `/api/sessions/{id}` | 세션 필드 부분 업데이트 | ○ |
| `POST` | `/api/translate/{id}/phase1` | 초벌번역 + 고유명사 추출 | ○ |
| `POST` | `/api/translate/{id}/phase2` | 고유명사/문체 확인 | ○ |
| `POST` | `/api/translate/{id}/phase3` | 재번역 (제약조건 적용) | ○ |
| `POST` | `/api/translate/{id}/phase4` | 마무리 다듬기 | ○ |
## 빌드 & 배포
```bash
# 1. 프론트엔드 빌드
cd frontend && npx vite build && cd ..
# 2. 백엔드 서빙 (dist 정적 파일 포함)
uvicorn backend.main:app --host 0.0.0.0 --port 8000
```
`backend/main.py``frontend/dist/` 폴더를 정적 파일로 마운트하므로, 빌드 후 백엔드 서버 하나만 실행하면 됩니다.
## 테스트 (LLM 서버 연결 확인)
```bash
curl -s http://btuna.net:45455/v1/chat/completions \
-H "Authorization: Bearer btuna-public-key" \
-H "Content-Type: application/json" \
-d '{"model":"hx370/qwen3.6-35b-a3b","messages":[{"role":"user","content":"test"}],"max_tokens":10}' | jq
```
## 라이선스
내부 사용 목적

1
backend/__init__.py Normal file
View file

@ -0,0 +1 @@
# LLM Translator Backend Package

85
backend/auth.py Normal file
View file

@ -0,0 +1,85 @@
"""JWT-based authentication with config-file user management."""
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
import bcrypt
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
# ── Config ────────────────────────────────────────────
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "24"))
security = HTTPBearer()
# ── User Loading ───────────────────────────────────────
def _load_users_file():
"""Load users from config file. Auto-hash plain-text passwords on first run."""
import json as j
config_path = os.getenv(
"USERS_CONFIG_PATH", str(Path(__file__).parent.parent / "config" / "users.json")
)
with open(config_path, encoding="utf-8") as f:
users_raw = j.load(f)
# Migrate plain-text passwords to bcrypt hashes (one-time on startup)
migrated = False
for user in users_raw:
if "password_plain" in user and "password" not in user:
pw_bytes = user.pop("password_plain").encode("utf-8")
user["password"] = bcrypt.hashpw(pw_bytes, bcrypt.gensalt()).decode("utf-8")
migrated = True
if migrated:
with open(config_path, "w", encoding="utf-8") as f:
j.dump(users_raw, f, ensure_ascii=False, indent=2)
return users_raw
_users_db = _load_users_file()
def get_user_by_username(username: str):
for u in _users_db:
if u["id"] == username:
return u
return None
# ── Token Helpers ─────────────────────────────────────
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
def decode_access_token(token: str) -> dict | None:
try:
return jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
# ── Dependency ───────────────────────────────────────
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
"""Extract and validate JWT, returning the user_id."""
payload = decode_access_token(credentials.credentials)
if payload is None or "user_id" not in payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않거나 만료된 토큰입니다",
)
return payload["user_id"]

88
backend/llm_client.py Normal file
View file

@ -0,0 +1,88 @@
"""OpenAI-compatible LLM client for multi-server support."""
import os
from pathlib import Path
from openai import AsyncOpenAI
from backend.models import LLMConfig
# ── Server Config Loading ─────────────────────────────
def _load_llm_configs() -> list[LLMConfig]:
config_path = os.getenv(
"LLMS_CONFIG_PATH", str(Path(__file__).parent.parent / "config" / "llms.json")
)
import json
with open(config_path, encoding="utf-8") as f:
data = json.load(f)
return [LLMConfig(**item) for item in data]
_llm_configs: list[LLMConfig] = []
_clients: dict[str, AsyncOpenAI] = {} # alias -> client instance
def get_llm_configs() -> list[LLMConfig]:
"""Return all LLM configurations."""
return _llm_configs
def get_client_for_alias(alias: str) -> tuple[AsyncOpenAI, LLMConfig]:
"""Get the AsyncOpenAI client and config for a given alias.
Creates a lazily cached client per alias.
"""
if alias not in _clients:
cfg = None
for c in _llm_configs:
if c.alias == alias:
cfg = c
break
if cfg is None:
raise ValueError(f"Unknown LLM alias: {alias}")
client = AsyncOpenAI(
api_key=cfg.api_key,
base_url=cfg.base_url,
timeout=120.0,
)
_clients[alias] = client
return _clients[alias], next(c for c in _llm_configs if c.alias == alias)
async def chat_complete(
alias: str,
system_prompt: str,
user_content: str,
temperature: float = 0.3,
) -> str:
"""Send a single-turn chat completion and return the assistant message."""
client, cfg = get_client_for_alias(alias)
response = await client.chat.completions.create(
model=cfg.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
temperature=temperature,
)
content = response.choices[0].message.content or ""
return content.strip()
def get_context_size(alias: str) -> int | None:
"""Get the context window size for a given alias."""
for c in _llm_configs:
if c.alias == alias:
return c.context_size
return None
# ── Initialize on import ──────────────────────────────
_llm_configs = _load_llm_configs()

286
backend/main.py Normal file
View file

@ -0,0 +1,286 @@
"""FastAPI application — main entry point for the LLM Translator backend."""
import os
from pathlib import Path
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from backend import auth as auth_mod
from backend.auth import get_current_user
from backend.llm_client import (
chat_complete,
get_llm_configs,
get_context_size,
)
from backend.models import (
CreateSessionRequest,
LoginRequest,
Phase1Result,
Phase2ConfirmRequest,
PhaseUpdateRequest,
ProperNoun,
SessionResponse,
TokenResponse,
UserInfo,
TranslationSession,
)
from backend.prompts import (
SYSTEM_PROMPT_PHASE1,
SYSTEM_PROMPT_PHASE3,
build_phase1_user_prompt,
build_phase3_user_prompt,
build_phase4_user_prompt,
)
from backend.sessions import session_store
# ── App Setup ────────────────────────────────────────
app = FastAPI(title="LLM Translator", version="0.1.0")
# CORS (frontend same-origin by default, allow dev origins)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:8000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Auth Routes ───────────────────────────────────────
@app.post("/api/auth/login")
async def login(req: LoginRequest):
import bcrypt as _bcrypt
user = auth_mod.get_user_by_username(req.id)
if user is None or not _bcrypt.checkpw(
req.password.encode("utf-8"), user["password"].encode("utf-8")
):
raise HTTPException(status_code=401, detail="ID 또는 비밀번호가 틀렸습니다")
token = auth_mod.create_access_token({"user_id": user["id"]})
return TokenResponse(access_token=token)
@app.get("/api/auth/me")
async def me(user_id: str = Depends(get_current_user)):
return UserInfo(user_id=user_id)
# ── LLM Config Routes ────────────────────────────────
@app.get("/api/models")
async def list_models():
"""Return available LLM model configurations."""
configs = get_llm_configs()
return [
{
"alias": c.alias,
"model": c.model,
"context_size": c.context_size,
}
for c in configs
]
# ── Session Routes ───────────────────────────────────
@app.post("/api/translate/create")
async def create_session(req: CreateSessionRequest):
session_data = TranslationSession(
source_text=req.source_text,
source_language=req.source_language,
target_language=req.target_language,
model_phase1=req.model_phase1,
model_phase2=req.model_phase2,
model_phase3=req.model_phase3,
model_phase4=req.model_phase4,
)
session_id = session_store.create(session_data)
return {"session_id": session_id}
@app.get("/api/sessions/{session_id}")
async def get_session(session_id: str):
data = session_store.get(session_id)
if data is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=data).model_dump()
@app.delete("/api/sessions/{session_id}")
async def delete_session(session_id: str):
deleted = session_store.delete(session_id)
if not deleted:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return {"deleted": True}
@app.patch("/api/sessions/{session_id}")
async def update_session(session_id: str, req: PhaseUpdateRequest):
data = req.model_dump(exclude_none=True)
updated = session_store.update(session_id, data)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
# ── Phase Execution Routes ───────────────────────────
@app.post("/api/translate/{session_id}/phase1")
async def run_phase1(session_id: str):
"""Run Phase 1: rough translation + proper noun extraction + summary + style analysis."""
session = session_store.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
alias = session.model_phase1
if not alias:
raise HTTPException(status_code=400, detail="Phase 1 LLM 모델을 선택하세요")
# Context size check (rough: source text tokens ~ len/2 for Korean)
ctx_size = get_context_size(alias)
if ctx_size and len(session.source_text) > ctx_size * 0.7:
raise HTTPException(
status_code=400,
detail=f"원문이 너무 깁니다 (최대 {ctx_size} 컨텍스트의 70% 이내로 줄여주세요)",
)
user_prompt = build_phase1_user_prompt(session.source_text, session.target_language)
raw_response = await chat_complete(alias, SYSTEM_PROMPT_PHASE1, user_prompt)
# Parse JSON response — extract from code blocks if wrapped
import json
cleaned = _extract_json(raw_response)
parsed = json.loads(cleaned)
result = Phase1Result(
translated=parsed.get("translated", ""),
proper_nouns=[ProperNoun(**pn) for pn in parsed.get("proper_nouns", [])],
summary=parsed.get("summary", ""),
style=parsed.get("style", ""),
)
# Update session with Phase 1 results and initialize Phase 2 data
update_data = {
"phase1_result": result.model_dump(),
"phase2_proper_nouns": [pn.model_dump() for pn in result.proper_nouns],
"phase2_style": result.style,
}
updated = session_store.update(session_id, update_data)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
@app.post("/api/translate/{session_id}/phase2")
async def run_phase2(session_id: str, req: Phase2ConfirmRequest):
"""Phase 2: user confirms or edits proper nouns and style."""
session = session_store.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
update_data = {
"phase2_proper_nouns": [pn.model_dump() for pn in req.proper_nouns],
"phase2_style": req.style,
}
updated = session_store.update(session_id, update_data)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
@app.post("/api/translate/{session_id}/phase3")
async def run_phase3(session_id: str):
"""Run Phase 3: re-translation with proper noun and style constraints."""
session = session_store.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
if not session.phase1_result:
raise HTTPException(status_code=400, detail="먼저 Phase 1을 실행하세요")
alias = session.model_phase3 or session.model_phase1
user_prompt = build_phase3_user_prompt(
phase1_translated=session.phase1_result.translated,
proper_nouns=[pn.model_dump() for pn in session.phase2_proper_nouns],
style=session.phase2_style or (session.phase1_result.style if session.phase1_result else ""),
target_language=session.target_language,
)
result_text = await chat_complete(alias, SYSTEM_PROMPT_PHASE3, user_prompt)
update_data = {"phase3_result": result_text}
updated = session_store.update(session_id, update_data)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
@app.post("/api/translate/{session_id}/phase4")
async def run_phase4(session_id: str):
"""Run Phase 4: polish for readability."""
session = session_store.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
if not session.phase3_result:
raise HTTPException(status_code=400, detail="먼저 Phase 3을 실행하세요")
alias = session.model_phase4 or session.model_phase3 or session.model_phase1
user_prompt = build_phase4_user_prompt(
phase3_result=session.phase3_result,
proper_nouns=[pn.model_dump() for pn in session.phase2_proper_nouns],
style=session.phase2_style or (session.phase1_result.style if session.phase1_result else ""),
)
# Patch target_language into system prompt
sys_prompt = SYSTEM_PROMPT_PHASE4.replace("{target_language}", session.target_language)
result_text = await chat_complete(alias, sys_prompt, user_prompt)
update_data = {"phase4_result": result_text}
updated = session_store.update(session_id, update_data)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
# ── Helpers ───────────────────────────────────────────
def _extract_json(text: str) -> str:
"""Extract JSON from LLM response, handling markdown code blocks."""
text = text.strip()
# Remove markdown json/code block wrapper
if "```" in text:
parts = text.split("```")
for part in parts:
part = part.strip()
if part.startswith("json"):
part = part[4:].strip()
if part.startswith("{"):
return part
return text
# ── Static File Serving (Frontend) ───────────────────
frontend_dist = os.getenv(
"FRONTEND_DIST_PATH", str(Path(__file__).parent.parent / "frontend" / "dist")
)
if Path(frontend_dist).exists():
app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="frontend")
else:
# Dev mode: serve a simple HTML page
@app.get("/")
async def dev_root():
return {"message": "LLM Translator — 프론트엔드를 빌드하세요 (cd frontend && npm run build)"}

99
backend/models.py Normal file
View file

@ -0,0 +1,99 @@
"""Pydantic request/response models for the API."""
from pydantic import BaseModel, Field
# ── Auth ───────────────────────────────────────────────
class LoginRequest(BaseModel):
id: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserInfo(BaseModel):
user_id: str
# ── LLM Config ───────────────────────────────────────
class LLMConfig(BaseModel):
alias: str
base_url: str
api_key: str
model: str
context_size: int
# ── Translation Session ────────────────────────────────
class ProperNoun(BaseModel):
original: str # 원문 고유명사
suggested: str # 제안 번역
final: str = "" # 사용자 최종 결정 (Phase 2)
class Phase1Result(BaseModel):
translated: str # 초벌 번역 결과
proper_nouns: list[ProperNoun] # 추출된 고유명사 리스트
summary: str # 내용 요약
style: str # 문체 분석 결과
class TranslationSession(BaseModel):
"""Full translation session state."""
source_text: str = "" # 원문
source_language: str = "auto" # 출발어 (auto = 자동판단)
target_language: str = "한국어" # 도착어
model_phase1: str = "" # Phase 1 LLM 모델 alias
model_phase2: str = "" # Phase 2 LLM 모델 alias
model_phase3: str = "" # Phase 3 LLM 모델 alias
model_phase4: str = "" # Phase 4 LLM 모델 alias
phase1_result: Phase1Result | None = None # 초벌 번역 결과
phase2_proper_nouns: list[ProperNoun] = [] # Phase 2에서 최종 확정된 고유명사
phase2_style: str = "" # Phase 2에서 최종 확정된 문체
phase3_result: str = "" # 재번역 결과
phase4_result: str = "" # 마무리 다듬기 결과
# ── API Requests ───────────────────────────────────────
class CreateSessionRequest(BaseModel):
source_text: str
source_language: str = "auto"
target_language: str = "한국어"
model_phase1: str = ""
model_phase2: str = ""
model_phase3: str = ""
model_phase4: str = ""
class PhaseUpdateRequest(BaseModel):
"""Partial update for a specific phase."""
source_text: str | None = None
source_language: str | None = None
target_language: str | None = None
model_phase1: str | None = None
model_phase2: str | None = None
model_phase3: str | None = None
model_phase4: str | None = None
class Phase2ConfirmRequest(BaseModel):
"""User confirms or edits proper nouns and style for Phase 2."""
proper_nouns: list[ProperNoun]
style: str
# ── API Responses ──────────────────────────────────────
class SessionResponse(BaseModel):
session_id: str
data: TranslationSession

113
backend/prompts.py Normal file
View file

@ -0,0 +1,113 @@
"""Phase 1~4 prompt templates for the translation pipeline."""
# ── Phase 1: 초벌 번역 + 고유명사 추출 + 요약 + 문체 분석 ─
SYSTEM_PROMPT_PHASE1 = """\
당신은 전문 번역가입니다. 사용자의 지시에 따라 원문을 도착 언어로 번역하고, \
고유명사를 추출하며, 내용 요약을 작성하고, 문체를 분석합니다.\
"""
def build_phase1_user_prompt(source_text: str, target_language: str) -> str:
return f"""\
다음 원문을 "{target_language}"으로 번역하세요.
번역 결과를 반드시 아래 JSON 포맷으로만 반환하세요 (마크다운 코드 블록 없이 순수 JSON):
{{
"translated": "(도착어 번역문)",
"proper_nouns": [
{{"original": "(원문 고유명사)", "suggested": "(제안 도착어 번역)"}}
],
"summary": "(내용 요약, 2-3문장以内)",
"style": "(문체 분석: 예: '공식적인', '친근한', '학술적', '보도풍', '문학적이고 서정적인' 등)"
}}
고유명사 추출 규칙:
- 인물명,地名, 기관명, 제품명, 작품명 고유 명사를 포함
- 일반 명사는 제외 (: "정부", "회사" 제외)
- 없는 경우 배열 [] 반환
원문:
---
{source_text}
---"""
# ── Phase 3: 재번역 (고유명사 + 문체 적용) ───────────────
SYSTEM_PROMPT_PHASE3 = """\
당신은 숙련된 번역 교정가입니다. 제공된 초벌 번역문을 고유명사 매핑과 \
문체 지시를 엄격히 적용하여 재번역하세요.\
"""
def build_phase3_user_prompt(
phase1_translated: str,
proper_nouns: list[dict],
style: str,
target_language: str,
) -> str:
pn_lines = "\n".join(
f" - {pn['original']}{pn.get('final', pn.get('suggested', ''))}"
for pn in proper_nouns if pn.get("original") or pn.get("suggested")
)
return f"""\
다음 초벌 번역문을 "{target_language}"으로 재번역하세요.
고유명사 매핑 (반드시 적용):
{pn_lines}
문체: {style}
재번역 규칙:
1. 고유명사 매핑을 반드시 준수하세요
2. 지시된 문체에 맞게 어조를 조정하세요
3. 초벌 번역의 의미는 유지하되 자연스럽고 정확히 다듬으세요
4. 결과만 반환하세요 (설명 없이 번역문만)
초벌 번역:
---
{phase1_translated}
---"""
# ── Phase 4: 가독성 개선 ───────────────────────────────
SYSTEM_PROMPT_PHASE4 = """\
당신은 "{target_language}" 원어민 편집자입니다. 제공된 번역문을 \
"{target_language}" 화자에게 읽기 편하도록 다듬으세요.\
"""
def build_phase4_user_prompt(
phase3_result: str,
proper_nouns: list[dict],
style: str,
) -> str:
pn_lines = "\n".join(
f" - {pn['original']}{pn.get('final', pn.get('suggested', ''))}"
for pn in proper_nouns if pn.get("original") or pn.get("suggested")
)
return f"""\
다음 번역문을 읽기 편하고 자연스러운 문장으로 다듬으세요.
고유명사 (변경 금지):
{pn_lines}
문체: {style}
다듬기 규칙:
1. 고유명사는 절대 변경하지 마세요
2. 한국어 화자에게 자연스럽게 읽히도록 어순과 표현을 조정하세요
3. 문장은 간결하고 명확하게 다듬으세요
4. 의미는 반드시 유지하세요
5. 결과만 반환하세요 (설명 없이 번역문만)
번역문:
---
{phase3_result}
---"""

70
backend/sessions.py Normal file
View file

@ -0,0 +1,70 @@
"""In-memory translation session store with 24h TTL."""
import uuid
from datetime import datetime, timezone, timedelta
from typing import Any
from backend.models import TranslationSession
class SessionStore:
"""Thread-safe-ish in-memory dict for translation sessions.
Each session is keyed by a UUID and has a 24-hour TTL.
Expired sessions are lazily cleaned on access.
"""
def __init__(self, ttl_hours: int = 24):
self._store: dict[str, dict[str, Any]] = {}
self.ttl_hours = ttl_hours
def create(self, session_data: TranslationSession) -> str:
session_id = uuid.uuid4().hex[:16]
self._store[session_id] = {
"data": session_data.model_dump(),
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
return session_id
def get(self, session_id: str) -> TranslationSession | None:
entry = self._store.get(session_id)
if entry is None:
return None
# Check TTL
now = datetime.now(timezone.utc)
age = now - entry["updated_at"]
if age > timedelta(hours=self.ttl_hours):
del self._store[session_id]
return None
# Touch (refresh TTL)
entry["updated_at"] = now
return TranslationSession(**entry["data"])
def update(self, session_id: str, data: dict[str, Any]) -> TranslationSession | None:
entry = self._store.get(session_id)
if entry is None:
return None
entry["data"].update(data)
entry["updated_at"] = datetime.now(timezone.utc)
return TranslationSession(**entry["data"])
def delete(self, session_id: str) -> bool:
if session_id in self._store:
del self._store[session_id]
return True
return False
def cleanup_expired(self):
"""Remove all expired sessions."""
now = datetime.now(timezone.utc)
expired_keys = [
k for k, v in self._store.items()
if (now - v["updated_at"]) > timedelta(hours=self.ttl_hours)
]
for k in expired_keys:
del self._store[k]
# Singleton instance
session_store = SessionStore(ttl_hours=24)

16
config/llms.json Normal file
View file

@ -0,0 +1,16 @@
[
{
"alias": "Qwen3.6-35B",
"base_url": "http://btuna.net:45455/v1",
"api_key": "btuna-public-key",
"model": "hx370/qwen3.6-35b-a3b",
"context_size": 65536
},
{
"alias": "Gemma4-26B",
"base_url": "http://btuna.net:45455/v1",
"api_key": "btuna-public-key",
"model": "hx370/gemma-4-26b-a4b-it",
"context_size": 32768
}
]

6
config/users.json Normal file
View file

@ -0,0 +1,6 @@
[
{
"id": "admin",
"password": "$2b$12$dH6P9Yt4fiqKiihqxgOLo.XqF2FWuspP4jfQJBPWdKWPqAVYe.sq."
}
]

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',
},
})

6
requirements.txt Normal file
View file

@ -0,0 +1,6 @@
fastapi==0.115.*
uvicorn[standard]==0.34.*
pyjwt==2.10.*
passlib[bcrypt]==1.7.*
openai==1.66.*
python-dotenv==1.0.*