Initial working translator implementation
This commit is contained in:
commit
9439858b11
35 changed files with 5365 additions and 0 deletions
17
.env.example
Normal file
17
.env.example
Normal 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
24
.gitignore
vendored
Normal 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
169
AGENTS.md
Normal 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 1–4 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
127
README.md
Normal 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
1
backend/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# LLM Translator Backend Package
|
||||
85
backend/auth.py
Normal file
85
backend/auth.py
Normal 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
88
backend/llm_client.py
Normal 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
286
backend/main.py
Normal 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
99
backend/models.py
Normal 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
113
backend/prompts.py
Normal 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
70
backend/sessions.py
Normal 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
16
config/llms.json
Normal 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
6
config/users.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[
|
||||
{
|
||||
"id": "admin",
|
||||
"password": "$2b$12$dH6P9Yt4fiqKiihqxgOLo.XqF2FWuspP4jfQJBPWdKWPqAVYe.sq."
|
||||
}
|
||||
]
|
||||
7
frontend/env.d.ts
vendored
Normal file
7
frontend/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LLM 번역기</title>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2980
frontend/package-lock.json
generated
Normal file
2980
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "llm-translator-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.0",
|
||||
"axios": "^1.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^6.1.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.25.12": true,
|
||||
"vue-demi@0.14.10": true
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
20
frontend/src/App.vue
Normal file
20
frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import LoginView from './views/LoginView.vue'
|
||||
import TranslatorView from './views/TranslatorView.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
onMounted(() => {
|
||||
auth.check()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 dark:from-gray-900 dark:to-slate-800 transition-colors">
|
||||
<!-- Show Login if not authenticated, otherwise Translator -->
|
||||
<LoginView v-if="!auth.isLoggedIn" />
|
||||
<TranslatorView v-else />
|
||||
</div>
|
||||
</template>
|
||||
113
frontend/src/api.ts
Normal file
113
frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/** Axios-based API client with JWT token management */
|
||||
|
||||
import axios from 'axios'
|
||||
import type { LLMModel, SessionResponse } from './types'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
// Attach JWT on every request
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('jwt_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Auto-redirect on 401
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('jwt_token')
|
||||
window.location.hash = '#/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────
|
||||
|
||||
export async function login(id: string, password: string): Promise<string> {
|
||||
const res = await api.post('/auth/login', { id, password })
|
||||
const token = res.data.access_token as string
|
||||
localStorage.setItem('jwt_token', token)
|
||||
return token
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
localStorage.removeItem('jwt_token')
|
||||
}
|
||||
|
||||
export async function me(): Promise<{ user_id: string }> {
|
||||
const res = await api.get('/auth/me')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ── Models ───────────────────────────────────────────
|
||||
|
||||
export async function listModels(): Promise<LLMModel[]> {
|
||||
const res = await api.get('/models')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ── Sessions ─────────────────────────────────────────
|
||||
|
||||
export async function createSession(params: {
|
||||
source_text: string
|
||||
source_language: string
|
||||
target_language: string
|
||||
model_phase1: string
|
||||
model_phase2: string
|
||||
model_phase3: string
|
||||
model_phase4: string
|
||||
}): Promise<{ session_id: string }> {
|
||||
const res = await api.post('/translate/create', params)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getSession(sessionId: string): Promise<SessionResponse> {
|
||||
const res = await api.get(`/sessions/${sessionId}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
await api.delete(`/sessions/${sessionId}`)
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
sessionId: string,
|
||||
patch: Record<string, any>,
|
||||
): Promise<SessionResponse> {
|
||||
const res = await api.patch(`/sessions/${sessionId}`, patch)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ── Phase Execution ──────────────────────────────────
|
||||
|
||||
export async function runPhase1(sessionId: string): Promise<SessionResponse> {
|
||||
const res = await api.post(`/translate/${sessionId}/phase1`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function runPhase2(
|
||||
sessionId: string,
|
||||
proper_nouns: any[],
|
||||
style: string,
|
||||
): Promise<SessionResponse> {
|
||||
const res = await api.post(`/translate/${sessionId}/phase2`, { proper_nouns, style })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function runPhase3(sessionId: string): Promise<SessionResponse> {
|
||||
const res = await api.post(`/translate/${sessionId}/phase3`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function runPhase4(sessionId: string): Promise<SessionResponse> {
|
||||
const res = await api.post(`/translate/${sessionId}/phase4`)
|
||||
return res.data
|
||||
}
|
||||
80
frontend/src/components/ComparisonView.vue
Normal file
80
frontend/src/components/ComparisonView.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<script setup lang="ts">
|
||||
import { useTranslationStore } from '../stores/translation'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const store = useTranslationStore()
|
||||
|
||||
const p1Text = computed(() => store.phase1Result?.translated || '')
|
||||
const p3Text = computed(() => store.phase3Result || '')
|
||||
const p4Text = computed(() => store.phase4Result || '')
|
||||
|
||||
function copy(text: string) {
|
||||
navigator.clipboard.writeText(text).catch(() => {})
|
||||
}
|
||||
|
||||
function download(filename: string, content: string) {
|
||||
const blob = new Blob([content], { type: 'text/plain; charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Export Bar -->
|
||||
<div v-if="p4Text" class="flex gap-2 justify-end">
|
||||
<button @click="copy(p4Text)" class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors flex items-center gap-1.5">
|
||||
📋 최종 결과 복사
|
||||
</button>
|
||||
<button @click="download('translation_final.txt', p4Text)" class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors flex items-center gap-1.5">
|
||||
📄 TXT 다운로드
|
||||
</button>
|
||||
<button @click="download('translation_final.md', p4Text)" class="px-3 py-1.5 text-sm bg-blue-100 dark:bg-blue-900/40 hover:bg-blue-200 dark:hover:bg-blue-900/60 rounded-lg transition-colors flex items-center gap-1.5">
|
||||
📝 MD 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 3-Column Comparison -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Phase 1 Column -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/30 dark:to-indigo-900/30 border-b border-blue-100 dark:border-blue-800 flex justify-between items-center">
|
||||
<h3 class="text-sm font-semibold text-blue-700 dark:text-blue-300">Phase 1 — 초벌 번역</h3>
|
||||
<button v-if="p1Text" @click="copy(p1Text)" class="text-xs px-2 py-1 bg-white dark:bg-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors" title="복사">📋</button>
|
||||
</div>
|
||||
<div class="p-4 min-h-[200px] max-h-[600px] overflow-y-auto prose-sm text-gray-800 dark:text-gray-200">
|
||||
<p v-if="!p1Text" class="text-gray-400 italic text-sm">Phase 1을 실행하면 결과가 표시됩니다</p>
|
||||
<p v-else class="whitespace-pre-wrap leading-relaxed">{{ p1Text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 3 Column -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-gradient-to-r from-emerald-50 to-teal-50 dark:from-emerald-900/30 dark:to-teal-900/30 border-b border-emerald-100 dark:border-emerald-800 flex justify-between items-center">
|
||||
<h3 class="text-sm font-semibold text-emerald-700 dark:text-emerald-300">Phase 3 — 재번역</h3>
|
||||
<button v-if="p3Text" @click="copy(p3Text)" class="text-xs px-2 py-1 bg-white dark:bg-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors" title="복사">📋</button>
|
||||
</div>
|
||||
<div class="p-4 min-h-[200px] max-h-[600px] overflow-y-auto prose-sm text-gray-800 dark:text-gray-200">
|
||||
<p v-if="!p3Text" class="text-gray-400 italic text-sm">Phase 3을 실행하면 결과가 표시됩니다</p>
|
||||
<p v-else class="whitespace-pre-wrap leading-relaxed">{{ p3Text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 4 Column -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border-2 border-purple-200 dark:border-purple-700 overflow-hidden shadow-lg">
|
||||
<div class="px-4 py-3 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/30 dark:to-pink-900/30 border-b border-purple-100 dark:border-purple-800 flex justify-between items-center">
|
||||
<h3 class="text-sm font-semibold text-purple-700 dark:text-purple-300">Phase 4 — 최종 결과 ✨</h3>
|
||||
<button v-if="p4Text" @click="copy(p4Text)" class="text-xs px-2 py-1 bg-white dark:bg-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors" title="복사">📋</button>
|
||||
</div>
|
||||
<div class="p-4 min-h-[200px] max-h-[600px] overflow-y-auto prose-sm text-gray-800 dark:text-gray-200">
|
||||
<p v-if="!p4Text" class="text-gray-400 italic text-sm">Phase 4를 실행하면 최종 번역문이 표시됩니다</p>
|
||||
<p v-else class="whitespace-pre-wrap leading-relaxed">{{ p4Text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
30
frontend/src/components/LLMSelector.vue
Normal file
30
frontend/src/components/LLMSelector.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script setup lang="ts">
|
||||
import type { LLMModel } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
value: string
|
||||
models: LLMModel[]
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'update:value', v: string): void }>()
|
||||
|
||||
function update(v: string) {
|
||||
emit('update:value', v)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ label }}</label>
|
||||
<select
|
||||
:value="value"
|
||||
@change="update((($event.target as HTMLSelectElement).value))"
|
||||
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option value="">-- 선택 --</option>
|
||||
<option v-for="m in models" :key="m.alias" :value="m.alias">
|
||||
{{ m.alias }} ({{ m.context_size.toLocaleString() }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
80
frontend/src/components/ProperNounsEditor.vue
Normal file
80
frontend/src/components/ProperNounsEditor.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<script setup lang="ts">
|
||||
import type { ProperNoun } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
proper_nouns: ProperNoun[]
|
||||
style: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:proper_nouns', v: ProperNoun[]): void
|
||||
(e: 'update:style', v: string): void
|
||||
}>()
|
||||
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const nouns = ref<ProperNoun[]>([...props.proper_nouns])
|
||||
const styleText = ref(props.style)
|
||||
|
||||
watch(() => props.proper_nouns, (v) => { nouns.value = [...v] }, { deep: true })
|
||||
watch(() => props.style, (v) => { styleText.value = v })
|
||||
|
||||
function emitAll() {
|
||||
emit('update:proper_nouns', nouns.value)
|
||||
emit('update:style', styleText.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
|
||||
<!-- Summary & Style -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">📝 요약 및 문체</h4>
|
||||
<p v-if="props.proper_nouns.length === 0 && !styleText" class="text-sm text-gray-400 italic">
|
||||
Phase 1 결과를 확인하면 요약과 문체가 표시됩니다.
|
||||
</p>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<!-- Summary is read-only, shown from phase1Result — but we pass it as a prop somewhere else -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">문체 (수정 가능)</label>
|
||||
<input
|
||||
v-model="styleText"
|
||||
@change="emitAll()"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proper Nouns Table -->
|
||||
<div v-if="nouns.length > 0">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">🔤 고유명사 번역 (수정 가능)</h4>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-600">
|
||||
<th class="text-left py-2 px-3 text-xs font-medium text-gray-500 dark:text-gray-400">원문</th>
|
||||
<th class="text-left py-2 px-3 text-xs font-medium text-gray-500 dark:text-gray-400">제안 번역</th>
|
||||
<th class="text-left py-2 px-3 text-xs font-medium text-gray-500 dark:text-gray-400">최종 번역 (수정)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(pn, idx) in nouns" :key="idx" class="border-b border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="py-2 px-3 font-medium text-gray-800 dark:text-gray-200">{{ pn.original }}</td>
|
||||
<td class="py-2 px-3 text-gray-600 dark:text-gray-400">{{ pn.suggested }}</td>
|
||||
<td class="py-1 px-2">
|
||||
<input
|
||||
v-model="nouns[idx].final"
|
||||
@change="emitAll()"
|
||||
type="text"
|
||||
class="w-full px-2 py-1 border border-gray-200 dark:border-gray-600 rounded text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-sm text-gray-400 italic">고유명사가 없습니다</div>
|
||||
</div>
|
||||
</template>
|
||||
14
frontend/src/main.ts
Normal file
14
frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
// Apply dark class before Vue mounts to avoid flash
|
||||
const saved = localStorage.getItem('llm_translator_theme')
|
||||
if (saved === 'dark' || (saved === null && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.mount('#app')
|
||||
30
frontend/src/stores/auth.ts
Normal file
30
frontend/src/stores/auth.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { me as apiMe, logout as apiLogout } from '../api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user_id = ref<string | null>(null)
|
||||
const loaded = ref(false)
|
||||
|
||||
const isLoggedIn = computed(() => !!user_id.value && loaded.value)
|
||||
|
||||
async function check() {
|
||||
if (loaded.value) return
|
||||
try {
|
||||
const data = await apiMe()
|
||||
user_id.value = data.user_id
|
||||
} catch {
|
||||
user_id.value = null
|
||||
} finally {
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await apiLogout()
|
||||
user_id.value = null
|
||||
window.location.hash = '#/login'
|
||||
}
|
||||
|
||||
return { user_id, loaded, isLoggedIn, check, logout }
|
||||
})
|
||||
33
frontend/src/stores/theme.ts
Normal file
33
frontend/src/stores/theme.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { ref, watch, onMounted } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const isDark = ref(false)
|
||||
|
||||
function init() {
|
||||
const saved = localStorage.getItem('llm_translator_theme')
|
||||
if (saved === 'dark') {
|
||||
isDark.value = true
|
||||
} else if (saved === null && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
isDark.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||
|
||||
watch(isDark, (dark) => {
|
||||
if (dark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
localStorage.setItem('llm_translator_theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
localStorage.setItem('llm_translator_theme', 'light')
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => init())
|
||||
|
||||
return { isDark, toggle }
|
||||
})
|
||||
335
frontend/src/stores/translation.ts
Normal file
335
frontend/src/stores/translation.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ProperNoun, TranslationSessionData, HistoryEntry, LLMModel } from '../types'
|
||||
import * as api from '../api'
|
||||
|
||||
const HISTORY_KEY = 'llm_translator_history'
|
||||
const SESSION_KEY_PREFIX = 'llm_translator_session_'
|
||||
|
||||
export const useTranslationStore = defineStore('translation', () => {
|
||||
// ── State ────────────────────────────────────────
|
||||
const sessionId = ref<string | null>(null)
|
||||
const sourceText = ref('')
|
||||
const sourceLanguage = ref('auto')
|
||||
const targetLanguage = ref('한국어')
|
||||
const modelPhase1 = ref('')
|
||||
const modelPhase2 = ref('')
|
||||
const modelPhase3 = ref('')
|
||||
const modelPhase4 = ref('')
|
||||
|
||||
// Phase results
|
||||
const phase1Result = ref<{ translated: string; proper_nouns: ProperNoun[]; summary: string; style: string } | null>(null)
|
||||
const phase2ProperNouns = ref<ProperNoun[]>([])
|
||||
const phase2Style = ref('')
|
||||
const phase3Result = ref('')
|
||||
const phase4Result = ref('')
|
||||
|
||||
// UI state
|
||||
const loading = ref(false)
|
||||
const currentPhaseLoading = ref<number | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const models = ref<LLMModel[]>([])
|
||||
const history = ref<HistoryEntry[]>([])
|
||||
|
||||
// ── Computed ─────────────────────────────────────
|
||||
const phase1Done = computed(() => !!phase1Result.value)
|
||||
const phase2Ready = computed(() => phase1Done.value) // can edit after P1
|
||||
const phase3Ready = computed(() => phase1Done.value && (phase2ProperNouns.value.length > 0 || phase2Style.value))
|
||||
const phase4Ready = computed(() => !!phase3Result.value)
|
||||
|
||||
const activeAbortController = ref<AbortController | null>(null)
|
||||
|
||||
// ── Actions ──────────────────────────────────────
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
models.value = await api.listModels()
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load models:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function newSession() {
|
||||
sessionId.value = null
|
||||
sourceText.value = ''
|
||||
sourceLanguage.value = 'auto'
|
||||
targetLanguage.value = '한국어'
|
||||
phase1Result.value = null
|
||||
phase2ProperNouns.value = []
|
||||
phase2Style.value = ''
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
error.value = null
|
||||
|
||||
// Restore model selections from localStorage
|
||||
const savedModels = JSON.parse(localStorage.getItem('llm_translator_models') || '{}')
|
||||
modelPhase1.value = savedModels.p1 || models.value[0]?.alias || ''
|
||||
modelPhase2.value = savedModels.p2 || models.value[0]?.alias || ''
|
||||
modelPhase3.value = savedModels.p3 || models.value[0]?.alias || ''
|
||||
modelPhase4.value = savedModels.p4 || (models.value[1]?.alias || models.value[0]?.alias || '')
|
||||
}
|
||||
|
||||
function saveModelSelections() {
|
||||
localStorage.setItem('llm_translator_models', JSON.stringify({
|
||||
p1: modelPhase1.value,
|
||||
p2: modelPhase2.value,
|
||||
p3: modelPhase3.value,
|
||||
p4: modelPhase4.value,
|
||||
}))
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (!sessionId.value) {
|
||||
const res = await api.createSession(getSessionParams())
|
||||
sessionId.value = res.session_id
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionParams() {
|
||||
return {
|
||||
source_text: sourceText.value,
|
||||
source_language: sourceLanguage.value,
|
||||
target_language: targetLanguage.value,
|
||||
model_phase1: modelPhase1.value,
|
||||
model_phase2: modelPhase2.value,
|
||||
model_phase3: modelPhase3.value,
|
||||
model_phase4: modelPhase4.value,
|
||||
}
|
||||
}
|
||||
|
||||
async function syncToServer() {
|
||||
if (!sessionId.value) return
|
||||
try {
|
||||
await api.updateSession(sessionId.value, getSessionParams())
|
||||
} catch (e: any) {
|
||||
console.error('Sync failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase Execution ──────────────────────────────
|
||||
|
||||
async function executePhase1() {
|
||||
if (!sourceText.value.trim()) {
|
||||
error.value = '원문을 입력해주세요'
|
||||
return
|
||||
}
|
||||
if (!modelPhase1.value) {
|
||||
error.value = 'Phase 1 LLM 모델을 선택하세요'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
currentPhaseLoading.value = 1
|
||||
error.value = null
|
||||
activeAbortController.value = new AbortController()
|
||||
|
||||
try {
|
||||
await ensureSession()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
const res = await api.runPhase1(sessionId.value)
|
||||
phase1Result.value = res.data.phase1_result
|
||||
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
|
||||
phase2Style.value = res.data.phase2_style || ''
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || e.message || 'Phase 1 실행 실패'
|
||||
} finally {
|
||||
loading.value = false
|
||||
currentPhaseLoading.value = null
|
||||
activeAbortController.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function executePhase2() {
|
||||
// Phase 2 is a user confirmation step — we just save the edited proper nouns and style
|
||||
if (!sessionId.value) return
|
||||
|
||||
loading.value = true
|
||||
currentPhaseLoading.value = 2
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.runPhase2(
|
||||
sessionId.value,
|
||||
phase2ProperNouns.value.map(pn => ({ ...pn, final: pn.final || pn.suggested })),
|
||||
phase2Style.value,
|
||||
)
|
||||
// Update local state from server response
|
||||
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
|
||||
phase2Style.value = res.data.phase2_style || ''
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || e.message || 'Phase 2 확인 실패'
|
||||
} finally {
|
||||
loading.value = false
|
||||
currentPhaseLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function executePhase3() {
|
||||
if (!phase1Result.value) {
|
||||
error.value = '먼저 Phase 1을 실행하세요'
|
||||
return
|
||||
}
|
||||
if (!modelPhase3.value && !modelPhase1.value) {
|
||||
error.value = 'Phase 3 LLM 모델을 선택하세요'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
currentPhaseLoading.value = 3
|
||||
error.value = null
|
||||
activeAbortController.value = new AbortController()
|
||||
|
||||
try {
|
||||
await ensureSession()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
const res = await api.runPhase3(sessionId.value)
|
||||
phase3Result.value = res.data.phase3_result || ''
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || e.message || 'Phase 3 실행 실패'
|
||||
} finally {
|
||||
loading.value = false
|
||||
currentPhaseLoading.value = null
|
||||
activeAbortController.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function executePhase4() {
|
||||
if (!phase3Result.value) {
|
||||
error.value = '먼저 Phase 3을 실행하세요'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
currentPhaseLoading.value = 4
|
||||
error.value = null
|
||||
activeAbortController.value = new AbortController()
|
||||
|
||||
try {
|
||||
await ensureSession()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
const res = await api.runPhase4(sessionId.value)
|
||||
phase4Result.value = res.data.phase4_result || ''
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || e.message || 'Phase 4 실행 실패'
|
||||
} finally {
|
||||
loading.value = false
|
||||
currentPhaseLoading.value = null
|
||||
activeAbortController.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── History (localStorage) ───────────────────────
|
||||
|
||||
function saveToHistory() {
|
||||
const entry: HistoryEntry = {
|
||||
id: sessionId.value || Date.now().toString(),
|
||||
source_preview: sourceText.value.slice(0, 100),
|
||||
target_language: targetLanguage.value,
|
||||
timestamp: Date.now(),
|
||||
data: getSessionData(),
|
||||
}
|
||||
|
||||
// Avoid duplicates
|
||||
const idx = history.value.findIndex(h => h.id === entry.id)
|
||||
if (idx >= 0) {
|
||||
history.value[idx] = entry
|
||||
} else {
|
||||
history.value.unshift(entry)
|
||||
}
|
||||
// Keep max 50 entries
|
||||
if (history.value.length > 50) history.value.pop()
|
||||
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.value))
|
||||
}
|
||||
|
||||
function loadHistory() {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY) || '[]'
|
||||
history.value = JSON.parse(raw) as HistoryEntry[]
|
||||
} catch {
|
||||
history.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreFromHistory(entry: HistoryEntry) {
|
||||
sessionId.value = entry.id
|
||||
sourceText.value = entry.data.source_text
|
||||
sourceLanguage.value = entry.data.source_language
|
||||
targetLanguage.value = entry.data.target_language
|
||||
modelPhase1.value = entry.data.model_phase1
|
||||
modelPhase2.value = entry.data.model_phase2
|
||||
modelPhase3.value = entry.data.model_phase3
|
||||
modelPhase4.value = entry.data.model_phase4
|
||||
phase1Result.value = entry.data.phase1_result
|
||||
phase2ProperNouns.value = entry.data.phase2_proper_nouns || []
|
||||
phase2Style.value = entry.data.phase2_style || ''
|
||||
phase3Result.value = entry.data.phase3_result || ''
|
||||
phase4Result.value = entry.data.phase4_result || ''
|
||||
|
||||
// Save model selections for future sessions
|
||||
saveModelSelections()
|
||||
}
|
||||
|
||||
function getSessionData(): TranslationSessionData {
|
||||
return {
|
||||
source_text: sourceText.value,
|
||||
source_language: sourceLanguage.value,
|
||||
target_language: targetLanguage.value,
|
||||
model_phase1: modelPhase1.value,
|
||||
model_phase2: modelPhase2.value,
|
||||
model_phase3: modelPhase3.value,
|
||||
model_phase4: modelPhase4.value,
|
||||
phase1_result: phase1Result.value || null,
|
||||
phase2_proper_nouns: phase2ProperNouns.value,
|
||||
phase2_style: phase2Style.value,
|
||||
phase3_result: phase3Result.value,
|
||||
phase4_result: phase4Result.value,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Export ────────────────────────────────────────
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text).catch(() => {
|
||||
error.value = '클립보드 복사에 실패했습니다'
|
||||
})
|
||||
}
|
||||
|
||||
function downloadTxt(filename: string, content: string): void {
|
||||
const blob = new Blob([content], { type: 'text/plain; charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function downloadMd(filename: string, content: string): void {
|
||||
// Add simple markdown formatting — frontmatter with metadata
|
||||
const md = `---
|
||||
source_language: ${sourceLanguage.value}
|
||||
target_language: ${target_language.value}
|
||||
timestamp: ${new Date().toISOString()}
|
||||
---\n\n${content}`
|
||||
downloadTxt(filename.replace('.txt', '.md'), md)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
sessionId, sourceText, sourceLanguage, targetLanguage,
|
||||
modelPhase1, modelPhase2, modelPhase3, modelPhase4,
|
||||
phase1Result, phase2ProperNouns, phase2Style,
|
||||
phase3Result, phase4Result,
|
||||
loading, currentPhaseLoading, error, models, history,
|
||||
|
||||
// Computed
|
||||
phase1Done, phase2Ready, phase3Ready, phase4Ready,
|
||||
|
||||
// Actions
|
||||
loadModels, newSession, saveModelSelections, ensureSession, syncToServer,
|
||||
executePhase1, executePhase2, executePhase3, executePhase4,
|
||||
saveToHistory, loadHistory, restoreFromHistory,
|
||||
copyToClipboard, downloadTxt, downloadMd, getSessionData,
|
||||
}
|
||||
})
|
||||
20
frontend/src/style.css
Normal file
20
frontend/src/style.css
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Dark mode body bg transition */
|
||||
html {
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
/* Custom scrollbar (light) */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #f1f5f9; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||
|
||||
.dark ::-webkit-scrollbar-track { background: #1e293b; }
|
||||
.dark ::-webkit-scrollbar-thumb { background: #475569; }
|
||||
|
||||
/* Selection color */
|
||||
::selection { background-color: #93c5fd; }
|
||||
.dark ::selection { background-color: #60a5fa; }
|
||||
49
frontend/src/types.ts
Normal file
49
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/** TypeScript types mirroring the backend models */
|
||||
|
||||
export interface LLMModel {
|
||||
alias: string
|
||||
model: string
|
||||
context_size: number
|
||||
}
|
||||
|
||||
export interface ProperNoun {
|
||||
original: string
|
||||
suggested: string
|
||||
final?: string
|
||||
}
|
||||
|
||||
export interface Phase1Result {
|
||||
translated: string
|
||||
proper_nouns: ProperNoun[]
|
||||
summary: string
|
||||
style: string
|
||||
}
|
||||
|
||||
export interface TranslationSessionData {
|
||||
source_text: string
|
||||
source_language: string
|
||||
target_language: string
|
||||
model_phase1: string
|
||||
model_phase2: string
|
||||
model_phase3: string
|
||||
model_phase4: string
|
||||
phase1_result: Phase1Result | null
|
||||
phase2_proper_nouns: ProperNoun[]
|
||||
phase2_style: string
|
||||
phase3_result: string
|
||||
phase4_result: string
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
session_id: string
|
||||
data: TranslationSessionData
|
||||
}
|
||||
|
||||
/** A saved translation history entry (localStorage) */
|
||||
export interface HistoryEntry {
|
||||
id: string
|
||||
source_preview: string
|
||||
target_language: string
|
||||
timestamp: number
|
||||
data: TranslationSessionData
|
||||
}
|
||||
74
frontend/src/views/LoginView.vue
Normal file
74
frontend/src/views/LoginView.vue
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import * as api from '../api'
|
||||
|
||||
const id = ref('')
|
||||
const password = ref('')
|
||||
const errorMessage = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
if (!id.value || !password.value) return
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await api.login(id.value, password.value)
|
||||
window.location.reload()
|
||||
} catch (e: any) {
|
||||
errorMessage.value = e.response?.data?.detail || '로그인에 실패했습니다'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="w-full max-w-md bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 border border-gray-200 dark:border-gray-700">
|
||||
<!-- Logo / Title -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/40 mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">LLM 번역기</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">4단계 고품질 번역 파이프라인</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||
<div>
|
||||
<label for="id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">ID</label>
|
||||
<input
|
||||
id="id" v-model="id" type="text" autocomplete="username"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="사용자 ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">비밀번호</label>
|
||||
<input
|
||||
id="password" v-model="password" type="password" autocomplete="current-password"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="비밀번호"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading || !id || !password"
|
||||
class="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span v-if="loading">로그인 중...</span>
|
||||
<span v-else>로그인</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
297
frontend/src/views/TranslatorView.vue
Normal file
297
frontend/src/views/TranslatorView.vue
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useTranslationStore } from '../stores/translation'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
import LLMSelector from '../components/LLMSelector.vue'
|
||||
import ProperNounsEditor from '../components/ProperNounsEditor.vue'
|
||||
import ComparisonView from '../components/ComparisonView.vue'
|
||||
|
||||
const store = useTranslationStore()
|
||||
const auth = useAuthStore()
|
||||
const theme = useThemeStore()
|
||||
const showHistorySidebar = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadModels()
|
||||
store.newSession()
|
||||
store.loadHistory()
|
||||
})
|
||||
|
||||
// Language options
|
||||
const languages = [
|
||||
{ value: 'auto', label: '자동 판단' },
|
||||
{ value: '영어', label: '영어' },
|
||||
{ value: '중국어', label: '중국어(간체)' },
|
||||
{ value: '중문(번체)', label: '중국어(번체)' },
|
||||
{ value: '일본어', label: '일본어' },
|
||||
{ value: '프랑스어', label: '프랑스어' },
|
||||
{ value: '독일어', label: '독일어' },
|
||||
{ value: '스페인어', label: '스페인어' },
|
||||
{ value: '러시아어', label: '러시아어' },
|
||||
]
|
||||
|
||||
const targetLanguages = [
|
||||
...languages.filter(l => l.value !== 'auto'),
|
||||
{ value: '한국어', label: '한국어' },
|
||||
]
|
||||
|
||||
function phaseLabel(n: number) {
|
||||
const labels = ['', '초벌번역', '고유명사확인', '재번역', '마무리']
|
||||
return labels[n] || ''
|
||||
}
|
||||
|
||||
async function runPhase(phase: number) {
|
||||
switch (phase) {
|
||||
case 1: await store.executePhase1(); break
|
||||
case 2: await store.executePhase2(); break
|
||||
case 3: await store.executePhase3(); break
|
||||
case 4: await store.executePhase4(); break
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewTranslation() {
|
||||
if (store.phase1Result || store.phase3Result || store.phase4Result) {
|
||||
if (!confirm('현재 번역을 새로운 번역으로 교체하시겠습니까?')) return
|
||||
}
|
||||
store.newSession()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<!-- Top Bar -->
|
||||
<header class="bg-white/90 dark:bg-gray-800/90 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10 shadow-sm backdrop-blur">
|
||||
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-lg font-bold text-gray-800 dark:text-gray-100">LLM 번역기</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button
|
||||
@click="theme.toggle()"
|
||||
class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors flex items-center gap-1.5"
|
||||
:title="theme.isDark ? '라이트모드' : '다크모드'"
|
||||
>
|
||||
{{ theme.isDark ? '☀️' : '🌙' }}
|
||||
</button>
|
||||
|
||||
<!-- History Button -->
|
||||
<button
|
||||
@click="showHistorySidebar = true"
|
||||
class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors flex items-center gap-1.5"
|
||||
title="히스토리"
|
||||
>
|
||||
🕐 히스토리 ({{ store.history.length }})
|
||||
</button>
|
||||
|
||||
<!-- User Info & Logout -->
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ auth.user_id }}</span>
|
||||
<button @click="auth.logout()" class="px-3 py-1.5 text-sm bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 text-red-600 rounded-lg transition-colors">
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<!-- Model Selection Row -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<LLMSelector label="Phase 1 — 초벌번역" v-model:value="store.modelPhase1" :models="store.models" />
|
||||
<LLMSelector label="Phase 2 — 고유명사" v-model:value="store.modelPhase2" :models="store.models" />
|
||||
<LLMSelector label="Phase 3 — 재번역" v-model:value="store.modelPhase3" :models="store.models" />
|
||||
<LLMSelector label="Phase 4 — 마무리" v-model:value="store.modelPhase4" :models="store.models" />
|
||||
|
||||
<!-- Language Selectors -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">출발어</label>
|
||||
<select
|
||||
v-model="store.sourceLanguage"
|
||||
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option v-for="lang in languages" :key="lang.value" :value="lang.value">
|
||||
{{ lang.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end pb-2 text-gray-400">→</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">도착어</label>
|
||||
<select
|
||||
v-model="store.targetLanguage"
|
||||
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option v-for="lang in targetLanguages" :key="lang.value" :value="lang.value">
|
||||
{{ lang.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- New Translation Button -->
|
||||
<button @click="handleNewTranslation()" class="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
||||
🔄 새 번역
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Source Text Input -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-200">📝 원문 입력</h2>
|
||||
<span class="text-xs text-gray-400">{{ store.sourceText.length }}자</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="store.sourceText"
|
||||
rows="10"
|
||||
placeholder="번역할 원문을 여기에 붙여넣으세요..."
|
||||
class="w-full px-4 py-3 border border-gray-200 dark:border-gray-600 rounded-lg text-sm leading-relaxed resize-y bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
<!-- Phase Buttons -->
|
||||
<section class="flex flex-wrap gap-3">
|
||||
<button
|
||||
@click="runPhase(1)"
|
||||
:disabled="store.loading || !store.sourceText.trim() || !store.modelPhase1"
|
||||
class="px-5 py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 1">⏳</span>
|
||||
<span v-else>▶</span>
|
||||
Phase 1 — 초벌번역
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="runPhase(2)"
|
||||
:disabled="store.loading || !store.phase2Ready"
|
||||
class="px-5 py-2.5 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 2">⏳</span>
|
||||
<span v-else>✓</span>
|
||||
Phase 2 — 고유명사 확인
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="runPhase(3)"
|
||||
:disabled="store.loading || !store.phase3Ready"
|
||||
class="px-5 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 3">⏳</span>
|
||||
<span v-else>▶</span>
|
||||
Phase 3 — 재번역
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="runPhase(4)"
|
||||
:disabled="store.loading || !store.phase4Ready"
|
||||
class="px-5 py-2.5 bg-pink-600 text-white rounded-lg font-medium hover:bg-pink-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 4">⏳</span>
|
||||
<span v-else>✨</span>
|
||||
Phase 4 — 마무리 다듬기
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="store.error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
|
||||
⚠️ {{ store.error }}
|
||||
<button @click="store.error = null" class="ml-3 underline hover:no-underline">닫기</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<div v-if="store.loading && !store.error" class="flex items-center gap-2 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 px-4 py-3 rounded-lg text-sm">
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{{ phaseLabel(store.currentPhaseLoading || 0) }} 진행 중...
|
||||
</div>
|
||||
|
||||
<!-- Phase 2: Proper Noun Editor -->
|
||||
<section v-if="store.phase1Done">
|
||||
<ProperNounsEditor
|
||||
:proper_nouns="store.phase2ProperNouns"
|
||||
:style="store.phase2Style"
|
||||
@update:proper_nouns="(v) => store.phase2ProperNouns = v"
|
||||
@update:style="(v) => store.phase2Style = v"
|
||||
/>
|
||||
|
||||
<!-- Phase 1 Summary (read-only display) -->
|
||||
<div v-if="store.phase1Result?.summary" class="mt-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">📋 내용 요약</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">{{ store.phase1Result.summary }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Phase 1 Result Preview -->
|
||||
<div v-if="store.phase1Result?.translated" class="mt-4 bg-white dark:bg-gray-800 rounded-xl border border-blue-200 dark:border-blue-700 p-5">
|
||||
<h4 class="text-sm font-semibold text-blue-700 dark:text-blue-300 mb-2">초벌 번역 결과 미리보기</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">{{ store.phase1Result.translated }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Phase Comparison View -->
|
||||
<ComparisonView />
|
||||
</main>
|
||||
|
||||
<!-- History Sidebar (overlay) -->
|
||||
<Transition name="slide">
|
||||
<div v-if="showHistorySidebar" class="fixed inset-y-0 right-0 w-80 bg-white dark:bg-gray-800 shadow-2xl z-50 p-6 overflow-y-auto border-l border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center justify-between">
|
||||
번역 히스토리
|
||||
<button @click="showHistorySidebar = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">✕</button>
|
||||
</h3>
|
||||
|
||||
<!-- History List -->
|
||||
<div v-if="store.history.length === 0" class="text-sm text-gray-400 italic mt-4">
|
||||
아직 번역 기록이 없습니다.
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-2">
|
||||
<li v-for="(entry, idx) in store.history" :key="idx">
|
||||
<button
|
||||
@click="store.restoreFromHistory(entry); showHistorySidebar = false"
|
||||
class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-100 dark:border-gray-600 transition-colors"
|
||||
>
|
||||
<div class="text-xs text-gray-400 mb-1">
|
||||
{{ new Date(entry.timestamp).toLocaleString('ko-KR') }}
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-700 dark:text-gray-200 line-clamp-2">
|
||||
{{ entry.source_preview || '(빈 원문)' }}
|
||||
</div>
|
||||
<div class="text-xs text-blue-500 mt-1">→ {{ entry.target_language }}</div>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Overlay background for sidebar -->
|
||||
<div v-if="showHistorySidebar" @click="showHistorySidebar = false" class="fixed inset-0 bg-black/20 z-40"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
9
frontend/tailwind.config.js
Normal file
9
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
24
frontend/tsconfig.json
Normal file
24
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"]
|
||||
}
|
||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
})
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal 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.*
|
||||
Loading…
Add table
Add a link
Reference in a new issue