idnameraj commited on
Commit
1103f3b
·
verified ·
1 Parent(s): 2279b28

Upload 60 files

Browse files
.gitignore CHANGED
@@ -1,9 +1,6 @@
 
1
  __pycache__/
2
  *.pyc
3
  .venv/
4
- venv/
5
  .env
6
- .pytest_cache/
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", "-m", "app.ui"]
 
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: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.12'
9
- app_file: app.py
10
  pinned: false
11
  ---
12
 
13
  # PlainRewrite
14
 
15
- Offline text rewriter using **spaCy + WordNet + deterministic rules**.
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
- Or:
81
 
82
  ```bash
83
- python -m app.ui
 
 
 
 
 
 
 
 
 
84
  ```
85
 
86
  Open http://127.0.0.1:7860
87
 
88
- ### API only
 
 
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
- ### Docker (local)
99
 
100
  ```bash
101
  docker compose up --build
102
  ```
103
 
 
 
 
 
104
  ## Notes
105
 
106
- - **No OpenRouter / no generative LLM** (no GPT, Llama, T5, PEGASUS, etc.).
107
- - If spaCy model is missing, the app still runs with **regex + WordNet + rules** fallback.
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
- """Hugging Face Spaces / local entrypoint for PlainRewrite Gradio UI."""
2
 
3
  from __future__ import annotations
4
 
5
- from app.ui import APP_CSS, demo
 
 
 
6
 
7
  if __name__ == "__main__":
8
- try:
9
- demo.queue(default_concurrency_limit=2).launch(
10
- server_name="0.0.0.0",
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 service wrapping the non-LLM rewrite engine."""
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
- app = FastAPI(title=APP_TITLE, version="1.0.0")
 
 
 
 
 
 
 
 
 
 
24
 
25
 
26
  class RewriteRequest(BaseModel):
@@ -42,7 +55,7 @@ def health():
42
  return {
43
  "status": "ok",
44
  "app": APP_TITLE,
45
- "llm": False,
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
- "llm": False,
 
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
- """PlainRewrite non-LLM rewrite app (spaCy + WordNet + rules). No OpenRouter / no generative LLM."""
2
 
3
  from __future__ import annotations
4
 
5
- import os
6
- import sys
7
- import tempfile
8
- from pathlib import Path
9
 
10
- ROOT = Path(__file__).resolve().parent.parent
11
- if str(ROOT) not in sys.path:
12
- sys.path.insert(0, str(ROOT))
13
-
14
- from app.bootstrap import ensure_resources
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
- demo = build_ui()
214
 
215
- if __name__ == "__main__":
216
- launch_kwargs = {
217
- "server_name": HOST,
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