Upload 60 files
Browse files- .gitignore +2 -5
- Dockerfile +13 -2
- README.md +37 -67
- app.py +8 -14
- app/__pycache__/main.cpython-311.pyc +0 -0
- app/main.py +43 -4
- app/ui.py +13 -220
- frontend/.gitignore +4 -0
- frontend/dist/assets/index.css +327 -0
- frontend/dist/assets/index.js +276 -0
- frontend/dist/index.html +28 -0
- frontend/index.html +18 -0
- frontend/package.json +22 -0
- frontend/src/App.tsx +216 -0
- frontend/src/api.ts +48 -0
- frontend/src/index.css +327 -0
- frontend/src/main.tsx +10 -0
- frontend/src/vite-env.d.ts +1 -0
- frontend/tsconfig.json +21 -0
- frontend/tsconfig.node.json +20 -0
- frontend/vite.config.ts +17 -0
- requirements.txt +0 -1
.gitignore
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
|
|
| 1 |
__pycache__/
|
| 2 |
*.pyc
|
| 3 |
.venv/
|
| 4 |
-
venv/
|
| 5 |
.env
|
| 6 |
-
.
|
| 7 |
-
plainrewrite_*.txt
|
| 8 |
-
*.egg-info/
|
| 9 |
-
.dist/
|
|
|
|
| 1 |
+
frontend/node_modules/
|
| 2 |
__pycache__/
|
| 3 |
*.pyc
|
| 4 |
.venv/
|
|
|
|
| 5 |
.env
|
| 6 |
+
.DS_Store
|
|
|
|
|
|
|
|
|
Dockerfile
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
FROM python:3.12-slim
|
| 2 |
|
| 3 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
@@ -21,10 +31,11 @@ RUN pip install --upgrade pip && pip install -r requirements.txt \
|
|
| 21 |
|
| 22 |
COPY app ./app
|
| 23 |
COPY README.md .
|
|
|
|
| 24 |
|
| 25 |
EXPOSE 7860
|
| 26 |
|
| 27 |
HEALTHCHECK --interval=20s --timeout=5s --start-period=40s --retries=5 \
|
| 28 |
-
CMD curl -fsS http://127.0.0.1:7860/ || exit 1
|
| 29 |
|
| 30 |
-
CMD ["python", "
|
|
|
|
| 1 |
+
# ---- frontend build ----
|
| 2 |
+
FROM node:22-alpine AS frontend
|
| 3 |
+
WORKDIR /web
|
| 4 |
+
ENV NODE_TLS_REJECT_UNAUTHORIZED=0
|
| 5 |
+
COPY frontend/package.json ./
|
| 6 |
+
RUN npm install
|
| 7 |
+
COPY frontend/ ./
|
| 8 |
+
RUN npm run build
|
| 9 |
+
|
| 10 |
+
# ---- backend ----
|
| 11 |
FROM python:3.12-slim
|
| 12 |
|
| 13 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
|
|
| 31 |
|
| 32 |
COPY app ./app
|
| 33 |
COPY README.md .
|
| 34 |
+
COPY --from=frontend /web/dist ./frontend/dist
|
| 35 |
|
| 36 |
EXPOSE 7860
|
| 37 |
|
| 38 |
HEALTHCHECK --interval=20s --timeout=5s --start-period=40s --retries=5 \
|
| 39 |
+
CMD curl -fsS http://127.0.0.1:7860/health || exit 1
|
| 40 |
|
| 41 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -3,106 +3,76 @@ title: PlainRewrite
|
|
| 3 |
emoji: ✍️
|
| 4 |
colorFrom: gray
|
| 5 |
colorTo: blue
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
python_version: '3.12'
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
# PlainRewrite
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
> **Hardware tip:** Prefer **CPU basic** (free). This app uses **no LLM and no GPU**. Do **not** use ZeroGPU — there is no `@spaces.GPU` workload here.
|
| 18 |
-
|
| 19 |
-
## How it works
|
| 20 |
-
|
| 21 |
-
Classical NLP paraphrase on **any** input (not fixed story regex, not an LLM):
|
| 22 |
-
|
| 23 |
-
1. **Segment** — split paragraphs / sentences
|
| 24 |
-
2. **Scrub** — remove/replace stock AI filler phrases from `app/data/`
|
| 25 |
-
3. **Structure** — length-based clause splits / cautious passive→active when spaCy exists
|
| 26 |
-
4. **Synonyms** — token-level lexicon (`preferred_swaps` / `elevate_swaps`) + WordNet
|
| 27 |
-
5. **Tone** — Neutral (balanced), Casual (conversational), Formal (professional), Academic (scholarly lexicon + discourse frames).
|
| 28 |
-
6. **Similarity assist** (optional) — overlap % vs your reference
|
| 29 |
-
|
| 30 |
-
Lexicons are word lists applied dynamically to whatever tokens appear — not hardcoded full-sentence rewrites.
|
| 31 |
-
|
| 32 |
-
### Rewrite strength
|
| 33 |
-
|
| 34 |
-
| Strength | Behavior |
|
| 35 |
-
|----------|----------|
|
| 36 |
-
| **Light** | Mostly phrase scrub + preferred swaps |
|
| 37 |
-
| **Normal** | Structure tweaks + moderate WordNet |
|
| 38 |
-
| **Heavy** | More synonym and sentence-structure changes |
|
| 39 |
-
|
| 40 |
-
## Fix Runtime error on Hugging Face
|
| 41 |
-
|
| 42 |
-
If the Space shows:
|
| 43 |
-
|
| 44 |
-
**`No @spaces.GPU function detected during startup`**
|
| 45 |
-
|
| 46 |
-
your Space is on **ZeroGPU**. Switch hardware:
|
| 47 |
-
|
| 48 |
-
1. Space **Settings → Hardware → CPU basic** (free)
|
| 49 |
-
2. Optional variable: `GRADIO_SSR_MODE` = `false`
|
| 50 |
-
3. **Factory rebuild**
|
| 51 |
-
|
| 52 |
-
No API secrets are required (no OpenRouter).
|
| 53 |
-
|
| 54 |
-
## Deploy (Gradio)
|
| 55 |
-
|
| 56 |
-
1. Create Space → SDK **Gradio** → hardware **CPU basic**
|
| 57 |
-
2. Upload this project (`app.py`, `app/`, `requirements.txt`, this README)
|
| 58 |
-
3. Optional: `GRADIO_SSR_MODE` = `false`
|
| 59 |
-
4. Factory rebuild
|
| 60 |
-
|
| 61 |
-
`requirements.txt` installs the `en_core_web_sm` wheel so Gradio Spaces get spaCy (not only regex-fallback). Docker builds also download WordNet at image build time.
|
| 62 |
-
|
| 63 |
-
If output equals input on an older deploy: **Factory rebuild** after uploading the latest `app/pipeline/` + `app/data/preferred_swaps.json`. Status under the button shows **Changed** / **Similarity**; Space **Logs** show `[plainrewrite] ...` lines (not Gradio’s chat panel).
|
| 64 |
-
|
| 65 |
-
### Deploy (Docker Space, alternative)
|
| 66 |
-
|
| 67 |
-
1. Create Space → SDK **Docker** → `app_port: 7860`
|
| 68 |
-
2. Upload including `Dockerfile`
|
| 69 |
-
3. Factory rebuild
|
| 70 |
|
| 71 |
## Local run
|
| 72 |
|
|
|
|
|
|
|
| 73 |
```bash
|
| 74 |
pip install -r requirements.txt
|
| 75 |
pip install https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
|
| 76 |
python -c "import nltk; nltk.download('wordnet'); nltk.download('omw-1.4')"
|
| 77 |
-
python app.py
|
| 78 |
```
|
| 79 |
|
| 80 |
-
|
| 81 |
|
| 82 |
```bash
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
```
|
| 85 |
|
| 86 |
Open http://127.0.0.1:7860
|
| 87 |
|
| 88 |
-
###
|
|
|
|
|
|
|
| 89 |
|
| 90 |
```bash
|
| 91 |
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
| 92 |
```
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
- `GET /health`
|
| 95 |
- `POST /v1/rewrite`
|
| 96 |
- `POST /v1/similarity`
|
| 97 |
|
| 98 |
-
##
|
| 99 |
|
| 100 |
```bash
|
| 101 |
docker compose up --build
|
| 102 |
```
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
## Notes
|
| 105 |
|
| 106 |
-
-
|
| 107 |
-
-
|
| 108 |
-
- This is a classical paraphraser — it does **not** guarantee low AI-detector scores. Edit important text by hand.
|
|
|
|
| 3 |
emoji: ✍️
|
| 4 |
colorFrom: gray
|
| 5 |
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
|
|
|
|
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
# PlainRewrite
|
| 12 |
|
| 13 |
+
Rewrite text in Neutral, Casual, Formal, or Academic tone.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
## Local run
|
| 16 |
|
| 17 |
+
### 1. Backend deps
|
| 18 |
+
|
| 19 |
```bash
|
| 20 |
pip install -r requirements.txt
|
| 21 |
pip install https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
|
| 22 |
python -c "import nltk; nltk.download('wordnet'); nltk.download('omw-1.4')"
|
|
|
|
| 23 |
```
|
| 24 |
|
| 25 |
+
### 2. Frontend build
|
| 26 |
|
| 27 |
```bash
|
| 28 |
+
cd frontend
|
| 29 |
+
npm install
|
| 30 |
+
npm run build
|
| 31 |
+
cd ..
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
### 3. Start app
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
python app.py
|
| 38 |
```
|
| 39 |
|
| 40 |
Open http://127.0.0.1:7860
|
| 41 |
|
| 42 |
+
### Frontend dev (hot reload)
|
| 43 |
+
|
| 44 |
+
Terminal A:
|
| 45 |
|
| 46 |
```bash
|
| 47 |
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
| 48 |
```
|
| 49 |
|
| 50 |
+
Terminal B:
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
cd frontend
|
| 54 |
+
npm run dev
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
Open http://127.0.0.1:5173 (proxies API to port 8000).
|
| 58 |
+
|
| 59 |
+
## API
|
| 60 |
+
|
| 61 |
- `GET /health`
|
| 62 |
- `POST /v1/rewrite`
|
| 63 |
- `POST /v1/similarity`
|
| 64 |
|
| 65 |
+
## Docker
|
| 66 |
|
| 67 |
```bash
|
| 68 |
docker compose up --build
|
| 69 |
```
|
| 70 |
|
| 71 |
+
## Deploy (Hugging Face)
|
| 72 |
+
|
| 73 |
+
Use a **Docker** Space (`app_port: 7860`), not Gradio SDK. Prefer **CPU basic**.
|
| 74 |
+
|
| 75 |
## Notes
|
| 76 |
|
| 77 |
+
- Core rewrite engine is classical NLP (spaCy + WordNet + rules) — unchanged by the React UI.
|
| 78 |
+
- Edit important text by hand before publishing.
|
|
|
app.py
CHANGED
|
@@ -1,19 +1,13 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
if __name__ == "__main__":
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
server_port=7860,
|
| 12 |
-
ssr_mode=False,
|
| 13 |
-
css=APP_CSS,
|
| 14 |
-
)
|
| 15 |
-
except TypeError:
|
| 16 |
-
demo.queue(default_concurrency_limit=2).launch(
|
| 17 |
-
server_name="0.0.0.0",
|
| 18 |
-
server_port=7860,
|
| 19 |
-
)
|
|
|
|
| 1 |
+
"""Application entrypoint — serves React UI + rewrite API."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
import uvicorn
|
| 8 |
+
|
| 9 |
|
| 10 |
if __name__ == "__main__":
|
| 11 |
+
host = os.environ.get("HOST", "0.0.0.0")
|
| 12 |
+
port = int(os.environ.get("PORT", "7860") or "7860")
|
| 13 |
+
uvicorn.run("app.main:app", host=host, port=port, reload=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (6.1 kB). View file
|
|
|
app/main.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""FastAPI
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -10,6 +10,9 @@ if str(ROOT) not in sys.path:
|
|
| 10 |
sys.path.insert(0, str(ROOT))
|
| 11 |
|
| 12 |
from fastapi import FastAPI, HTTPException
|
|
|
|
|
|
|
|
|
|
| 13 |
from pydantic import BaseModel, Field
|
| 14 |
|
| 15 |
from app.bootstrap import ensure_resources
|
|
@@ -20,7 +23,17 @@ from app.pipeline.tones import normalize_tone
|
|
| 20 |
|
| 21 |
ensure_resources()
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
class RewriteRequest(BaseModel):
|
|
@@ -42,7 +55,7 @@ def health():
|
|
| 42 |
return {
|
| 43 |
"status": "ok",
|
| 44 |
"app": APP_TITLE,
|
| 45 |
-
"
|
| 46 |
"engines": engines,
|
| 47 |
}
|
| 48 |
|
|
@@ -67,7 +80,8 @@ def api_rewrite(body: RewriteRequest):
|
|
| 67 |
"seconds": result.seconds,
|
| 68 |
"tone": result.tone,
|
| 69 |
"strength": result.strength,
|
| 70 |
-
"
|
|
|
|
| 71 |
},
|
| 72 |
}
|
| 73 |
|
|
@@ -82,3 +96,28 @@ def api_similarity(body: SimilarityRequest):
|
|
| 82 |
"flags": sim.flags,
|
| 83 |
"highlighted_html": sim.highlighted_html,
|
| 84 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI app — API + React static UI."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 10 |
sys.path.insert(0, str(ROOT))
|
| 11 |
|
| 12 |
from fastapi import FastAPI, HTTPException
|
| 13 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
+
from fastapi.responses import FileResponse
|
| 15 |
+
from fastapi.staticfiles import StaticFiles
|
| 16 |
from pydantic import BaseModel, Field
|
| 17 |
|
| 18 |
from app.bootstrap import ensure_resources
|
|
|
|
| 23 |
|
| 24 |
ensure_resources()
|
| 25 |
|
| 26 |
+
STATIC_DIR = ROOT / "frontend" / "dist"
|
| 27 |
+
|
| 28 |
+
app = FastAPI(title=APP_TITLE, version="2.0.0")
|
| 29 |
+
|
| 30 |
+
app.add_middleware(
|
| 31 |
+
CORSMiddleware,
|
| 32 |
+
allow_origins=["*"],
|
| 33 |
+
allow_credentials=True,
|
| 34 |
+
allow_methods=["*"],
|
| 35 |
+
allow_headers=["*"],
|
| 36 |
+
)
|
| 37 |
|
| 38 |
|
| 39 |
class RewriteRequest(BaseModel):
|
|
|
|
| 55 |
return {
|
| 56 |
"status": "ok",
|
| 57 |
"app": APP_TITLE,
|
| 58 |
+
"ui": "react",
|
| 59 |
"engines": engines,
|
| 60 |
}
|
| 61 |
|
|
|
|
| 80 |
"seconds": result.seconds,
|
| 81 |
"tone": result.tone,
|
| 82 |
"strength": result.strength,
|
| 83 |
+
"changed": result.changed,
|
| 84 |
+
"notes": result.notes,
|
| 85 |
},
|
| 86 |
}
|
| 87 |
|
|
|
|
| 96 |
"flags": sim.flags,
|
| 97 |
"highlighted_html": sim.highlighted_html,
|
| 98 |
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _register_frontend() -> None:
|
| 102 |
+
if not STATIC_DIR.is_dir():
|
| 103 |
+
return
|
| 104 |
+
|
| 105 |
+
assets = STATIC_DIR / "assets"
|
| 106 |
+
if assets.is_dir():
|
| 107 |
+
app.mount("/assets", StaticFiles(directory=str(assets)), name="assets")
|
| 108 |
+
|
| 109 |
+
index = STATIC_DIR / "index.html"
|
| 110 |
+
|
| 111 |
+
@app.get("/")
|
| 112 |
+
def serve_index():
|
| 113 |
+
return FileResponse(index)
|
| 114 |
+
|
| 115 |
+
@app.get("/favicon.ico")
|
| 116 |
+
def favicon():
|
| 117 |
+
ico = STATIC_DIR / "favicon.ico"
|
| 118 |
+
if ico.is_file():
|
| 119 |
+
return FileResponse(ico)
|
| 120 |
+
raise HTTPException(status_code=404)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
_register_frontend()
|
app/ui.py
CHANGED
|
@@ -1,227 +1,20 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
import
|
| 6 |
-
import sys
|
| 7 |
-
import tempfile
|
| 8 |
-
from pathlib import Path
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
ensure_resources()
|
| 17 |
-
|
| 18 |
-
import gradio as gr
|
| 19 |
-
|
| 20 |
-
from app.config import APP_TITLE, HOST, MAX_CHARS, PORT
|
| 21 |
-
from app.pipeline.tones import TONE_OPTIONS, normalize_tone
|
| 22 |
-
from app.pipeline.orchestrator import rewrite_text, similarity_check
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
APP_CSS = """
|
| 26 |
-
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=Fraunces:wght@600;700&display=swap');
|
| 27 |
-
|
| 28 |
-
.gradio-container {
|
| 29 |
-
max-width: 1080px !important;
|
| 30 |
-
font-family: "DM Sans", system-ui, sans-serif !important;
|
| 31 |
-
background:
|
| 32 |
-
radial-gradient(900px 400px at 0% 0%, #e4eef5 0%, transparent 55%),
|
| 33 |
-
linear-gradient(180deg, #f6f8fa 0%, #eef2f5 100%) !important;
|
| 34 |
-
}
|
| 35 |
-
.main-title {
|
| 36 |
-
font-family: "Fraunces", Georgia, serif !important;
|
| 37 |
-
font-size: 2.2rem !important;
|
| 38 |
-
margin: 0.2rem 0 !important;
|
| 39 |
-
color: #1a2b36 !important;
|
| 40 |
-
}
|
| 41 |
-
.subtitle { color: #5a6b75; max-width: 40rem; line-height: 1.45; }
|
| 42 |
-
.badge {
|
| 43 |
-
display: inline-block; border: 1px solid #c9d6de; background: #f7fafb;
|
| 44 |
-
padding: 0.25rem 0.65rem; font-size: 0.78rem; color: #5a6b75; margin-right: 0.35rem;
|
| 45 |
-
}
|
| 46 |
-
#go-btn { background: #1f5c6b !important; border: none !important; font-weight: 600 !important; }
|
| 47 |
-
.prose-note { color: #5a6b75; font-size: 0.9rem; margin-top: 0.6rem; }
|
| 48 |
-
"""
|
| 49 |
-
|
| 50 |
-
STRENGTH_MAP = {"Light": 0, "Normal": 1, "Heavy": 2}
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
def do_rewrite(text: str, tone: str, strength_label: str, preserve_length: bool):
|
| 54 |
-
text = (text or "").strip()
|
| 55 |
-
if not text:
|
| 56 |
-
raise gr.Error("Paste some text first.")
|
| 57 |
-
if len(text) > MAX_CHARS:
|
| 58 |
-
raise gr.Error(f"Text too long ({len(text):,} chars). Max is {MAX_CHARS:,}.")
|
| 59 |
-
|
| 60 |
-
result = rewrite_text(
|
| 61 |
-
text,
|
| 62 |
-
tone=normalize_tone(tone),
|
| 63 |
-
strength=STRENGTH_MAP.get(strength_label, 1),
|
| 64 |
-
preserve_length=preserve_length,
|
| 65 |
-
)
|
| 66 |
-
from difflib import SequenceMatcher
|
| 67 |
-
|
| 68 |
-
sim = SequenceMatcher(None, text.lower(), result.text.lower()).ratio()
|
| 69 |
-
status = (
|
| 70 |
-
f"**Engine:** `{result.engine}` (no LLM) · "
|
| 71 |
-
f"**Tone:** {result.tone} · **Strength:** {strength_label} · "
|
| 72 |
-
f"**Words:** {result.input_words:,} → {result.output_words:,} · "
|
| 73 |
-
f"**Changed:** {'yes' if result.changed else 'no'} · "
|
| 74 |
-
f"**Similarity:** {sim:.0%} · "
|
| 75 |
-
f"**Time:** {result.seconds}s"
|
| 76 |
-
)
|
| 77 |
-
if result.notes:
|
| 78 |
-
status += f"\n\n_{result.notes}_"
|
| 79 |
-
if not result.changed:
|
| 80 |
-
status += (
|
| 81 |
-
"\n\n_Output matched input. Try **Heavy** strength, or paste text with "
|
| 82 |
-
"formal words (utilize, facilitate, robust…) or AI-style openers._"
|
| 83 |
-
)
|
| 84 |
-
path = _download(result.text)
|
| 85 |
-
return result.text, status, path, text
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
def do_similarity(rewrite: str, reference: str):
|
| 89 |
-
if not (rewrite or "").strip() or not (reference or "").strip():
|
| 90 |
-
raise gr.Error("Need both rewrite and reference text.")
|
| 91 |
-
sim = similarity_check(rewrite, reference)
|
| 92 |
-
summary = (
|
| 93 |
-
f"**Overlap:** {sim.overlap_pct}% · **Level:** {sim.level} · "
|
| 94 |
-
f"**Longest match:** {sim.longest_match_words} words"
|
| 95 |
-
)
|
| 96 |
-
if sim.flags:
|
| 97 |
-
summary += "\n\n**Flagged phrases:** " + "; ".join(f"`{f}`" for f in sim.flags[:6])
|
| 98 |
-
summary += (
|
| 99 |
-
"\n\n_This measures overlap with your reference only — "
|
| 100 |
-
"not AI-detector scores or web plagiarism._"
|
| 101 |
-
)
|
| 102 |
-
return summary, sim.highlighted_html
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def _download(text: str) -> str | None:
|
| 106 |
-
if not text.strip():
|
| 107 |
-
return None
|
| 108 |
-
fd, path = tempfile.mkstemp(prefix="plainrewrite_", suffix=".txt")
|
| 109 |
-
os.close(fd)
|
| 110 |
-
Path(path).write_text(text, encoding="utf-8")
|
| 111 |
-
return path
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
def build_ui() -> gr.Blocks:
|
| 115 |
-
gradio_major = int(gr.__version__.split(".")[0])
|
| 116 |
-
|
| 117 |
-
with gr.Blocks(title=APP_TITLE) as demo:
|
| 118 |
-
gr.HTML(f"<style>{APP_CSS}</style>")
|
| 119 |
-
gr.HTML(
|
| 120 |
-
f"""
|
| 121 |
-
<div class="main-title">{APP_TITLE}</div>
|
| 122 |
-
<p class="subtitle">
|
| 123 |
-
Offline rewrite with <strong>spaCy + WordNet + rules</strong>.
|
| 124 |
-
No OpenRouter. No generative LLM.
|
| 125 |
-
</p>
|
| 126 |
-
<div>
|
| 127 |
-
<span class="badge">non-LLM</span>
|
| 128 |
-
<span class="badge">Docker-ready</span>
|
| 129 |
-
<span class="badge">similarity assist</span>
|
| 130 |
-
</div>
|
| 131 |
-
"""
|
| 132 |
-
)
|
| 133 |
-
|
| 134 |
-
with gr.Row():
|
| 135 |
-
with gr.Column(scale=5):
|
| 136 |
-
inp = gr.Textbox(
|
| 137 |
-
label="Input text",
|
| 138 |
-
lines=16,
|
| 139 |
-
placeholder="Paste text to rewrite…",
|
| 140 |
-
)
|
| 141 |
-
with gr.Column(scale=3):
|
| 142 |
-
tone = gr.Radio(
|
| 143 |
-
list(TONE_OPTIONS),
|
| 144 |
-
value="Neutral",
|
| 145 |
-
label="Tone",
|
| 146 |
-
info="Neutral = balanced · Casual = conversational · Formal = professional · Academic = scholarly",
|
| 147 |
-
)
|
| 148 |
-
strength = gr.Radio(
|
| 149 |
-
["Light", "Normal", "Heavy"],
|
| 150 |
-
value="Normal",
|
| 151 |
-
label="Rewrite strength",
|
| 152 |
-
info="Heavy = more phrase and structure changes.",
|
| 153 |
-
)
|
| 154 |
-
preserve = gr.Checkbox(
|
| 155 |
-
value=True,
|
| 156 |
-
label="Match original length (don't expand)",
|
| 157 |
-
)
|
| 158 |
-
btn = gr.Button("Rewrite", variant="primary", elem_id="go-btn")
|
| 159 |
-
status = gr.Markdown("Ready.")
|
| 160 |
-
|
| 161 |
-
out_kwargs: dict = {"label": "Rewritten text", "lines": 16}
|
| 162 |
-
if gradio_major >= 6:
|
| 163 |
-
out_kwargs["buttons"] = ["copy"]
|
| 164 |
-
else:
|
| 165 |
-
out_kwargs["show_copy_button"] = True
|
| 166 |
-
out = gr.Textbox(**out_kwargs)
|
| 167 |
-
download = gr.File(label="Download .txt")
|
| 168 |
-
original_store = gr.State("")
|
| 169 |
-
|
| 170 |
-
with gr.Accordion("Similarity assist (optional)", open=False):
|
| 171 |
-
gr.Markdown(
|
| 172 |
-
"Compare rewrite to your original input (or paste another reference)."
|
| 173 |
-
)
|
| 174 |
-
ref = gr.Textbox(
|
| 175 |
-
label="Reference text",
|
| 176 |
-
lines=8,
|
| 177 |
-
placeholder="Defaults to your input after Rewrite; or paste sources here.",
|
| 178 |
-
)
|
| 179 |
-
sim_btn = gr.Button("Run similarity check")
|
| 180 |
-
sim_md = gr.Markdown()
|
| 181 |
-
sim_html = gr.HTML()
|
| 182 |
-
|
| 183 |
-
gr.HTML(
|
| 184 |
-
"""
|
| 185 |
-
<p class="prose-note">
|
| 186 |
-
PlainRewrite changes wording and structure with classical NLP only.
|
| 187 |
-
It is not an LLM humanizer and does not guarantee AI-detector scores.
|
| 188 |
-
Edit important text by hand after rewriting.
|
| 189 |
-
</p>
|
| 190 |
-
"""
|
| 191 |
-
)
|
| 192 |
-
|
| 193 |
-
def _on_rewrite(text, tone_v, strength_v, preserve_v):
|
| 194 |
-
rewritten, st, path, original = do_rewrite(
|
| 195 |
-
text, tone_v, strength_v, preserve_v
|
| 196 |
-
)
|
| 197 |
-
return rewritten, st, path, original, original
|
| 198 |
-
|
| 199 |
-
btn.click(
|
| 200 |
-
_on_rewrite,
|
| 201 |
-
inputs=[inp, tone, strength, preserve],
|
| 202 |
-
outputs=[out, status, download, original_store, ref],
|
| 203 |
-
)
|
| 204 |
-
sim_btn.click(
|
| 205 |
-
do_similarity,
|
| 206 |
-
inputs=[out, ref],
|
| 207 |
-
outputs=[sim_md, sim_html],
|
| 208 |
-
)
|
| 209 |
-
|
| 210 |
-
return demo
|
| 211 |
|
|
|
|
|
|
|
| 212 |
|
| 213 |
-
|
| 214 |
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
"server_port": PORT,
|
| 219 |
-
}
|
| 220 |
-
try:
|
| 221 |
-
demo.queue(default_concurrency_limit=2).launch(
|
| 222 |
-
**launch_kwargs,
|
| 223 |
-
ssr_mode=False,
|
| 224 |
-
css=APP_CSS,
|
| 225 |
-
)
|
| 226 |
-
except TypeError:
|
| 227 |
-
demo.queue(default_concurrency_limit=2).launch(**launch_kwargs)
|
|
|
|
| 1 |
+
"""Deprecated Gradio UI — use React frontend + `python app.py` / uvicorn instead."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
warnings.warn(
|
| 8 |
+
"app.ui (Gradio) is deprecated. Run `python app.py` to serve the React UI + API.",
|
| 9 |
+
DeprecationWarning,
|
| 10 |
+
stacklevel=2,
|
| 11 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
if __name__ == "__main__":
|
| 14 |
+
import os
|
| 15 |
|
| 16 |
+
import uvicorn
|
| 17 |
|
| 18 |
+
host = os.environ.get("HOST", "0.0.0.0")
|
| 19 |
+
port = int(os.environ.get("PORT", "7860") or "7860")
|
| 20 |
+
uvicorn.run("app.main:app", host=host, port=port, reload=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
dist
|
| 3 |
+
.DS_Store
|
| 4 |
+
*.local
|
frontend/dist/assets/index.css
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--ink: #14262f;
|
| 3 |
+
--ink-soft: #3d5561;
|
| 4 |
+
--muted: #6a7f8a;
|
| 5 |
+
--paper: #f3f6f8;
|
| 6 |
+
--panel: rgba(255, 255, 255, 0.72);
|
| 7 |
+
--line: rgba(20, 38, 47, 0.12);
|
| 8 |
+
--accent: #1f6f7a;
|
| 9 |
+
--accent-deep: #15545c;
|
| 10 |
+
--accent-glow: rgba(31, 111, 122, 0.18);
|
| 11 |
+
--ok: #2a7a5c;
|
| 12 |
+
--danger: #a33b3b;
|
| 13 |
+
--radius: 18px;
|
| 14 |
+
--font-display: "Fraunces", Georgia, serif;
|
| 15 |
+
--font-body: "Sora", system-ui, sans-serif;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
* {
|
| 19 |
+
box-sizing: border-box;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
html,
|
| 23 |
+
body,
|
| 24 |
+
#root {
|
| 25 |
+
min-height: 100%;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
body {
|
| 29 |
+
margin: 0;
|
| 30 |
+
color: var(--ink);
|
| 31 |
+
font-family: var(--font-body);
|
| 32 |
+
background:
|
| 33 |
+
radial-gradient(1000px 520px at 8% -10%, #d7ecef 0%, transparent 55%),
|
| 34 |
+
radial-gradient(900px 480px at 100% 0%, #e7eef3 0%, transparent 50%),
|
| 35 |
+
linear-gradient(180deg, #f7fafb 0%, #eef3f5 48%, #e8eef1 100%);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
button,
|
| 39 |
+
textarea {
|
| 40 |
+
font: inherit;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.app {
|
| 44 |
+
width: min(1180px, calc(100% - 2rem));
|
| 45 |
+
margin: 0 auto;
|
| 46 |
+
padding: 1.75rem 0 3rem;
|
| 47 |
+
animation: rise 0.55s ease both;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
@keyframes rise {
|
| 51 |
+
from {
|
| 52 |
+
opacity: 0;
|
| 53 |
+
transform: translateY(10px);
|
| 54 |
+
}
|
| 55 |
+
to {
|
| 56 |
+
opacity: 1;
|
| 57 |
+
transform: translateY(0);
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
@keyframes pulse {
|
| 62 |
+
0%,
|
| 63 |
+
100% {
|
| 64 |
+
opacity: 1;
|
| 65 |
+
}
|
| 66 |
+
50% {
|
| 67 |
+
opacity: 0.55;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.brand {
|
| 72 |
+
display: flex;
|
| 73 |
+
flex-direction: column;
|
| 74 |
+
gap: 0.45rem;
|
| 75 |
+
margin-bottom: 1.5rem;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.brand h1 {
|
| 79 |
+
margin: 0;
|
| 80 |
+
font-family: var(--font-display);
|
| 81 |
+
font-size: clamp(2.2rem, 4vw, 3.1rem);
|
| 82 |
+
font-weight: 700;
|
| 83 |
+
letter-spacing: -0.02em;
|
| 84 |
+
line-height: 1.05;
|
| 85 |
+
color: var(--ink);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.brand p {
|
| 89 |
+
margin: 0;
|
| 90 |
+
max-width: 34rem;
|
| 91 |
+
color: var(--ink-soft);
|
| 92 |
+
font-size: 1.02rem;
|
| 93 |
+
line-height: 1.5;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.workspace {
|
| 97 |
+
display: grid;
|
| 98 |
+
grid-template-columns: 1.15fr 0.85fr;
|
| 99 |
+
gap: 1.1rem;
|
| 100 |
+
align-items: start;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
@media (max-width: 900px) {
|
| 104 |
+
.workspace {
|
| 105 |
+
grid-template-columns: 1fr;
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.editors {
|
| 110 |
+
display: grid;
|
| 111 |
+
grid-template-columns: 1fr 1fr;
|
| 112 |
+
gap: 0.9rem;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
@media (max-width: 720px) {
|
| 116 |
+
.editors {
|
| 117 |
+
grid-template-columns: 1fr;
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.pane {
|
| 122 |
+
display: flex;
|
| 123 |
+
flex-direction: column;
|
| 124 |
+
gap: 0.55rem;
|
| 125 |
+
min-height: 420px;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.pane-head {
|
| 129 |
+
display: flex;
|
| 130 |
+
align-items: center;
|
| 131 |
+
justify-content: space-between;
|
| 132 |
+
gap: 0.75rem;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.pane-head h2 {
|
| 136 |
+
margin: 0;
|
| 137 |
+
font-size: 0.78rem;
|
| 138 |
+
font-weight: 600;
|
| 139 |
+
letter-spacing: 0.08em;
|
| 140 |
+
text-transform: uppercase;
|
| 141 |
+
color: var(--muted);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.pane textarea {
|
| 145 |
+
flex: 1;
|
| 146 |
+
width: 100%;
|
| 147 |
+
min-height: 380px;
|
| 148 |
+
resize: vertical;
|
| 149 |
+
border: 1px solid var(--line);
|
| 150 |
+
border-radius: var(--radius);
|
| 151 |
+
padding: 1rem 1.05rem;
|
| 152 |
+
background: var(--panel);
|
| 153 |
+
backdrop-filter: blur(10px);
|
| 154 |
+
color: var(--ink);
|
| 155 |
+
line-height: 1.55;
|
| 156 |
+
outline: none;
|
| 157 |
+
transition:
|
| 158 |
+
border-color 0.2s ease,
|
| 159 |
+
box-shadow 0.2s ease,
|
| 160 |
+
transform 0.25s ease;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.pane textarea:focus {
|
| 164 |
+
border-color: rgba(31, 111, 122, 0.45);
|
| 165 |
+
box-shadow: 0 0 0 4px var(--accent-glow);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.pane textarea::placeholder {
|
| 169 |
+
color: #8aa0ab;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.controls {
|
| 173 |
+
position: sticky;
|
| 174 |
+
top: 1rem;
|
| 175 |
+
display: flex;
|
| 176 |
+
flex-direction: column;
|
| 177 |
+
gap: 1.15rem;
|
| 178 |
+
padding: 1.15rem 1.2rem 1.25rem;
|
| 179 |
+
border: 1px solid var(--line);
|
| 180 |
+
border-radius: calc(var(--radius) + 4px);
|
| 181 |
+
background:
|
| 182 |
+
linear-gradient(165deg, rgba(255, 255, 255, 0.88), rgba(243, 249, 250, 0.75));
|
| 183 |
+
backdrop-filter: blur(12px);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.field {
|
| 187 |
+
display: flex;
|
| 188 |
+
flex-direction: column;
|
| 189 |
+
gap: 0.55rem;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.field label {
|
| 193 |
+
font-size: 0.78rem;
|
| 194 |
+
font-weight: 600;
|
| 195 |
+
letter-spacing: 0.06em;
|
| 196 |
+
text-transform: uppercase;
|
| 197 |
+
color: var(--muted);
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.chips {
|
| 201 |
+
display: flex;
|
| 202 |
+
flex-wrap: wrap;
|
| 203 |
+
gap: 0.45rem;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.chip {
|
| 207 |
+
border: 1px solid var(--line);
|
| 208 |
+
background: rgba(255, 255, 255, 0.7);
|
| 209 |
+
color: var(--ink-soft);
|
| 210 |
+
border-radius: 999px;
|
| 211 |
+
padding: 0.42rem 0.8rem;
|
| 212 |
+
font-size: 0.88rem;
|
| 213 |
+
font-weight: 500;
|
| 214 |
+
cursor: pointer;
|
| 215 |
+
transition:
|
| 216 |
+
background 0.2s ease,
|
| 217 |
+
color 0.2s ease,
|
| 218 |
+
border-color 0.2s ease,
|
| 219 |
+
transform 0.15s ease;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.chip:hover {
|
| 223 |
+
transform: translateY(-1px);
|
| 224 |
+
border-color: rgba(31, 111, 122, 0.35);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.chip.active {
|
| 228 |
+
background: var(--accent);
|
| 229 |
+
border-color: var(--accent);
|
| 230 |
+
color: #fff;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.toggle {
|
| 234 |
+
display: flex;
|
| 235 |
+
align-items: center;
|
| 236 |
+
gap: 0.65rem;
|
| 237 |
+
color: var(--ink-soft);
|
| 238 |
+
font-size: 0.92rem;
|
| 239 |
+
cursor: pointer;
|
| 240 |
+
user-select: none;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.toggle input {
|
| 244 |
+
width: 1.05rem;
|
| 245 |
+
height: 1.05rem;
|
| 246 |
+
accent-color: var(--accent);
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.actions {
|
| 250 |
+
display: flex;
|
| 251 |
+
flex-direction: column;
|
| 252 |
+
gap: 0.55rem;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.btn {
|
| 256 |
+
border: none;
|
| 257 |
+
border-radius: 12px;
|
| 258 |
+
padding: 0.85rem 1rem;
|
| 259 |
+
font-weight: 600;
|
| 260 |
+
cursor: pointer;
|
| 261 |
+
transition:
|
| 262 |
+
background 0.2s ease,
|
| 263 |
+
transform 0.15s ease,
|
| 264 |
+
opacity 0.2s ease;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.btn:disabled {
|
| 268 |
+
opacity: 0.55;
|
| 269 |
+
cursor: not-allowed;
|
| 270 |
+
transform: none;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.btn-primary {
|
| 274 |
+
background: linear-gradient(180deg, #24828e 0%, var(--accent-deep) 100%);
|
| 275 |
+
color: #fff;
|
| 276 |
+
box-shadow: 0 10px 24px rgba(21, 84, 92, 0.22);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.btn-primary:not(:disabled):hover {
|
| 280 |
+
transform: translateY(-1px);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.btn-ghost {
|
| 284 |
+
background: transparent;
|
| 285 |
+
border: 1px solid var(--line);
|
| 286 |
+
color: var(--ink-soft);
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.btn-ghost:not(:disabled):hover {
|
| 290 |
+
background: rgba(255, 255, 255, 0.7);
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.meta {
|
| 294 |
+
min-height: 1.3rem;
|
| 295 |
+
font-size: 0.88rem;
|
| 296 |
+
color: var(--muted);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.meta.error {
|
| 300 |
+
color: var(--danger);
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.meta.loading {
|
| 304 |
+
animation: pulse 1.1s ease infinite;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.icon-btn {
|
| 308 |
+
border: 1px solid var(--line);
|
| 309 |
+
background: rgba(255, 255, 255, 0.75);
|
| 310 |
+
color: var(--ink-soft);
|
| 311 |
+
border-radius: 10px;
|
| 312 |
+
padding: 0.35rem 0.65rem;
|
| 313 |
+
font-size: 0.8rem;
|
| 314 |
+
font-weight: 600;
|
| 315 |
+
cursor: pointer;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.icon-btn:disabled {
|
| 319 |
+
opacity: 0.45;
|
| 320 |
+
cursor: not-allowed;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.footer-note {
|
| 324 |
+
margin-top: 1.4rem;
|
| 325 |
+
color: var(--muted);
|
| 326 |
+
font-size: 0.85rem;
|
| 327 |
+
}
|
frontend/dist/assets/index.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from "react";
|
| 2 |
+
import { createRoot } from "react-dom/client";
|
| 3 |
+
|
| 4 |
+
const { createElement: h } = React;
|
| 5 |
+
|
| 6 |
+
const TONES = ["Neutral", "Casual", "Formal", "Academic"];
|
| 7 |
+
const STRENGTHS = ["Light", "Normal", "Heavy"];
|
| 8 |
+
const STRENGTH_MAP = { Light: 0, Normal: 1, Heavy: 2 };
|
| 9 |
+
const MAX_CHARS = 50000;
|
| 10 |
+
|
| 11 |
+
async function rewriteText(payload) {
|
| 12 |
+
const res = await fetch("/v1/rewrite", {
|
| 13 |
+
method: "POST",
|
| 14 |
+
headers: { "Content-Type": "application/json" },
|
| 15 |
+
body: JSON.stringify(payload),
|
| 16 |
+
});
|
| 17 |
+
if (!res.ok) {
|
| 18 |
+
let detail = "Rewrite failed.";
|
| 19 |
+
try {
|
| 20 |
+
const data = await res.json();
|
| 21 |
+
detail = data.detail || detail;
|
| 22 |
+
} catch {
|
| 23 |
+
/* ignore */
|
| 24 |
+
}
|
| 25 |
+
throw new Error(typeof detail === "string" ? detail : "Rewrite failed.");
|
| 26 |
+
}
|
| 27 |
+
return res.json();
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function Chip({ active, label, onClick }) {
|
| 31 |
+
return h(
|
| 32 |
+
"button",
|
| 33 |
+
{
|
| 34 |
+
type: "button",
|
| 35 |
+
className: `chip${active ? " active" : ""}`,
|
| 36 |
+
onClick,
|
| 37 |
+
},
|
| 38 |
+
label,
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function App() {
|
| 43 |
+
const [input, setInput] = useState("");
|
| 44 |
+
const [output, setOutput] = useState("");
|
| 45 |
+
const [tone, setTone] = useState("Neutral");
|
| 46 |
+
const [strength, setStrength] = useState("Normal");
|
| 47 |
+
const [preserveLength, setPreserveLength] = useState(true);
|
| 48 |
+
const [loading, setLoading] = useState(false);
|
| 49 |
+
const [error, setError] = useState("");
|
| 50 |
+
const [meta, setMeta] = useState("");
|
| 51 |
+
const [copied, setCopied] = useState(false);
|
| 52 |
+
|
| 53 |
+
useEffect(() => {
|
| 54 |
+
if (!copied) return undefined;
|
| 55 |
+
const t = window.setTimeout(() => setCopied(false), 1600);
|
| 56 |
+
return () => window.clearTimeout(t);
|
| 57 |
+
}, [copied]);
|
| 58 |
+
|
| 59 |
+
async function onRewrite() {
|
| 60 |
+
const text = input.trim();
|
| 61 |
+
if (!text) {
|
| 62 |
+
setError("Paste some text first.");
|
| 63 |
+
return;
|
| 64 |
+
}
|
| 65 |
+
if (text.length > MAX_CHARS) {
|
| 66 |
+
setError(`Text is too long (${text.length.toLocaleString()} chars).`);
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
setLoading(true);
|
| 71 |
+
setError("");
|
| 72 |
+
setMeta("Rewriting…");
|
| 73 |
+
try {
|
| 74 |
+
const result = await rewriteText({
|
| 75 |
+
text,
|
| 76 |
+
tone,
|
| 77 |
+
strength: STRENGTH_MAP[strength],
|
| 78 |
+
preserve_length: preserveLength,
|
| 79 |
+
});
|
| 80 |
+
setOutput(result.rewrite);
|
| 81 |
+
setMeta(
|
| 82 |
+
`${result.meta.input_words.toLocaleString()} → ${result.meta.output_words.toLocaleString()} words · ${result.meta.seconds}s · ${result.meta.tone}`,
|
| 83 |
+
);
|
| 84 |
+
} catch (err) {
|
| 85 |
+
setError(err instanceof Error ? err.message : "Rewrite failed.");
|
| 86 |
+
setMeta("");
|
| 87 |
+
} finally {
|
| 88 |
+
setLoading(false);
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
async function onCopy() {
|
| 93 |
+
if (!output.trim()) return;
|
| 94 |
+
try {
|
| 95 |
+
await navigator.clipboard.writeText(output);
|
| 96 |
+
setCopied(true);
|
| 97 |
+
} catch {
|
| 98 |
+
setError("Could not copy to clipboard.");
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function onDownload() {
|
| 103 |
+
if (!output.trim()) return;
|
| 104 |
+
const blob = new Blob([output], { type: "text/plain;charset=utf-8" });
|
| 105 |
+
const url = URL.createObjectURL(blob);
|
| 106 |
+
const a = document.createElement("a");
|
| 107 |
+
a.href = url;
|
| 108 |
+
a.download = "plainrewrite.txt";
|
| 109 |
+
a.click();
|
| 110 |
+
URL.revokeObjectURL(url);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
return h(
|
| 114 |
+
"div",
|
| 115 |
+
{ className: "app" },
|
| 116 |
+
h(
|
| 117 |
+
"header",
|
| 118 |
+
{ className: "brand" },
|
| 119 |
+
h("h1", null, "PlainRewrite"),
|
| 120 |
+
h(
|
| 121 |
+
"p",
|
| 122 |
+
null,
|
| 123 |
+
"Rewrite any text in your voice — balanced, casual, formal, or academic.",
|
| 124 |
+
),
|
| 125 |
+
),
|
| 126 |
+
h(
|
| 127 |
+
"div",
|
| 128 |
+
{ className: "workspace" },
|
| 129 |
+
h(
|
| 130 |
+
"section",
|
| 131 |
+
{ className: "editors" },
|
| 132 |
+
h(
|
| 133 |
+
"div",
|
| 134 |
+
{ className: "pane" },
|
| 135 |
+
h("div", { className: "pane-head" }, h("h2", null, "Original")),
|
| 136 |
+
h("textarea", {
|
| 137 |
+
value: input,
|
| 138 |
+
onChange: (e) => setInput(e.target.value),
|
| 139 |
+
placeholder: "Paste your text…",
|
| 140 |
+
spellCheck: true,
|
| 141 |
+
}),
|
| 142 |
+
),
|
| 143 |
+
h(
|
| 144 |
+
"div",
|
| 145 |
+
{ className: "pane" },
|
| 146 |
+
h(
|
| 147 |
+
"div",
|
| 148 |
+
{ className: "pane-head" },
|
| 149 |
+
h("h2", null, "Rewrite"),
|
| 150 |
+
h(
|
| 151 |
+
"div",
|
| 152 |
+
{ style: { display: "flex", gap: "0.4rem" } },
|
| 153 |
+
h(
|
| 154 |
+
"button",
|
| 155 |
+
{
|
| 156 |
+
type: "button",
|
| 157 |
+
className: "icon-btn",
|
| 158 |
+
onClick: onCopy,
|
| 159 |
+
disabled: !output.trim(),
|
| 160 |
+
},
|
| 161 |
+
copied ? "Copied" : "Copy",
|
| 162 |
+
),
|
| 163 |
+
h(
|
| 164 |
+
"button",
|
| 165 |
+
{
|
| 166 |
+
type: "button",
|
| 167 |
+
className: "icon-btn",
|
| 168 |
+
onClick: onDownload,
|
| 169 |
+
disabled: !output.trim(),
|
| 170 |
+
},
|
| 171 |
+
"Download",
|
| 172 |
+
),
|
| 173 |
+
),
|
| 174 |
+
),
|
| 175 |
+
h("textarea", {
|
| 176 |
+
value: output,
|
| 177 |
+
onChange: (e) => setOutput(e.target.value),
|
| 178 |
+
placeholder: "Your rewrite will appear here…",
|
| 179 |
+
spellCheck: true,
|
| 180 |
+
}),
|
| 181 |
+
),
|
| 182 |
+
),
|
| 183 |
+
h(
|
| 184 |
+
"aside",
|
| 185 |
+
{ className: "controls" },
|
| 186 |
+
h(
|
| 187 |
+
"div",
|
| 188 |
+
{ className: "field" },
|
| 189 |
+
h("label", null, "Tone"),
|
| 190 |
+
h(
|
| 191 |
+
"div",
|
| 192 |
+
{ className: "chips" },
|
| 193 |
+
...TONES.map((t) =>
|
| 194 |
+
h(Chip, {
|
| 195 |
+
key: t,
|
| 196 |
+
label: t,
|
| 197 |
+
active: tone === t,
|
| 198 |
+
onClick: () => setTone(t),
|
| 199 |
+
}),
|
| 200 |
+
),
|
| 201 |
+
),
|
| 202 |
+
),
|
| 203 |
+
h(
|
| 204 |
+
"div",
|
| 205 |
+
{ className: "field" },
|
| 206 |
+
h("label", null, "Strength"),
|
| 207 |
+
h(
|
| 208 |
+
"div",
|
| 209 |
+
{ className: "chips" },
|
| 210 |
+
...STRENGTHS.map((s) =>
|
| 211 |
+
h(Chip, {
|
| 212 |
+
key: s,
|
| 213 |
+
label: s,
|
| 214 |
+
active: strength === s,
|
| 215 |
+
onClick: () => setStrength(s),
|
| 216 |
+
}),
|
| 217 |
+
),
|
| 218 |
+
),
|
| 219 |
+
),
|
| 220 |
+
h(
|
| 221 |
+
"label",
|
| 222 |
+
{ className: "toggle" },
|
| 223 |
+
h("input", {
|
| 224 |
+
type: "checkbox",
|
| 225 |
+
checked: preserveLength,
|
| 226 |
+
onChange: (e) => setPreserveLength(e.target.checked),
|
| 227 |
+
}),
|
| 228 |
+
" Match original length",
|
| 229 |
+
),
|
| 230 |
+
h(
|
| 231 |
+
"div",
|
| 232 |
+
{ className: "actions" },
|
| 233 |
+
h(
|
| 234 |
+
"button",
|
| 235 |
+
{
|
| 236 |
+
type: "button",
|
| 237 |
+
className: "btn btn-primary",
|
| 238 |
+
onClick: onRewrite,
|
| 239 |
+
disabled: loading,
|
| 240 |
+
},
|
| 241 |
+
loading ? "Rewriting…" : "Rewrite",
|
| 242 |
+
),
|
| 243 |
+
h(
|
| 244 |
+
"button",
|
| 245 |
+
{
|
| 246 |
+
type: "button",
|
| 247 |
+
className: "btn btn-ghost",
|
| 248 |
+
onClick: () => {
|
| 249 |
+
setInput("");
|
| 250 |
+
setOutput("");
|
| 251 |
+
setMeta("");
|
| 252 |
+
setError("");
|
| 253 |
+
},
|
| 254 |
+
disabled: loading,
|
| 255 |
+
},
|
| 256 |
+
"Clear",
|
| 257 |
+
),
|
| 258 |
+
),
|
| 259 |
+
h(
|
| 260 |
+
"div",
|
| 261 |
+
{
|
| 262 |
+
className: `meta${error ? " error" : ""}${loading ? " loading" : ""}`,
|
| 263 |
+
},
|
| 264 |
+
error || meta || "Ready when you are.",
|
| 265 |
+
),
|
| 266 |
+
),
|
| 267 |
+
),
|
| 268 |
+
h(
|
| 269 |
+
"p",
|
| 270 |
+
{ className: "footer-note" },
|
| 271 |
+
"Edit important text before you publish it.",
|
| 272 |
+
),
|
| 273 |
+
);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
createRoot(document.getElementById("root")).render(h(App));
|
frontend/dist/index.html
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>PlainRewrite</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Sora:wght@400;500;600;700&display=swap"
|
| 11 |
+
rel="stylesheet"
|
| 12 |
+
/>
|
| 13 |
+
<link rel="stylesheet" href="/assets/index.css" />
|
| 14 |
+
<script type="importmap">
|
| 15 |
+
{
|
| 16 |
+
"imports": {
|
| 17 |
+
"react": "https://esm.sh/react@19.1.0",
|
| 18 |
+
"react/jsx-runtime": "https://esm.sh/react@19.1.0/jsx-runtime",
|
| 19 |
+
"react-dom/client": "https://esm.sh/react-dom@19.1.0/client"
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
</script>
|
| 23 |
+
</head>
|
| 24 |
+
<body>
|
| 25 |
+
<div id="root"></div>
|
| 26 |
+
<script type="module" src="/assets/index.js"></script>
|
| 27 |
+
</body>
|
| 28 |
+
</html>
|
frontend/index.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>PlainRewrite</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Sora:wght@400;500;600;700&display=swap"
|
| 11 |
+
rel="stylesheet"
|
| 12 |
+
/>
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div id="root"></div>
|
| 16 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 17 |
+
</body>
|
| 18 |
+
</html>
|
frontend/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "plainrewrite-ui",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"react": "^19.0.0",
|
| 13 |
+
"react-dom": "^19.0.0"
|
| 14 |
+
},
|
| 15 |
+
"devDependencies": {
|
| 16 |
+
"@types/react": "^19.0.0",
|
| 17 |
+
"@types/react-dom": "^19.0.0",
|
| 18 |
+
"@vitejs/plugin-react": "^4.3.4",
|
| 19 |
+
"typescript": "~5.7.2",
|
| 20 |
+
"vite": "^6.0.0"
|
| 21 |
+
}
|
| 22 |
+
}
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react";
|
| 2 |
+
import {
|
| 3 |
+
rewriteText,
|
| 4 |
+
STRENGTH_MAP,
|
| 5 |
+
STRENGTHS,
|
| 6 |
+
TONES,
|
| 7 |
+
type StrengthLabel,
|
| 8 |
+
type Tone,
|
| 9 |
+
} from "./api";
|
| 10 |
+
|
| 11 |
+
const MAX_CHARS = 50000;
|
| 12 |
+
|
| 13 |
+
export default function App() {
|
| 14 |
+
const [input, setInput] = useState("");
|
| 15 |
+
const [output, setOutput] = useState("");
|
| 16 |
+
const [tone, setTone] = useState<Tone>("Neutral");
|
| 17 |
+
const [strength, setStrength] = useState<StrengthLabel>("Normal");
|
| 18 |
+
const [preserveLength, setPreserveLength] = useState(true);
|
| 19 |
+
const [loading, setLoading] = useState(false);
|
| 20 |
+
const [error, setError] = useState("");
|
| 21 |
+
const [meta, setMeta] = useState("");
|
| 22 |
+
const [copied, setCopied] = useState(false);
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
if (!copied) return;
|
| 26 |
+
const t = window.setTimeout(() => setCopied(false), 1600);
|
| 27 |
+
return () => window.clearTimeout(t);
|
| 28 |
+
}, [copied]);
|
| 29 |
+
|
| 30 |
+
async function onRewrite() {
|
| 31 |
+
const text = input.trim();
|
| 32 |
+
if (!text) {
|
| 33 |
+
setError("Paste some text first.");
|
| 34 |
+
return;
|
| 35 |
+
}
|
| 36 |
+
if (text.length > MAX_CHARS) {
|
| 37 |
+
setError(`Text is too long (${text.length.toLocaleString()} chars).`);
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
setLoading(true);
|
| 42 |
+
setError("");
|
| 43 |
+
setMeta("Rewriting…");
|
| 44 |
+
try {
|
| 45 |
+
const result = await rewriteText({
|
| 46 |
+
text,
|
| 47 |
+
tone,
|
| 48 |
+
strength: STRENGTH_MAP[strength],
|
| 49 |
+
preserve_length: preserveLength,
|
| 50 |
+
});
|
| 51 |
+
setOutput(result.rewrite);
|
| 52 |
+
setMeta(
|
| 53 |
+
`${result.meta.input_words.toLocaleString()} → ${result.meta.output_words.toLocaleString()} words · ${result.meta.seconds}s · ${result.meta.tone}`,
|
| 54 |
+
);
|
| 55 |
+
} catch (err) {
|
| 56 |
+
setError(err instanceof Error ? err.message : "Rewrite failed.");
|
| 57 |
+
setMeta("");
|
| 58 |
+
} finally {
|
| 59 |
+
setLoading(false);
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
async function onCopy() {
|
| 64 |
+
if (!output.trim()) return;
|
| 65 |
+
try {
|
| 66 |
+
await navigator.clipboard.writeText(output);
|
| 67 |
+
setCopied(true);
|
| 68 |
+
} catch {
|
| 69 |
+
setError("Could not copy to clipboard.");
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function onDownload() {
|
| 74 |
+
if (!output.trim()) return;
|
| 75 |
+
const blob = new Blob([output], { type: "text/plain;charset=utf-8" });
|
| 76 |
+
const url = URL.createObjectURL(blob);
|
| 77 |
+
const a = document.createElement("a");
|
| 78 |
+
a.href = url;
|
| 79 |
+
a.download = "plainrewrite.txt";
|
| 80 |
+
a.click();
|
| 81 |
+
URL.revokeObjectURL(url);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
return (
|
| 85 |
+
<div className="app">
|
| 86 |
+
<header className="brand">
|
| 87 |
+
<h1>PlainRewrite</h1>
|
| 88 |
+
<p>
|
| 89 |
+
Rewrite any text in your voice — balanced, casual, formal, or
|
| 90 |
+
academic.
|
| 91 |
+
</p>
|
| 92 |
+
</header>
|
| 93 |
+
|
| 94 |
+
<div className="workspace">
|
| 95 |
+
<section className="editors">
|
| 96 |
+
<div className="pane">
|
| 97 |
+
<div className="pane-head">
|
| 98 |
+
<h2>Original</h2>
|
| 99 |
+
</div>
|
| 100 |
+
<textarea
|
| 101 |
+
value={input}
|
| 102 |
+
onChange={(e) => setInput(e.target.value)}
|
| 103 |
+
placeholder="Paste your text…"
|
| 104 |
+
spellCheck
|
| 105 |
+
/>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<div className="pane">
|
| 109 |
+
<div className="pane-head">
|
| 110 |
+
<h2>Rewrite</h2>
|
| 111 |
+
<div style={{ display: "flex", gap: "0.4rem" }}>
|
| 112 |
+
<button
|
| 113 |
+
type="button"
|
| 114 |
+
className="icon-btn"
|
| 115 |
+
onClick={onCopy}
|
| 116 |
+
disabled={!output.trim()}
|
| 117 |
+
>
|
| 118 |
+
{copied ? "Copied" : "Copy"}
|
| 119 |
+
</button>
|
| 120 |
+
<button
|
| 121 |
+
type="button"
|
| 122 |
+
className="icon-btn"
|
| 123 |
+
onClick={onDownload}
|
| 124 |
+
disabled={!output.trim()}
|
| 125 |
+
>
|
| 126 |
+
Download
|
| 127 |
+
</button>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
<textarea
|
| 131 |
+
value={output}
|
| 132 |
+
onChange={(e) => setOutput(e.target.value)}
|
| 133 |
+
placeholder="Your rewrite will appear here…"
|
| 134 |
+
spellCheck
|
| 135 |
+
/>
|
| 136 |
+
</div>
|
| 137 |
+
</section>
|
| 138 |
+
|
| 139 |
+
<aside className="controls">
|
| 140 |
+
<div className="field">
|
| 141 |
+
<label>Tone</label>
|
| 142 |
+
<div className="chips">
|
| 143 |
+
{TONES.map((t) => (
|
| 144 |
+
<button
|
| 145 |
+
key={t}
|
| 146 |
+
type="button"
|
| 147 |
+
className={`chip${tone === t ? " active" : ""}`}
|
| 148 |
+
onClick={() => setTone(t)}
|
| 149 |
+
>
|
| 150 |
+
{t}
|
| 151 |
+
</button>
|
| 152 |
+
))}
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<div className="field">
|
| 157 |
+
<label>Strength</label>
|
| 158 |
+
<div className="chips">
|
| 159 |
+
{STRENGTHS.map((s) => (
|
| 160 |
+
<button
|
| 161 |
+
key={s}
|
| 162 |
+
type="button"
|
| 163 |
+
className={`chip${strength === s ? " active" : ""}`}
|
| 164 |
+
onClick={() => setStrength(s)}
|
| 165 |
+
>
|
| 166 |
+
{s}
|
| 167 |
+
</button>
|
| 168 |
+
))}
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
<label className="toggle">
|
| 173 |
+
<input
|
| 174 |
+
type="checkbox"
|
| 175 |
+
checked={preserveLength}
|
| 176 |
+
onChange={(e) => setPreserveLength(e.target.checked)}
|
| 177 |
+
/>
|
| 178 |
+
Match original length
|
| 179 |
+
</label>
|
| 180 |
+
|
| 181 |
+
<div className="actions">
|
| 182 |
+
<button
|
| 183 |
+
type="button"
|
| 184 |
+
className="btn btn-primary"
|
| 185 |
+
onClick={onRewrite}
|
| 186 |
+
disabled={loading}
|
| 187 |
+
>
|
| 188 |
+
{loading ? "Rewriting…" : "Rewrite"}
|
| 189 |
+
</button>
|
| 190 |
+
<button
|
| 191 |
+
type="button"
|
| 192 |
+
className="btn btn-ghost"
|
| 193 |
+
onClick={() => {
|
| 194 |
+
setInput("");
|
| 195 |
+
setOutput("");
|
| 196 |
+
setMeta("");
|
| 197 |
+
setError("");
|
| 198 |
+
}}
|
| 199 |
+
disabled={loading}
|
| 200 |
+
>
|
| 201 |
+
Clear
|
| 202 |
+
</button>
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
<div
|
| 206 |
+
className={`meta${error ? " error" : ""}${loading ? " loading" : ""}`}
|
| 207 |
+
>
|
| 208 |
+
{error || meta || "Ready when you are."}
|
| 209 |
+
</div>
|
| 210 |
+
</aside>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
<p className="footer-note">Edit important text before you publish it.</p>
|
| 214 |
+
</div>
|
| 215 |
+
);
|
| 216 |
+
}
|
frontend/src/api.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type Tone = "Neutral" | "Casual" | "Formal" | "Academic";
|
| 2 |
+
export type StrengthLabel = "Light" | "Normal" | "Heavy";
|
| 3 |
+
|
| 4 |
+
export const TONES: Tone[] = ["Neutral", "Casual", "Formal", "Academic"];
|
| 5 |
+
export const STRENGTHS: StrengthLabel[] = ["Light", "Normal", "Heavy"];
|
| 6 |
+
|
| 7 |
+
export const STRENGTH_MAP: Record<StrengthLabel, number> = {
|
| 8 |
+
Light: 0,
|
| 9 |
+
Normal: 1,
|
| 10 |
+
Heavy: 2,
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
export type RewriteMeta = {
|
| 14 |
+
input_words: number;
|
| 15 |
+
output_words: number;
|
| 16 |
+
seconds: number;
|
| 17 |
+
tone: string;
|
| 18 |
+
strength: number;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
export type RewriteResponse = {
|
| 22 |
+
rewrite: string;
|
| 23 |
+
meta: RewriteMeta;
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export async function rewriteText(payload: {
|
| 27 |
+
text: string;
|
| 28 |
+
tone: Tone;
|
| 29 |
+
strength: number;
|
| 30 |
+
preserve_length: boolean;
|
| 31 |
+
}): Promise<RewriteResponse> {
|
| 32 |
+
const res = await fetch("/v1/rewrite", {
|
| 33 |
+
method: "POST",
|
| 34 |
+
headers: { "Content-Type": "application/json" },
|
| 35 |
+
body: JSON.stringify(payload),
|
| 36 |
+
});
|
| 37 |
+
if (!res.ok) {
|
| 38 |
+
let detail = "Rewrite failed.";
|
| 39 |
+
try {
|
| 40 |
+
const data = await res.json();
|
| 41 |
+
detail = data.detail || detail;
|
| 42 |
+
} catch {
|
| 43 |
+
/* ignore */
|
| 44 |
+
}
|
| 45 |
+
throw new Error(typeof detail === "string" ? detail : "Rewrite failed.");
|
| 46 |
+
}
|
| 47 |
+
return res.json();
|
| 48 |
+
}
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--ink: #14262f;
|
| 3 |
+
--ink-soft: #3d5561;
|
| 4 |
+
--muted: #6a7f8a;
|
| 5 |
+
--paper: #f3f6f8;
|
| 6 |
+
--panel: rgba(255, 255, 255, 0.72);
|
| 7 |
+
--line: rgba(20, 38, 47, 0.12);
|
| 8 |
+
--accent: #1f6f7a;
|
| 9 |
+
--accent-deep: #15545c;
|
| 10 |
+
--accent-glow: rgba(31, 111, 122, 0.18);
|
| 11 |
+
--ok: #2a7a5c;
|
| 12 |
+
--danger: #a33b3b;
|
| 13 |
+
--radius: 18px;
|
| 14 |
+
--font-display: "Fraunces", Georgia, serif;
|
| 15 |
+
--font-body: "Sora", system-ui, sans-serif;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
* {
|
| 19 |
+
box-sizing: border-box;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
html,
|
| 23 |
+
body,
|
| 24 |
+
#root {
|
| 25 |
+
min-height: 100%;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
body {
|
| 29 |
+
margin: 0;
|
| 30 |
+
color: var(--ink);
|
| 31 |
+
font-family: var(--font-body);
|
| 32 |
+
background:
|
| 33 |
+
radial-gradient(1000px 520px at 8% -10%, #d7ecef 0%, transparent 55%),
|
| 34 |
+
radial-gradient(900px 480px at 100% 0%, #e7eef3 0%, transparent 50%),
|
| 35 |
+
linear-gradient(180deg, #f7fafb 0%, #eef3f5 48%, #e8eef1 100%);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
button,
|
| 39 |
+
textarea {
|
| 40 |
+
font: inherit;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.app {
|
| 44 |
+
width: min(1180px, calc(100% - 2rem));
|
| 45 |
+
margin: 0 auto;
|
| 46 |
+
padding: 1.75rem 0 3rem;
|
| 47 |
+
animation: rise 0.55s ease both;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
@keyframes rise {
|
| 51 |
+
from {
|
| 52 |
+
opacity: 0;
|
| 53 |
+
transform: translateY(10px);
|
| 54 |
+
}
|
| 55 |
+
to {
|
| 56 |
+
opacity: 1;
|
| 57 |
+
transform: translateY(0);
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
@keyframes pulse {
|
| 62 |
+
0%,
|
| 63 |
+
100% {
|
| 64 |
+
opacity: 1;
|
| 65 |
+
}
|
| 66 |
+
50% {
|
| 67 |
+
opacity: 0.55;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.brand {
|
| 72 |
+
display: flex;
|
| 73 |
+
flex-direction: column;
|
| 74 |
+
gap: 0.45rem;
|
| 75 |
+
margin-bottom: 1.5rem;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.brand h1 {
|
| 79 |
+
margin: 0;
|
| 80 |
+
font-family: var(--font-display);
|
| 81 |
+
font-size: clamp(2.2rem, 4vw, 3.1rem);
|
| 82 |
+
font-weight: 700;
|
| 83 |
+
letter-spacing: -0.02em;
|
| 84 |
+
line-height: 1.05;
|
| 85 |
+
color: var(--ink);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.brand p {
|
| 89 |
+
margin: 0;
|
| 90 |
+
max-width: 34rem;
|
| 91 |
+
color: var(--ink-soft);
|
| 92 |
+
font-size: 1.02rem;
|
| 93 |
+
line-height: 1.5;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.workspace {
|
| 97 |
+
display: grid;
|
| 98 |
+
grid-template-columns: 1.15fr 0.85fr;
|
| 99 |
+
gap: 1.1rem;
|
| 100 |
+
align-items: start;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
@media (max-width: 900px) {
|
| 104 |
+
.workspace {
|
| 105 |
+
grid-template-columns: 1fr;
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.editors {
|
| 110 |
+
display: grid;
|
| 111 |
+
grid-template-columns: 1fr 1fr;
|
| 112 |
+
gap: 0.9rem;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
@media (max-width: 720px) {
|
| 116 |
+
.editors {
|
| 117 |
+
grid-template-columns: 1fr;
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.pane {
|
| 122 |
+
display: flex;
|
| 123 |
+
flex-direction: column;
|
| 124 |
+
gap: 0.55rem;
|
| 125 |
+
min-height: 420px;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.pane-head {
|
| 129 |
+
display: flex;
|
| 130 |
+
align-items: center;
|
| 131 |
+
justify-content: space-between;
|
| 132 |
+
gap: 0.75rem;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.pane-head h2 {
|
| 136 |
+
margin: 0;
|
| 137 |
+
font-size: 0.78rem;
|
| 138 |
+
font-weight: 600;
|
| 139 |
+
letter-spacing: 0.08em;
|
| 140 |
+
text-transform: uppercase;
|
| 141 |
+
color: var(--muted);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.pane textarea {
|
| 145 |
+
flex: 1;
|
| 146 |
+
width: 100%;
|
| 147 |
+
min-height: 380px;
|
| 148 |
+
resize: vertical;
|
| 149 |
+
border: 1px solid var(--line);
|
| 150 |
+
border-radius: var(--radius);
|
| 151 |
+
padding: 1rem 1.05rem;
|
| 152 |
+
background: var(--panel);
|
| 153 |
+
backdrop-filter: blur(10px);
|
| 154 |
+
color: var(--ink);
|
| 155 |
+
line-height: 1.55;
|
| 156 |
+
outline: none;
|
| 157 |
+
transition:
|
| 158 |
+
border-color 0.2s ease,
|
| 159 |
+
box-shadow 0.2s ease,
|
| 160 |
+
transform 0.25s ease;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.pane textarea:focus {
|
| 164 |
+
border-color: rgba(31, 111, 122, 0.45);
|
| 165 |
+
box-shadow: 0 0 0 4px var(--accent-glow);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.pane textarea::placeholder {
|
| 169 |
+
color: #8aa0ab;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.controls {
|
| 173 |
+
position: sticky;
|
| 174 |
+
top: 1rem;
|
| 175 |
+
display: flex;
|
| 176 |
+
flex-direction: column;
|
| 177 |
+
gap: 1.15rem;
|
| 178 |
+
padding: 1.15rem 1.2rem 1.25rem;
|
| 179 |
+
border: 1px solid var(--line);
|
| 180 |
+
border-radius: calc(var(--radius) + 4px);
|
| 181 |
+
background:
|
| 182 |
+
linear-gradient(165deg, rgba(255, 255, 255, 0.88), rgba(243, 249, 250, 0.75));
|
| 183 |
+
backdrop-filter: blur(12px);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.field {
|
| 187 |
+
display: flex;
|
| 188 |
+
flex-direction: column;
|
| 189 |
+
gap: 0.55rem;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.field label {
|
| 193 |
+
font-size: 0.78rem;
|
| 194 |
+
font-weight: 600;
|
| 195 |
+
letter-spacing: 0.06em;
|
| 196 |
+
text-transform: uppercase;
|
| 197 |
+
color: var(--muted);
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.chips {
|
| 201 |
+
display: flex;
|
| 202 |
+
flex-wrap: wrap;
|
| 203 |
+
gap: 0.45rem;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.chip {
|
| 207 |
+
border: 1px solid var(--line);
|
| 208 |
+
background: rgba(255, 255, 255, 0.7);
|
| 209 |
+
color: var(--ink-soft);
|
| 210 |
+
border-radius: 999px;
|
| 211 |
+
padding: 0.42rem 0.8rem;
|
| 212 |
+
font-size: 0.88rem;
|
| 213 |
+
font-weight: 500;
|
| 214 |
+
cursor: pointer;
|
| 215 |
+
transition:
|
| 216 |
+
background 0.2s ease,
|
| 217 |
+
color 0.2s ease,
|
| 218 |
+
border-color 0.2s ease,
|
| 219 |
+
transform 0.15s ease;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.chip:hover {
|
| 223 |
+
transform: translateY(-1px);
|
| 224 |
+
border-color: rgba(31, 111, 122, 0.35);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.chip.active {
|
| 228 |
+
background: var(--accent);
|
| 229 |
+
border-color: var(--accent);
|
| 230 |
+
color: #fff;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.toggle {
|
| 234 |
+
display: flex;
|
| 235 |
+
align-items: center;
|
| 236 |
+
gap: 0.65rem;
|
| 237 |
+
color: var(--ink-soft);
|
| 238 |
+
font-size: 0.92rem;
|
| 239 |
+
cursor: pointer;
|
| 240 |
+
user-select: none;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.toggle input {
|
| 244 |
+
width: 1.05rem;
|
| 245 |
+
height: 1.05rem;
|
| 246 |
+
accent-color: var(--accent);
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.actions {
|
| 250 |
+
display: flex;
|
| 251 |
+
flex-direction: column;
|
| 252 |
+
gap: 0.55rem;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.btn {
|
| 256 |
+
border: none;
|
| 257 |
+
border-radius: 12px;
|
| 258 |
+
padding: 0.85rem 1rem;
|
| 259 |
+
font-weight: 600;
|
| 260 |
+
cursor: pointer;
|
| 261 |
+
transition:
|
| 262 |
+
background 0.2s ease,
|
| 263 |
+
transform 0.15s ease,
|
| 264 |
+
opacity 0.2s ease;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.btn:disabled {
|
| 268 |
+
opacity: 0.55;
|
| 269 |
+
cursor: not-allowed;
|
| 270 |
+
transform: none;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.btn-primary {
|
| 274 |
+
background: linear-gradient(180deg, #24828e 0%, var(--accent-deep) 100%);
|
| 275 |
+
color: #fff;
|
| 276 |
+
box-shadow: 0 10px 24px rgba(21, 84, 92, 0.22);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.btn-primary:not(:disabled):hover {
|
| 280 |
+
transform: translateY(-1px);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.btn-ghost {
|
| 284 |
+
background: transparent;
|
| 285 |
+
border: 1px solid var(--line);
|
| 286 |
+
color: var(--ink-soft);
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.btn-ghost:not(:disabled):hover {
|
| 290 |
+
background: rgba(255, 255, 255, 0.7);
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.meta {
|
| 294 |
+
min-height: 1.3rem;
|
| 295 |
+
font-size: 0.88rem;
|
| 296 |
+
color: var(--muted);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.meta.error {
|
| 300 |
+
color: var(--danger);
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.meta.loading {
|
| 304 |
+
animation: pulse 1.1s ease infinite;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.icon-btn {
|
| 308 |
+
border: 1px solid var(--line);
|
| 309 |
+
background: rgba(255, 255, 255, 0.75);
|
| 310 |
+
color: var(--ink-soft);
|
| 311 |
+
border-radius: 10px;
|
| 312 |
+
padding: 0.35rem 0.65rem;
|
| 313 |
+
font-size: 0.8rem;
|
| 314 |
+
font-weight: 600;
|
| 315 |
+
cursor: pointer;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.icon-btn:disabled {
|
| 319 |
+
opacity: 0.45;
|
| 320 |
+
cursor: not-allowed;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.footer-note {
|
| 324 |
+
margin-top: 1.4rem;
|
| 325 |
+
color: var(--muted);
|
| 326 |
+
font-size: 0.85rem;
|
| 327 |
+
}
|
frontend/src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from "react";
|
| 2 |
+
import { createRoot } from "react-dom/client";
|
| 3 |
+
import App from "./App";
|
| 4 |
+
import "./index.css";
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById("root")!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
);
|
frontend/src/vite-env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vite/client" />
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"useDefineForClassFields": true,
|
| 5 |
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"skipLibCheck": true,
|
| 8 |
+
"moduleResolution": "bundler",
|
| 9 |
+
"allowImportingTsExtensions": true,
|
| 10 |
+
"isolatedModules": true,
|
| 11 |
+
"moduleDetection": "force",
|
| 12 |
+
"noEmit": true,
|
| 13 |
+
"jsx": "react-jsx",
|
| 14 |
+
"strict": true,
|
| 15 |
+
"noUnusedLocals": true,
|
| 16 |
+
"noUnusedParameters": true,
|
| 17 |
+
"noFallthroughCasesInSwitch": true
|
| 18 |
+
},
|
| 19 |
+
"include": ["src"],
|
| 20 |
+
"references": [{ "path": "./tsconfig.node.json" }]
|
| 21 |
+
}
|
frontend/tsconfig.node.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
| 4 |
+
"target": "ES2022",
|
| 5 |
+
"lib": ["ES2023"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"skipLibCheck": true,
|
| 8 |
+
"moduleResolution": "bundler",
|
| 9 |
+
"allowImportingTsExtensions": true,
|
| 10 |
+
"isolatedModules": true,
|
| 11 |
+
"moduleDetection": "force",
|
| 12 |
+
"noEmit": true,
|
| 13 |
+
"strict": true,
|
| 14 |
+
"noUnusedLocals": true,
|
| 15 |
+
"noUnusedParameters": true,
|
| 16 |
+
"noFallthroughCasesInSwitch": true,
|
| 17 |
+
"noUncheckedSideEffectImports": true
|
| 18 |
+
},
|
| 19 |
+
"include": ["vite.config.ts"]
|
| 20 |
+
}
|
frontend/vite.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite";
|
| 2 |
+
import react from "@vitejs/plugin-react";
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
plugins: [react()],
|
| 6 |
+
server: {
|
| 7 |
+
port: 5173,
|
| 8 |
+
proxy: {
|
| 9 |
+
"/v1": "http://127.0.0.1:8000",
|
| 10 |
+
"/health": "http://127.0.0.1:8000",
|
| 11 |
+
},
|
| 12 |
+
},
|
| 13 |
+
build: {
|
| 14 |
+
outDir: "dist",
|
| 15 |
+
emptyOutDir: true,
|
| 16 |
+
},
|
| 17 |
+
});
|
requirements.txt
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
gradio>=4.44.0
|
| 2 |
fastapi>=0.115.0
|
| 3 |
uvicorn[standard]>=0.30.0
|
| 4 |
spacy>=3.7.0,<3.9.0
|
|
|
|
|
|
|
| 1 |
fastapi>=0.115.0
|
| 2 |
uvicorn[standard]>=0.30.0
|
| 3 |
spacy>=3.7.0,<3.9.0
|