Upload 80 files
Browse files- .gitattributes +6 -0
- Dockerfile +4 -1
- app/__pycache__/config.cpython-311.pyc +0 -0
- app/__pycache__/main.cpython-311.pyc +0 -0
- app/config.py +10 -1
- app/main.py +26 -10
- app/pipeline/__pycache__/grammar.cpython-311.pyc +0 -0
- app/pipeline/grammar.py +94 -31
- frontend/dist/assets/index.css +27 -0
- frontend/dist/assets/index.js +17 -3
- frontend/src/App.tsx +19 -3
- frontend/src/index.css +27 -0
- scripts/start.sh +52 -47
.gitattributes
CHANGED
|
@@ -1,3 +1,9 @@
|
|
| 1 |
# Keep shell scripts Unix-safe on Windows checkouts
|
| 2 |
*.sh text eol=lf
|
| 3 |
scripts/* text eol=lf
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Keep shell scripts Unix-safe on Windows checkouts
|
| 2 |
*.sh text eol=lf
|
| 3 |
scripts/* text eol=lf
|
| 4 |
+
frontend/dist/apple-touch-icon.png filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
frontend/dist/zuzu-icon-512.png filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
frontend/dist/zuzu-logo.png filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
frontend/public/apple-touch-icon.png filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
frontend/public/zuzu-icon-512.png filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
frontend/public/zuzu-logo.png filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
CHANGED
|
@@ -22,7 +22,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
| 22 |
LANGUAGE_TOOL_ENABLED=true \
|
| 23 |
LANGUAGE_TOOL_EMBEDDED=true \
|
| 24 |
LANGUAGE_TOOL_LANGUAGE=en-US \
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
WORKDIR /app
|
| 28 |
|
|
|
|
| 22 |
LANGUAGE_TOOL_ENABLED=true \
|
| 23 |
LANGUAGE_TOOL_EMBEDDED=true \
|
| 24 |
LANGUAGE_TOOL_LANGUAGE=en-US \
|
| 25 |
+
LANGUAGE_TOOL_TIMEOUT=90 \
|
| 26 |
+
LANGUAGE_TOOL_CHUNK_CHARS=1800 \
|
| 27 |
+
GRAMMAR_MAX_CHARS=12000 \
|
| 28 |
+
LANGUAGETOOL_JAVA_OPTS="-Xms256m -Xmx1024m"
|
| 29 |
|
| 30 |
WORKDIR /app
|
| 31 |
|
app/__pycache__/config.cpython-311.pyc
CHANGED
|
Binary files a/app/__pycache__/config.cpython-311.pyc and b/app/__pycache__/config.cpython-311.pyc differ
|
|
|
app/__pycache__/main.cpython-311.pyc
CHANGED
|
Binary files a/app/__pycache__/main.cpython-311.pyc and b/app/__pycache__/main.cpython-311.pyc differ
|
|
|
app/config.py
CHANGED
|
@@ -45,6 +45,15 @@ SESSION_IDLE_MINUTES = int(os.environ.get("SESSION_IDLE_MINUTES", "30") or "30")
|
|
| 45 |
# Example compose service: http://languagetool:8010 | local: http://127.0.0.1:8010
|
| 46 |
LANGUAGE_TOOL_URL = (os.environ.get("LANGUAGE_TOOL_URL") or "").rstrip("/")
|
| 47 |
LANGUAGE_TOOL_LANGUAGE = os.environ.get("LANGUAGE_TOOL_LANGUAGE") or "en-US"
|
| 48 |
-
LANGUAGE_TOOL_TIMEOUT = float(os.environ.get("LANGUAGE_TOOL_TIMEOUT", "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
_lt_flag = (os.environ.get("LANGUAGE_TOOL_ENABLED") or "true").strip().lower()
|
| 50 |
LANGUAGE_TOOL_ENABLED = _lt_flag not in {"0", "false", "no", "off"} and bool(LANGUAGE_TOOL_URL)
|
|
|
|
| 45 |
# Example compose service: http://languagetool:8010 | local: http://127.0.0.1:8010
|
| 46 |
LANGUAGE_TOOL_URL = (os.environ.get("LANGUAGE_TOOL_URL") or "").rstrip("/")
|
| 47 |
LANGUAGE_TOOL_LANGUAGE = os.environ.get("LANGUAGE_TOOL_LANGUAGE") or "en-US"
|
| 48 |
+
LANGUAGE_TOOL_TIMEOUT = float(os.environ.get("LANGUAGE_TOOL_TIMEOUT", "90") or "90")
|
| 49 |
+
LANGUAGE_TOOL_CHUNK_CHARS = max(
|
| 50 |
+
500,
|
| 51 |
+
min(int(os.environ.get("LANGUAGE_TOOL_CHUNK_CHARS", "1800") or "1800"), 8000),
|
| 52 |
+
)
|
| 53 |
+
# Hard cap for grammar checks (prevents Space OOMs / proxy 500s on huge pastes)
|
| 54 |
+
GRAMMAR_MAX_CHARS = max(
|
| 55 |
+
1000,
|
| 56 |
+
min(int(os.environ.get("GRAMMAR_MAX_CHARS", "12000") or "12000"), MAX_CHARS),
|
| 57 |
+
)
|
| 58 |
_lt_flag = (os.environ.get("LANGUAGE_TOOL_ENABLED") or "true").strip().lower()
|
| 59 |
LANGUAGE_TOOL_ENABLED = _lt_flag not in {"0", "false", "no", "off"} and bool(LANGUAGE_TOOL_URL)
|
app/main.py
CHANGED
|
@@ -26,8 +26,15 @@ from app.billing.quota import (
|
|
| 26 |
record_rewrite,
|
| 27 |
)
|
| 28 |
from app.bootstrap import ensure_resources
|
| 29 |
-
from app.config import
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
from app.pipeline.nlp import spacy_available
|
| 32 |
from app.pipeline.orchestrator import rewrite_text, similarity_check
|
| 33 |
from app.pipeline.tones import normalize_tone
|
|
@@ -191,11 +198,14 @@ def api_grammar(body: GrammarRequest):
|
|
| 191 |
if not text:
|
| 192 |
logger.info("grammar rejected: empty text")
|
| 193 |
raise HTTPException(status_code=400, detail="Paste some text to check.")
|
| 194 |
-
if len(text) >
|
| 195 |
-
logger.info("grammar rejected: too long chars=%s", len(text))
|
| 196 |
raise HTTPException(
|
| 197 |
status_code=413,
|
| 198 |
-
detail=
|
|
|
|
|
|
|
|
|
|
| 199 |
)
|
| 200 |
try:
|
| 201 |
result = check_grammar(text, language=language)
|
|
@@ -207,12 +217,18 @@ def api_grammar(body: GrammarRequest):
|
|
| 207 |
result.get("engine"),
|
| 208 |
)
|
| 209 |
return result
|
|
|
|
|
|
|
| 210 |
except Exception:
|
| 211 |
-
logger.exception("grammar check crashed")
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
|
| 218 |
def _register_frontend() -> None:
|
|
|
|
| 26 |
record_rewrite,
|
| 27 |
)
|
| 28 |
from app.bootstrap import ensure_resources
|
| 29 |
+
from app.config import (
|
| 30 |
+
APP_TITLE,
|
| 31 |
+
AUTH_ENABLED,
|
| 32 |
+
GRAMMAR_MAX_CHARS,
|
| 33 |
+
LANGUAGE_TOOL_LANGUAGE,
|
| 34 |
+
LANGUAGE_TOOL_URL,
|
| 35 |
+
MAX_CHARS,
|
| 36 |
+
)
|
| 37 |
+
from app.pipeline.grammar import check_grammar, languagetool_reachable, normalize_language, rules_fallback_result
|
| 38 |
from app.pipeline.nlp import spacy_available
|
| 39 |
from app.pipeline.orchestrator import rewrite_text, similarity_check
|
| 40 |
from app.pipeline.tones import normalize_tone
|
|
|
|
| 198 |
if not text:
|
| 199 |
logger.info("grammar rejected: empty text")
|
| 200 |
raise HTTPException(status_code=400, detail="Paste some text to check.")
|
| 201 |
+
if len(text) > GRAMMAR_MAX_CHARS:
|
| 202 |
+
logger.info("grammar rejected: too long chars=%s max=%s", len(text), GRAMMAR_MAX_CHARS)
|
| 203 |
raise HTTPException(
|
| 204 |
status_code=413,
|
| 205 |
+
detail=(
|
| 206 |
+
f"Text is too long for grammar check ({len(text):,} chars). "
|
| 207 |
+
f"Max is {GRAMMAR_MAX_CHARS:,} characters — shorten the draft or split into sections."
|
| 208 |
+
),
|
| 209 |
)
|
| 210 |
try:
|
| 211 |
result = check_grammar(text, language=language)
|
|
|
|
| 217 |
result.get("engine"),
|
| 218 |
)
|
| 219 |
return result
|
| 220 |
+
except HTTPException:
|
| 221 |
+
raise
|
| 222 |
except Exception:
|
| 223 |
+
logger.exception("grammar check crashed — returning safe fallback")
|
| 224 |
+
try:
|
| 225 |
+
return rules_fallback_result(text, language)
|
| 226 |
+
except Exception:
|
| 227 |
+
logger.exception("grammar fallback also failed")
|
| 228 |
+
raise HTTPException(
|
| 229 |
+
status_code=500,
|
| 230 |
+
detail="Grammar check failed on the server. Try a shorter text and retry.",
|
| 231 |
+
) from None
|
| 232 |
|
| 233 |
|
| 234 |
def _register_frontend() -> None:
|
app/pipeline/__pycache__/grammar.cpython-311.pyc
CHANGED
|
Binary files a/app/pipeline/__pycache__/grammar.cpython-311.pyc and b/app/pipeline/__pycache__/grammar.cpython-311.pyc differ
|
|
|
app/pipeline/grammar.py
CHANGED
|
@@ -9,6 +9,7 @@ from dataclasses import asdict, dataclass
|
|
| 9 |
import httpx
|
| 10 |
|
| 11 |
from app.config import (
|
|
|
|
| 12 |
LANGUAGE_TOOL_ENABLED,
|
| 13 |
LANGUAGE_TOOL_LANGUAGE,
|
| 14 |
LANGUAGE_TOOL_TIMEOUT,
|
|
@@ -119,37 +120,54 @@ def _check_languagetool_chunk(
|
|
| 119 |
return issues
|
| 120 |
|
| 121 |
|
| 122 |
-
def _iter_chunks(text: str, max_chars: int =
|
| 123 |
-
"""Split
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
return [(0, text)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
chunks: list[tuple[int, str]] = []
|
| 127 |
-
parts = re.split(r"(?<=\n\n)", text)
|
| 128 |
buf = ""
|
| 129 |
buf_start = 0
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
continue
|
| 134 |
-
if buf and len(buf) + len(part) >
|
| 135 |
chunks.append((buf_start, buf))
|
| 136 |
buf = part
|
| 137 |
-
buf_start =
|
| 138 |
else:
|
| 139 |
if not buf:
|
| 140 |
-
buf_start =
|
| 141 |
buf += part
|
| 142 |
-
pos += len(part)
|
| 143 |
-
if len(buf) >= max_chars:
|
| 144 |
-
chunks.append((buf_start, buf))
|
| 145 |
-
buf = ""
|
| 146 |
if buf:
|
| 147 |
chunks.append((buf_start, buf))
|
| 148 |
-
|
| 149 |
-
# hard split
|
| 150 |
-
for i in range(0, len(text), max_chars):
|
| 151 |
-
chunks.append((i, text[i : i + max_chars]))
|
| 152 |
-
return chunks
|
| 153 |
|
| 154 |
|
| 155 |
def normalize_language(language: str | None) -> str:
|
|
@@ -164,20 +182,41 @@ def normalize_language(language: str | None) -> str:
|
|
| 164 |
return f"{parts[0].lower()}-{parts[1].upper()}"
|
| 165 |
|
| 166 |
|
| 167 |
-
def check_languagetool(text: str, language: str | None = None) -> list[GrammarIssue] | None:
|
| 168 |
-
"""Call self-hosted LanguageTool.
|
|
|
|
|
|
|
|
|
|
| 169 |
if not LANGUAGE_TOOL_ENABLED or not LANGUAGE_TOOL_URL:
|
| 170 |
-
return None
|
| 171 |
lang = normalize_language(language)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
try:
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
except Exception as exc: # noqa: BLE001
|
| 179 |
-
logger.warning("LanguageTool
|
| 180 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
|
| 183 |
def check_rules(text: str) -> list[GrammarIssue]:
|
|
@@ -325,15 +364,18 @@ def check_grammar(text: str, language: str | None = None) -> dict:
|
|
| 325 |
"note": "Paste some text to check.",
|
| 326 |
}
|
| 327 |
|
| 328 |
-
lt_issues = check_languagetool(text, lang)
|
| 329 |
if lt_issues is not None:
|
| 330 |
filtered = _dedupe(lt_issues)
|
|
|
|
|
|
|
|
|
|
| 331 |
return {
|
| 332 |
"issues": [asdict(i) for i in filtered],
|
| 333 |
"input_words": _word_count(text),
|
| 334 |
"engine": "languagetool",
|
| 335 |
"language": lang,
|
| 336 |
-
"note":
|
| 337 |
}
|
| 338 |
|
| 339 |
# Fallback when LT is down / not configured
|
|
@@ -345,6 +387,8 @@ def check_grammar(text: str, language: str | None = None) -> dict:
|
|
| 345 |
else "LanguageTool is not configured — using basic local rules only. "
|
| 346 |
"Set LANGUAGE_TOOL_URL and start the self-hosted server."
|
| 347 |
)
|
|
|
|
|
|
|
| 348 |
return {
|
| 349 |
"issues": [asdict(i) for i in filtered],
|
| 350 |
"input_words": _word_count(text),
|
|
@@ -352,3 +396,22 @@ def check_grammar(text: str, language: str | None = None) -> dict:
|
|
| 352 |
"language": lang,
|
| 353 |
"note": note,
|
| 354 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
import httpx
|
| 10 |
|
| 11 |
from app.config import (
|
| 12 |
+
LANGUAGE_TOOL_CHUNK_CHARS,
|
| 13 |
LANGUAGE_TOOL_ENABLED,
|
| 14 |
LANGUAGE_TOOL_LANGUAGE,
|
| 15 |
LANGUAGE_TOOL_TIMEOUT,
|
|
|
|
| 120 |
return issues
|
| 121 |
|
| 122 |
|
| 123 |
+
def _iter_chunks(text: str, max_chars: int | None = None) -> list[tuple[int, str]]:
|
| 124 |
+
"""Split text into small chunks by sentence, then hard-split if needed.
|
| 125 |
+
|
| 126 |
+
Long single paragraphs (no blank lines) must still be chunked — otherwise
|
| 127 |
+
LanguageTool OOMs / times out on Spaces.
|
| 128 |
+
"""
|
| 129 |
+
limit = max_chars if max_chars is not None else LANGUAGE_TOOL_CHUNK_CHARS
|
| 130 |
+
if len(text) <= limit:
|
| 131 |
return [(0, text)]
|
| 132 |
+
|
| 133 |
+
# Prefer sentence boundaries, then paragraph breaks
|
| 134 |
+
pieces: list[tuple[int, str]] = []
|
| 135 |
+
pattern = re.compile(r".+?(?:[.!?][)\"']*\s+|\n+|$)", re.DOTALL)
|
| 136 |
+
pos = 0
|
| 137 |
+
for m in pattern.finditer(text):
|
| 138 |
+
chunk = m.group(0)
|
| 139 |
+
if not chunk:
|
| 140 |
+
continue
|
| 141 |
+
pieces.append((m.start(), chunk))
|
| 142 |
+
pos = m.end()
|
| 143 |
+
if pos < len(text):
|
| 144 |
+
pieces.append((pos, text[pos:]))
|
| 145 |
+
if not pieces:
|
| 146 |
+
pieces = [(0, text)]
|
| 147 |
+
|
| 148 |
chunks: list[tuple[int, str]] = []
|
|
|
|
| 149 |
buf = ""
|
| 150 |
buf_start = 0
|
| 151 |
+
for start, part in pieces:
|
| 152 |
+
# Oversized single sentence → hard split
|
| 153 |
+
if len(part) > limit:
|
| 154 |
+
if buf:
|
| 155 |
+
chunks.append((buf_start, buf))
|
| 156 |
+
buf = ""
|
| 157 |
+
for i in range(0, len(part), limit):
|
| 158 |
+
chunks.append((start + i, part[i : i + limit]))
|
| 159 |
continue
|
| 160 |
+
if buf and len(buf) + len(part) > limit:
|
| 161 |
chunks.append((buf_start, buf))
|
| 162 |
buf = part
|
| 163 |
+
buf_start = start
|
| 164 |
else:
|
| 165 |
if not buf:
|
| 166 |
+
buf_start = start
|
| 167 |
buf += part
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
if buf:
|
| 169 |
chunks.append((buf_start, buf))
|
| 170 |
+
return chunks or [(0, text)]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
|
| 173 |
def normalize_language(language: str | None) -> str:
|
|
|
|
| 182 |
return f"{parts[0].lower()}-{parts[1].upper()}"
|
| 183 |
|
| 184 |
|
| 185 |
+
def check_languagetool(text: str, language: str | None = None) -> tuple[list[GrammarIssue] | None, str | None]:
|
| 186 |
+
"""Call self-hosted LanguageTool.
|
| 187 |
+
|
| 188 |
+
Returns (issues, warning). issues is None if LT is unavailable entirely.
|
| 189 |
+
"""
|
| 190 |
if not LANGUAGE_TOOL_ENABLED or not LANGUAGE_TOOL_URL:
|
| 191 |
+
return None, None
|
| 192 |
lang = normalize_language(language)
|
| 193 |
+
chunks = _iter_chunks(text)
|
| 194 |
+
issues: list[GrammarIssue] = []
|
| 195 |
+
failures = 0
|
| 196 |
+
timeout = httpx.Timeout(connect=5.0, read=LANGUAGE_TOOL_TIMEOUT, write=10.0, pool=5.0)
|
| 197 |
try:
|
| 198 |
+
with httpx.Client(timeout=timeout) as client:
|
| 199 |
+
for base, chunk in chunks:
|
| 200 |
+
try:
|
| 201 |
+
issues.extend(_check_languagetool_chunk(client, chunk, base, lang))
|
| 202 |
+
except Exception as exc: # noqa: BLE001
|
| 203 |
+
failures += 1
|
| 204 |
+
logger.warning(
|
| 205 |
+
"LanguageTool chunk failed (offset=%s len=%s): %s",
|
| 206 |
+
base,
|
| 207 |
+
len(chunk),
|
| 208 |
+
exc,
|
| 209 |
+
)
|
| 210 |
except Exception as exc: # noqa: BLE001
|
| 211 |
+
logger.warning("LanguageTool client failed: %s", exc)
|
| 212 |
+
return None, str(exc)
|
| 213 |
+
|
| 214 |
+
if not issues and failures == len(chunks):
|
| 215 |
+
return None, f"All {failures} LanguageTool chunk(s) failed"
|
| 216 |
+
warning = None
|
| 217 |
+
if failures:
|
| 218 |
+
warning = f"{failures} of {len(chunks)} chunk(s) failed; showing partial results."
|
| 219 |
+
return issues, warning
|
| 220 |
|
| 221 |
|
| 222 |
def check_rules(text: str) -> list[GrammarIssue]:
|
|
|
|
| 364 |
"note": "Paste some text to check.",
|
| 365 |
}
|
| 366 |
|
| 367 |
+
lt_issues, lt_warning = check_languagetool(text, lang)
|
| 368 |
if lt_issues is not None:
|
| 369 |
filtered = _dedupe(lt_issues)
|
| 370 |
+
note = f"Self-hosted LanguageTool ({lang}) — full sentence grammar & spelling."
|
| 371 |
+
if lt_warning:
|
| 372 |
+
note = f"{note} {lt_warning}"
|
| 373 |
return {
|
| 374 |
"issues": [asdict(i) for i in filtered],
|
| 375 |
"input_words": _word_count(text),
|
| 376 |
"engine": "languagetool",
|
| 377 |
"language": lang,
|
| 378 |
+
"note": note,
|
| 379 |
}
|
| 380 |
|
| 381 |
# Fallback when LT is down / not configured
|
|
|
|
| 387 |
else "LanguageTool is not configured — using basic local rules only. "
|
| 388 |
"Set LANGUAGE_TOOL_URL and start the self-hosted server."
|
| 389 |
)
|
| 390 |
+
if lt_warning:
|
| 391 |
+
note = f"{note} ({lt_warning})"
|
| 392 |
return {
|
| 393 |
"issues": [asdict(i) for i in filtered],
|
| 394 |
"input_words": _word_count(text),
|
|
|
|
| 396 |
"language": lang,
|
| 397 |
"note": note,
|
| 398 |
}
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def rules_fallback_result(text: str, language: str | None = None, extra_note: str = "") -> dict:
|
| 402 |
+
"""Safe local-rules payload when LT or the main checker fails hard."""
|
| 403 |
+
lang = normalize_language(language)
|
| 404 |
+
filtered = _dedupe(check_rules(text or ""))
|
| 405 |
+
note = (
|
| 406 |
+
"Grammar engine hit an error on this text — showing basic local rules only. "
|
| 407 |
+
"Try a shorter section, or wait and retry."
|
| 408 |
+
)
|
| 409 |
+
if extra_note:
|
| 410 |
+
note = f"{note} {extra_note}"
|
| 411 |
+
return {
|
| 412 |
+
"issues": [asdict(i) for i in filtered],
|
| 413 |
+
"input_words": _word_count(text or ""),
|
| 414 |
+
"engine": "rules",
|
| 415 |
+
"language": lang,
|
| 416 |
+
"note": note,
|
| 417 |
+
}
|
frontend/dist/assets/index.css
CHANGED
|
@@ -318,6 +318,33 @@ textarea {
|
|
| 318 |
line-height: 1.45;
|
| 319 |
}
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
.grammar-lang {
|
| 322 |
flex-wrap: wrap;
|
| 323 |
}
|
|
|
|
| 318 |
line-height: 1.45;
|
| 319 |
}
|
| 320 |
|
| 321 |
+
.engine-badge {
|
| 322 |
+
display: inline-flex;
|
| 323 |
+
align-self: flex-start;
|
| 324 |
+
margin-top: 0.35rem;
|
| 325 |
+
font-size: 0.72rem;
|
| 326 |
+
font-weight: 700;
|
| 327 |
+
letter-spacing: 0.04em;
|
| 328 |
+
text-transform: uppercase;
|
| 329 |
+
padding: 0.25rem 0.55rem;
|
| 330 |
+
border-radius: 999px;
|
| 331 |
+
border: 1px solid var(--panel-edge);
|
| 332 |
+
background: rgba(255, 255, 255, 0.75);
|
| 333 |
+
color: var(--ink-soft);
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.engine-badge.engine-languagetool {
|
| 337 |
+
border-color: rgba(15, 122, 95, 0.35);
|
| 338 |
+
background: rgba(15, 122, 95, 0.1);
|
| 339 |
+
color: var(--accent);
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
.engine-badge.engine-rules {
|
| 343 |
+
border-color: rgba(226, 90, 60, 0.35);
|
| 344 |
+
background: rgba(226, 90, 60, 0.1);
|
| 345 |
+
color: var(--coral);
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
.grammar-lang {
|
| 349 |
flex-wrap: wrap;
|
| 350 |
}
|
frontend/dist/assets/index.js
CHANGED
|
@@ -15,6 +15,7 @@ const TONES = ["Neutral", "Casual", "Formal", "Academic"];
|
|
| 15 |
const STRENGTHS = ["Light", "Normal", "Heavy"];
|
| 16 |
const STRENGTH_MAP = { Light: 0, Normal: 1, Heavy: 2 };
|
| 17 |
const MAX_CHARS = 50000;
|
|
|
|
| 18 |
const LOGO_SRC = "/zuzu-logo.png";
|
| 19 |
|
| 20 |
function BrandLogo({ size = 44, className = "" }) {
|
|
@@ -623,6 +624,7 @@ function App() {
|
|
| 623 |
const [grammarLanguage, setGrammarLanguage] = useState("en-US");
|
| 624 |
const [grammarIssues, setGrammarIssues] = useState([]);
|
| 625 |
const [grammarNote, setGrammarNote] = useState("");
|
|
|
|
| 626 |
const [grammarLoading, setGrammarLoading] = useState(false);
|
| 627 |
const [grammarError, setGrammarError] = useState("");
|
| 628 |
const [grammarMeta, setGrammarMeta] = useState("");
|
|
@@ -671,10 +673,14 @@ function App() {
|
|
| 671 |
async function onGrammarCheck() {
|
| 672 |
const text = grammarText.trim();
|
| 673 |
if (!text) { setGrammarError("Paste some text first — or try the sample."); return; }
|
| 674 |
-
if (text.length >
|
|
|
|
|
|
|
|
|
|
| 675 |
setGrammarLoading(true);
|
| 676 |
setGrammarError("");
|
| 677 |
setGrammarMeta("Checking…");
|
|
|
|
| 678 |
try {
|
| 679 |
const result = await checkGrammar(text, {
|
| 680 |
language: grammarLanguage,
|
|
@@ -682,12 +688,14 @@ function App() {
|
|
| 682 |
});
|
| 683 |
setGrammarIssues(result.issues || []);
|
| 684 |
setGrammarNote(result.note || "");
|
|
|
|
| 685 |
const n = (result.issues || []).length;
|
| 686 |
const lang = result.language || grammarLanguage;
|
| 687 |
setGrammarMeta(n ? `${n} issue${n === 1 ? "" : "s"} · ${result.input_words} words · ${lang}` : `No issues found · ${result.input_words} words · ${lang}`);
|
| 688 |
} catch (err) {
|
| 689 |
setGrammarError(err instanceof Error ? err.message : "Grammar check failed.");
|
| 690 |
setGrammarMeta("");
|
|
|
|
| 691 |
setGrammarIssues([]);
|
| 692 |
} finally {
|
| 693 |
setGrammarLoading(false);
|
|
@@ -863,11 +871,17 @@ function App() {
|
|
| 863 |
h("p", { className: "grammar-lead" },
|
| 864 |
"Self-hosted LanguageTool checks full sentence grammar for ",
|
| 865 |
h("strong", null, (GRAMMAR_LANGUAGES.find((l) => l.id === grammarLanguage) || {}).label || grammarLanguage),
|
| 866 |
-
". If LanguageTool is offline, basic local rules are used instead.",
|
| 867 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 868 |
),
|
| 869 |
h("div", { className: "toolbar-actions" },
|
| 870 |
-
h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: () => { setGrammarText(""); setGrammarIssues([]); setGrammarMeta(""); setGrammarError(""); setGrammarNote(""); } }, "Clear"),
|
| 871 |
h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: () => { setGrammarText(GRAMMAR_SAMPLE); setGrammarIssues([]); setGrammarError(""); setGrammarMeta("Sample loaded — hit Check grammar."); setGrammarNote(""); } }, "Try sample"),
|
| 872 |
grammarIssues.length ? h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: onApplyAllGrammar }, "Apply all") : null,
|
| 873 |
h("button", { type: "button", className: "btn btn-primary btn-rewrite", disabled: grammarLoading, onClick: () => onGrammarCheck() }, grammarLoading ? "Checking…" : "Check grammar"),
|
|
|
|
| 15 |
const STRENGTHS = ["Light", "Normal", "Heavy"];
|
| 16 |
const STRENGTH_MAP = { Light: 0, Normal: 1, Heavy: 2 };
|
| 17 |
const MAX_CHARS = 50000;
|
| 18 |
+
const GRAMMAR_MAX_CHARS = 12000;
|
| 19 |
const LOGO_SRC = "/zuzu-logo.png";
|
| 20 |
|
| 21 |
function BrandLogo({ size = 44, className = "" }) {
|
|
|
|
| 624 |
const [grammarLanguage, setGrammarLanguage] = useState("en-US");
|
| 625 |
const [grammarIssues, setGrammarIssues] = useState([]);
|
| 626 |
const [grammarNote, setGrammarNote] = useState("");
|
| 627 |
+
const [grammarEngine, setGrammarEngine] = useState("");
|
| 628 |
const [grammarLoading, setGrammarLoading] = useState(false);
|
| 629 |
const [grammarError, setGrammarError] = useState("");
|
| 630 |
const [grammarMeta, setGrammarMeta] = useState("");
|
|
|
|
| 673 |
async function onGrammarCheck() {
|
| 674 |
const text = grammarText.trim();
|
| 675 |
if (!text) { setGrammarError("Paste some text first — or try the sample."); return; }
|
| 676 |
+
if (text.length > GRAMMAR_MAX_CHARS) {
|
| 677 |
+
setGrammarError(`Text is too long for grammar check (${text.length.toLocaleString()} chars). Max is ${GRAMMAR_MAX_CHARS.toLocaleString()} — shorten or split into sections.`);
|
| 678 |
+
return;
|
| 679 |
+
}
|
| 680 |
setGrammarLoading(true);
|
| 681 |
setGrammarError("");
|
| 682 |
setGrammarMeta("Checking…");
|
| 683 |
+
setGrammarEngine("");
|
| 684 |
try {
|
| 685 |
const result = await checkGrammar(text, {
|
| 686 |
language: grammarLanguage,
|
|
|
|
| 688 |
});
|
| 689 |
setGrammarIssues(result.issues || []);
|
| 690 |
setGrammarNote(result.note || "");
|
| 691 |
+
setGrammarEngine(result.engine || "");
|
| 692 |
const n = (result.issues || []).length;
|
| 693 |
const lang = result.language || grammarLanguage;
|
| 694 |
setGrammarMeta(n ? `${n} issue${n === 1 ? "" : "s"} · ${result.input_words} words · ${lang}` : `No issues found · ${result.input_words} words · ${lang}`);
|
| 695 |
} catch (err) {
|
| 696 |
setGrammarError(err instanceof Error ? err.message : "Grammar check failed.");
|
| 697 |
setGrammarMeta("");
|
| 698 |
+
setGrammarEngine("");
|
| 699 |
setGrammarIssues([]);
|
| 700 |
} finally {
|
| 701 |
setGrammarLoading(false);
|
|
|
|
| 871 |
h("p", { className: "grammar-lead" },
|
| 872 |
"Self-hosted LanguageTool checks full sentence grammar for ",
|
| 873 |
h("strong", null, (GRAMMAR_LANGUAGES.find((l) => l.id === grammarLanguage) || {}).label || grammarLanguage),
|
| 874 |
+
". Long text is checked in small chunks. If LanguageTool is offline, basic local rules are used instead.",
|
| 875 |
),
|
| 876 |
+
grammarEngine
|
| 877 |
+
? h("span", {
|
| 878 |
+
className: `engine-badge engine-${grammarEngine}`,
|
| 879 |
+
title: grammarNote || undefined,
|
| 880 |
+
}, `Engine: ${grammarEngine === "languagetool" ? "LanguageTool" : grammarEngine === "rules" ? "Local rules" : grammarEngine}`)
|
| 881 |
+
: null,
|
| 882 |
),
|
| 883 |
h("div", { className: "toolbar-actions" },
|
| 884 |
+
h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: () => { setGrammarText(""); setGrammarIssues([]); setGrammarMeta(""); setGrammarError(""); setGrammarNote(""); setGrammarEngine(""); } }, "Clear"),
|
| 885 |
h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: () => { setGrammarText(GRAMMAR_SAMPLE); setGrammarIssues([]); setGrammarError(""); setGrammarMeta("Sample loaded — hit Check grammar."); setGrammarNote(""); } }, "Try sample"),
|
| 886 |
grammarIssues.length ? h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: onApplyAllGrammar }, "Apply all") : null,
|
| 887 |
h("button", { type: "button", className: "btn btn-primary btn-rewrite", disabled: grammarLoading, onClick: () => onGrammarCheck() }, grammarLoading ? "Checking…" : "Check grammar"),
|
frontend/src/App.tsx
CHANGED
|
@@ -18,6 +18,7 @@ import type { PlanCard } from "./supabase";
|
|
| 18 |
const LOGO_SRC = "/zuzu-logo.png";
|
| 19 |
const LOGO_FALLBACK = "/favicon.svg";
|
| 20 |
const MAX_CHARS = 50000;
|
|
|
|
| 21 |
|
| 22 |
type ProductId = "writer" | "grammar";
|
| 23 |
|
|
@@ -621,6 +622,7 @@ export default function App() {
|
|
| 621 |
const [grammarLanguage, setGrammarLanguage] = useState("en-US");
|
| 622 |
const [grammarIssues, setGrammarIssues] = useState<GrammarIssue[]>([]);
|
| 623 |
const [grammarNote, setGrammarNote] = useState("");
|
|
|
|
| 624 |
const [grammarLoading, setGrammarLoading] = useState(false);
|
| 625 |
const [grammarError, setGrammarError] = useState("");
|
| 626 |
const [grammarMeta, setGrammarMeta] = useState("");
|
|
@@ -694,13 +696,16 @@ export default function App() {
|
|
| 694 |
setGrammarError("Paste some text first — or try the sample.");
|
| 695 |
return;
|
| 696 |
}
|
| 697 |
-
if (text.length >
|
| 698 |
-
setGrammarError(
|
|
|
|
|
|
|
| 699 |
return;
|
| 700 |
}
|
| 701 |
setGrammarLoading(true);
|
| 702 |
setGrammarError("");
|
| 703 |
setGrammarMeta("Checking…");
|
|
|
|
| 704 |
try {
|
| 705 |
const result = await checkGrammar(text, {
|
| 706 |
language: grammarLanguage,
|
|
@@ -708,6 +713,7 @@ export default function App() {
|
|
| 708 |
});
|
| 709 |
setGrammarIssues(result.issues);
|
| 710 |
setGrammarNote(result.note ?? "");
|
|
|
|
| 711 |
setGrammarMeta(
|
| 712 |
result.issues.length
|
| 713 |
? `${result.issues.length} issue${result.issues.length === 1 ? "" : "s"} · ${result.input_words} words · ${result.language ?? grammarLanguage}`
|
|
@@ -717,6 +723,7 @@ export default function App() {
|
|
| 717 |
const apiErr = err as ApiError;
|
| 718 |
setGrammarError(apiErr.message || "Grammar check failed.");
|
| 719 |
setGrammarMeta("");
|
|
|
|
| 720 |
setGrammarIssues([]);
|
| 721 |
} finally {
|
| 722 |
setGrammarLoading(false);
|
|
@@ -967,8 +974,16 @@ export default function App() {
|
|
| 967 |
<p className="grammar-lead">
|
| 968 |
Self-hosted LanguageTool checks full sentence grammar for{" "}
|
| 969 |
<strong>{GRAMMAR_LANGUAGES.find((l) => l.id === grammarLanguage)?.label ?? grammarLanguage}</strong>
|
| 970 |
-
. If LanguageTool is offline, basic local rules are used instead.
|
| 971 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 972 |
</div>
|
| 973 |
<div className="toolbar-actions">
|
| 974 |
<button
|
|
@@ -980,6 +995,7 @@ export default function App() {
|
|
| 980 |
setGrammarMeta("");
|
| 981 |
setGrammarError("");
|
| 982 |
setGrammarNote("");
|
|
|
|
| 983 |
}}
|
| 984 |
disabled={grammarLoading}
|
| 985 |
>
|
|
|
|
| 18 |
const LOGO_SRC = "/zuzu-logo.png";
|
| 19 |
const LOGO_FALLBACK = "/favicon.svg";
|
| 20 |
const MAX_CHARS = 50000;
|
| 21 |
+
const GRAMMAR_MAX_CHARS = 12000;
|
| 22 |
|
| 23 |
type ProductId = "writer" | "grammar";
|
| 24 |
|
|
|
|
| 622 |
const [grammarLanguage, setGrammarLanguage] = useState("en-US");
|
| 623 |
const [grammarIssues, setGrammarIssues] = useState<GrammarIssue[]>([]);
|
| 624 |
const [grammarNote, setGrammarNote] = useState("");
|
| 625 |
+
const [grammarEngine, setGrammarEngine] = useState("");
|
| 626 |
const [grammarLoading, setGrammarLoading] = useState(false);
|
| 627 |
const [grammarError, setGrammarError] = useState("");
|
| 628 |
const [grammarMeta, setGrammarMeta] = useState("");
|
|
|
|
| 696 |
setGrammarError("Paste some text first — or try the sample.");
|
| 697 |
return;
|
| 698 |
}
|
| 699 |
+
if (text.length > GRAMMAR_MAX_CHARS) {
|
| 700 |
+
setGrammarError(
|
| 701 |
+
`Text is too long for grammar check (${text.length.toLocaleString()} chars). Max is ${GRAMMAR_MAX_CHARS.toLocaleString()} — shorten or split into sections.`,
|
| 702 |
+
);
|
| 703 |
return;
|
| 704 |
}
|
| 705 |
setGrammarLoading(true);
|
| 706 |
setGrammarError("");
|
| 707 |
setGrammarMeta("Checking…");
|
| 708 |
+
setGrammarEngine("");
|
| 709 |
try {
|
| 710 |
const result = await checkGrammar(text, {
|
| 711 |
language: grammarLanguage,
|
|
|
|
| 713 |
});
|
| 714 |
setGrammarIssues(result.issues);
|
| 715 |
setGrammarNote(result.note ?? "");
|
| 716 |
+
setGrammarEngine(result.engine || "");
|
| 717 |
setGrammarMeta(
|
| 718 |
result.issues.length
|
| 719 |
? `${result.issues.length} issue${result.issues.length === 1 ? "" : "s"} · ${result.input_words} words · ${result.language ?? grammarLanguage}`
|
|
|
|
| 723 |
const apiErr = err as ApiError;
|
| 724 |
setGrammarError(apiErr.message || "Grammar check failed.");
|
| 725 |
setGrammarMeta("");
|
| 726 |
+
setGrammarEngine("");
|
| 727 |
setGrammarIssues([]);
|
| 728 |
} finally {
|
| 729 |
setGrammarLoading(false);
|
|
|
|
| 974 |
<p className="grammar-lead">
|
| 975 |
Self-hosted LanguageTool checks full sentence grammar for{" "}
|
| 976 |
<strong>{GRAMMAR_LANGUAGES.find((l) => l.id === grammarLanguage)?.label ?? grammarLanguage}</strong>
|
| 977 |
+
. Long text is checked in small chunks. If LanguageTool is offline, basic local rules are used instead.
|
| 978 |
</p>
|
| 979 |
+
{grammarEngine ? (
|
| 980 |
+
<span
|
| 981 |
+
className={`engine-badge engine-${grammarEngine}`}
|
| 982 |
+
title={grammarNote || undefined}
|
| 983 |
+
>
|
| 984 |
+
Engine: {grammarEngine === "languagetool" ? "LanguageTool" : grammarEngine === "rules" ? "Local rules" : grammarEngine}
|
| 985 |
+
</span>
|
| 986 |
+
) : null}
|
| 987 |
</div>
|
| 988 |
<div className="toolbar-actions">
|
| 989 |
<button
|
|
|
|
| 995 |
setGrammarMeta("");
|
| 996 |
setGrammarError("");
|
| 997 |
setGrammarNote("");
|
| 998 |
+
setGrammarEngine("");
|
| 999 |
}}
|
| 1000 |
disabled={grammarLoading}
|
| 1001 |
>
|
frontend/src/index.css
CHANGED
|
@@ -318,6 +318,33 @@ textarea {
|
|
| 318 |
line-height: 1.45;
|
| 319 |
}
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
.grammar-lang {
|
| 322 |
flex-wrap: wrap;
|
| 323 |
}
|
|
|
|
| 318 |
line-height: 1.45;
|
| 319 |
}
|
| 320 |
|
| 321 |
+
.engine-badge {
|
| 322 |
+
display: inline-flex;
|
| 323 |
+
align-self: flex-start;
|
| 324 |
+
margin-top: 0.35rem;
|
| 325 |
+
font-size: 0.72rem;
|
| 326 |
+
font-weight: 700;
|
| 327 |
+
letter-spacing: 0.04em;
|
| 328 |
+
text-transform: uppercase;
|
| 329 |
+
padding: 0.25rem 0.55rem;
|
| 330 |
+
border-radius: 999px;
|
| 331 |
+
border: 1px solid var(--panel-edge);
|
| 332 |
+
background: rgba(255, 255, 255, 0.75);
|
| 333 |
+
color: var(--ink-soft);
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.engine-badge.engine-languagetool {
|
| 337 |
+
border-color: rgba(15, 122, 95, 0.35);
|
| 338 |
+
background: rgba(15, 122, 95, 0.1);
|
| 339 |
+
color: var(--accent);
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
.engine-badge.engine-rules {
|
| 343 |
+
border-color: rgba(226, 90, 60, 0.35);
|
| 344 |
+
background: rgba(226, 90, 60, 0.1);
|
| 345 |
+
color: var(--coral);
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
.grammar-lang {
|
| 349 |
flex-wrap: wrap;
|
| 350 |
}
|
scripts/start.sh
CHANGED
|
@@ -1,47 +1,52 @@
|
|
| 1 |
-
#!/usr/bin/env bash
|
| 2 |
-
# Start self-hosted LanguageTool (optional) then the ZuZu API.
|
| 3 |
-
set -euo pipefail
|
| 4 |
-
|
| 5 |
-
LT_HOME="${LANGUAGETOOL_HOME:-/opt/languagetool}"
|
| 6 |
-
LT_PORT="${LANGUAGETOOL_PORT:-8010}"
|
| 7 |
-
LT_JAVA_OPTS="${LANGUAGETOOL_JAVA_OPTS:--Xms256m -
|
| 8 |
-
|
| 9 |
-
start_languagetool() {
|
| 10 |
-
if [[ "${LANGUAGE_TOOL_EMBEDDED:-true}" != "true" ]]; then
|
| 11 |
-
echo "[zuzu] Embedded LanguageTool disabled (LANGUAGE_TOOL_EMBEDDED=${LANGUAGE_TOOL_EMBEDDED:-})"
|
| 12 |
-
return 0
|
| 13 |
-
fi
|
| 14 |
-
if [[ ! -f "${LT_HOME}/languagetool-server.jar" ]]; then
|
| 15 |
-
echo "[zuzu] LanguageTool jar not found at ${LT_HOME}; grammar will use local rules only"
|
| 16 |
-
return 0
|
| 17 |
-
fi
|
| 18 |
-
|
| 19 |
-
echo "[zuzu] Starting LanguageTool on :${LT_PORT}
|
| 20 |
-
# shellcheck disable=SC2086
|
| 21 |
-
java ${LT_JAVA_OPTS} -cp "${LT_HOME}/languagetool-server.jar" \
|
| 22 |
-
org.languagetool.server.HTTPServer \
|
| 23 |
-
--port "${LT_PORT}" \
|
| 24 |
-
--public \
|
| 25 |
-
--allow-origin "*" \
|
| 26 |
-
>/tmp/languagetool.log 2>&1 &
|
| 27 |
-
echo $! >/tmp/languagetool.pid
|
| 28 |
-
|
| 29 |
-
export LANGUAGE_TOOL_URL="${LANGUAGE_TOOL_URL:-http://127.0.0.1:${LT_PORT}}"
|
| 30 |
-
export LANGUAGE_TOOL_ENABLED="${LANGUAGE_TOOL_ENABLED:-true}"
|
| 31 |
-
|
| 32 |
-
for i in $(seq 1 90); do
|
| 33 |
-
if curl -fsS "http://127.0.0.1:${LT_PORT}/v2/languages" >/dev/null 2>&1; then
|
| 34 |
-
echo "[zuzu] LanguageTool
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Start self-hosted LanguageTool (optional) then the ZuZu API.
|
| 3 |
+
set -euo pipefail
|
| 4 |
+
|
| 5 |
+
LT_HOME="${LANGUAGETOOL_HOME:-/opt/languagetool}"
|
| 6 |
+
LT_PORT="${LANGUAGETOOL_PORT:-8010}"
|
| 7 |
+
LT_JAVA_OPTS="${LANGUAGETOOL_JAVA_OPTS:--Xms256m -Xmx1024m}"
|
| 8 |
+
|
| 9 |
+
start_languagetool() {
|
| 10 |
+
if [[ "${LANGUAGE_TOOL_EMBEDDED:-true}" != "true" ]]; then
|
| 11 |
+
echo "[zuzu] Embedded LanguageTool disabled (LANGUAGE_TOOL_EMBEDDED=${LANGUAGE_TOOL_EMBEDDED:-})"
|
| 12 |
+
return 0
|
| 13 |
+
fi
|
| 14 |
+
if [[ ! -f "${LT_HOME}/languagetool-server.jar" ]]; then
|
| 15 |
+
echo "[zuzu] LanguageTool jar not found at ${LT_HOME}; grammar will use local rules only"
|
| 16 |
+
return 0
|
| 17 |
+
fi
|
| 18 |
+
|
| 19 |
+
echo "[zuzu] Starting LanguageTool on :${LT_PORT} ..."
|
| 20 |
+
# shellcheck disable=SC2086
|
| 21 |
+
java ${LT_JAVA_OPTS} -cp "${LT_HOME}/languagetool-server.jar" \
|
| 22 |
+
org.languagetool.server.HTTPServer \
|
| 23 |
+
--port "${LT_PORT}" \
|
| 24 |
+
--public \
|
| 25 |
+
--allow-origin "*" \
|
| 26 |
+
>/tmp/languagetool.log 2>&1 &
|
| 27 |
+
echo $! >/tmp/languagetool.pid
|
| 28 |
+
|
| 29 |
+
export LANGUAGE_TOOL_URL="${LANGUAGE_TOOL_URL:-http://127.0.0.1:${LT_PORT}}"
|
| 30 |
+
export LANGUAGE_TOOL_ENABLED="${LANGUAGE_TOOL_ENABLED:-true}"
|
| 31 |
+
|
| 32 |
+
for i in $(seq 1 90); do
|
| 33 |
+
if curl -fsS "http://127.0.0.1:${LT_PORT}/v2/languages" >/dev/null 2>&1; then
|
| 34 |
+
echo "[zuzu] LanguageTool HTTP up (attempt ${i}) — prewarming check..."
|
| 35 |
+
curl -fsS -X POST "http://127.0.0.1:${LT_PORT}/v2/check" \
|
| 36 |
+
--data-urlencode "language=en-US" \
|
| 37 |
+
--data-urlencode "text=She go to the store yesterday." \
|
| 38 |
+
>/dev/null 2>&1 || true
|
| 39 |
+
echo "[zuzu] LanguageTool ready"
|
| 40 |
+
return 0
|
| 41 |
+
fi
|
| 42 |
+
sleep 2
|
| 43 |
+
done
|
| 44 |
+
echo "[zuzu] WARNING: LanguageTool did not become ready; see /tmp/languagetool.log"
|
| 45 |
+
echo "[zuzu] Grammar will fall back to local rules until LT is up"
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
start_languagetool
|
| 49 |
+
|
| 50 |
+
HOST="${HOST:-0.0.0.0}"
|
| 51 |
+
PORT="${PORT:-7860}"
|
| 52 |
+
exec uvicorn app.main:app --host "${HOST}" --port "${PORT}"
|