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