Compare commits

...
70 changed files with 7844 additions and 101 deletions
+19 -1
View File
@@ -21,7 +21,14 @@ server {
proxy_send_timeout 10m;
}
location ^~ /git/ {
# Only the git smart-HTTP endpoints of a project (/git/<project id>[.git]/...) are
# public. git-bridge's other routes (/status, /health_check, /metrics, /diags,
# /api/...) are meant for the internal network only and must not be reachable.
location /git/ {
return 404;
}
location ~ "^/git/[0-9a-f]{24}(\.git)?(/|$)" {
resolver_timeout 2s;
resolver 127.0.0.11 valid=10s;
set $git_upstream http://${GIT_BRIDGE_HOST}:${GIT_BRIDGE_PORT};
@@ -63,6 +70,17 @@ server {
expires 1y;
}
# handle output.zip, which clsi builds on demand rather than writing to disk
location ~ ^/project/[0-9a-f]+(/user/[0-9a-f]+)?/build/[0-9a-f-]+/output/output\.zip$ {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 10m;
proxy_send_timeout 10m;
}
# handle output files for specific users
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
-93
View File
@@ -1,93 +0,0 @@
server {
listen 80;
server_name _; # Catch all, see http://nginx.org/en/docs/http/server_names.html
root /overleaf/services/web/public/;
# block external access to prometheus /metrics
location /metrics {
internal;
}
location / {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 10m;
proxy_send_timeout 10m;
}
location /socket.io {
proxy_pass http://127.0.0.1:3026;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 10m;
proxy_send_timeout 10m;
}
location /stylesheets {
expires 1y;
}
location /minjs {
expires 1y;
}
location /img {
expires 1y;
}
# handle output.zip, which clsi builds on demand rather than writing to disk
location ~ ^/project/[0-9a-f]+(/user/[0-9a-f]+)?/build/[0-9a-f-]+/output/output\.zip$ {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 10m;
proxy_send_timeout 10m;
}
# handle output files for specific users
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
proxy_http_version 1.1;
}
# handle output files for anonymous users
location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
proxy_http_version 1.1;
}
# PDF range for specific users
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/content/([0-9a-f-]+/[0-9a-f]+)$ {
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
proxy_http_version 1.1;
}
# PDF range for anonymous users
location ~ ^/project/([0-9a-f]+)/content/([0-9a-f-]+/[0-9a-f]+)$ {
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
proxy_http_version 1.1;
}
# block external access to metrics
location ~* ^/metrics/?$ {
return 404 'Not found';
}
# block external access to all health checks /health_check, /health_check/full, etc
location ~* ^/health_check {
return 404 'Not found';
}
# Load any extra configuration for this vhost
include /etc/nginx/vhost-extras/overleaf/*.conf;
}
@@ -101,14 +101,14 @@ const GitBridgePATManager = {
if (!objToken?.user_id) return null
// does the user still exists and not deleted?
// does the user still exists and not deleted and not suspended?
// tokens of a deleted user are not deleted until user expires
const user = await db.users.findOne(
{ _id: new ObjectId(objToken.user_id) },
{ projection: { _id: 1 } }
{ projection: { _id: 1, suspended: 1 } }
)
if (!user) return null
if (!user || user.suspended) return null
// non-blocking
db.oauthAccessTokens.updateOne(
@@ -96,7 +96,9 @@ async function exportUserProjectsToZip(userId, output) {
`Exporting ${allIds.length} projects for user ${userId} to ${output}`
)
const zipStream = await createZipStreamForMultipleProjectsAsync(allIds, false, null)
const zipStream = await createZipStreamForMultipleProjectsAsync(allIds, false)
// NOTE: createZipStreamForMultipleProjects(projectIds, zipFromHistory, callback) has no
// historyId slot (unlike createZipStreamForProject), so only one argument besides the ids.
zipStream.on('progress', progress => {
updateProgress(progress.entries.total, allIds.length)
@@ -37,8 +37,11 @@ const TrackChangesController = {
async getAllRanges(req, res, next) {
try {
const { project_id } = req.params
const ranges = await DocumentUpdaterHandler.promises.getProjectRanges(project_id)
res.json(ranges)
// document-updater only knows the docs loaded in Redis. Flush them to
// docstore (without unloading) and read every non-deleted doc from there.
await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id)
const docs = await DocstoreManager.promises.getAllRanges(project_id)
res.json(docs.map(doc => ({ id: doc._id, ranges: doc.ranges ?? {} })))
} catch (err) {
next(err)
}
@@ -50,6 +53,9 @@ const TrackChangesController = {
// provided here to assign names to authors who have left the project but still have unaccepted changes.
try {
const { project_id } = req.params
// Same as getAllRanges: a tracked change still in document-updater is
// not in docstore yet, so its author would be missing here.
await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id)
const memberIds = await DocstoreManager.promises.getTrackedChangesUserIds(project_id)
const limit = pLimit(3)
const users = await Promise.all(
@@ -68,6 +68,7 @@ async function refreshLinkedFile(
parentFolderId,
userId
) {
linkedFileData = _normalizeLegacyData(linkedFileData)
logger.debug(
{ projectId, userId, linkedFileData },
'refreshing Zotero linked file'
@@ -108,7 +109,7 @@ async function _getBibtex(linkedFileData) {
return await ZoteroApiClient.getLibraryBibtex(
userId,
linkedFileData.group_id, // == null for main library
linkedFileData.format || 'bibtex'
linkedFileData.format
)
} catch (err) {
@@ -129,6 +130,24 @@ async function _getBibtex(linkedFileData) {
}
}
// Files imported before "Zotero: fix for 6.3.0" use the old key names.
function _normalizeLegacyData(data) {
const {
zoteroGroupId,
importedByUserId,
importedByName,
bibFormat,
...rest
} = data
return {
...rest,
group_id: rest.group_id ?? zoteroGroupId ?? null,
importer_id: rest.importer_id ?? importedByUserId,
importer_name: rest.importer_name ?? importedByName,
format: rest.format ?? bibFormat,
}
}
function _sanitizeData(data) {
return {
provider: 'zotero',
+101
View File
@@ -0,0 +1,101 @@
# Credits & Attribution
This project wraps two upstream bodies of work. Neither is redistributed in
unattributed form; the specifics of what comes from where are listed below.
## 1. grammar-llm — functional core
**Repo:** <https://github.com/whiteh4cker-tr/grammar-llm>
**License:** MIT — Copyright (c) 2025 whiteh4cker-tr
**What this project takes:**
- **The whole correction pipeline** — the pure engine under
`grammar-llm/src/electron/core/`, vendored byte-for-byte into this repo's
`core/` (7 files: `apply.ts`, `clean.ts`, `correction.ts`, `diff.ts`,
`reconstruct.ts`, `sentences.ts`, `types.ts`): sentence splitting, model
output cleaning, word-diff/word-fix extraction, suggestion reconstruction.
Provenance (source commit `ceff65a` + per-file SHA-256) is recorded in
`core/.sync-info`; refresh with `scripts/sync-core.sh`.
- **LLM call semantics** — system prompt, stop sequences, sampling defaults
(temperature 0.7 / topK 40 / topP 0.95 / minP 0.01), and
`node-llama-cpp` session usage mirror
`grammar-llm/src/electron/llamaService.ts` so container output stays
byte-identical to the desktop app for a given model.
- **Model** — GRMR-V3-G4B (fine-tuned by the grammar-llm project; GGUF
weights are fetched from Hugging Face at
`icecubetr/GRMR-V3-G4B-GGUF`, Q4_K_M by default).
- **Model downloader usage** — the download flow in `src/download-model.ts`
follows `grammar-llm`'s use of `createModelDownloader` from
`modelManager.ts`.
> grammar-llm is itself an Electron/React desktop migration of the original
> GrammarLLM Python app (`whiteh4cker-tr/grammar-llm`, `python` branch); the
> engine core it ships is what this container is built on.
Full MIT license text (retained with the vendored `core/` files):
```
MIT License
Copyright (c) 2025 whiteh4cker-tr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## 2. LanguageTool — API compatibility reference
**Project:** <https://languagetool.org> (source:
<https://github.com/languagetool-org/languagetool>)
**License:** GNU Lesser General Public License (LGPL) — see `COPYING.txt` in
the LanguageTool source tree
**What this project takes:** *the wire protocol, not the code.*
- **The v2 REST API contract** this container emulates: `GET /v2/languages`,
`POST /v2/check`, and the response envelope (`software`, `warnings`,
`language`, `matches[]`, `sentenceRanges`) — schema taken from
`languagetool-core/src/main/java/org/languagetool/tools/RuleMatchesAsJsonSerializer.java`,
`languagetool-server/.../ApiV2.java` and `TextChecker.java`, so that
existing LanguageTool clients (Overleaf, editors, CI linters) work
unmodified.
- **Error/status semantics** (`400` missing text, `413` too long, `503`
degraded, `warnings.incompleteResults`) follow LanguageTool's behavior so
clients that special-case it keep working.
- **Drop-in target packaging** — port `8010`, `--allow-origin '*'` behavior
and the "run-a-side" admin story follow the widely deployed
`erikvl87/languagetool` / `Erikvl87/docker-languagetool` images.
No LanguageTool source code is included in this repository; compatibility is
at the HTTP/JSON level only, so the LGPL obligations of the Java source do
not attach to this container. (If you embed this as a *library* rather than a
service, review the LGPL implications yourself.)
## 3. Runtime dependencies
| Dependency | License | Role |
|---|---|---|
| [node-llama-cpp](https://github.com/withcatai/node-llama-cpp) (Gilad S. / withcatai), v3.19.1 | MIT | GGUF inference engine (`llama.cpp` bindings) + `createModelDownloader` used for model fetches |
| [esbuild](https://github.com/evanw/esbuild) | MIT | bundling `src/` → `dist/` |
| Node.js LTS (≥ 20) | MIT (nodejs org) | runtime |
## 4. Integration context
Serves the Overleaf LanguageTool module in this repo
(`services/web/modules/languagetool/`, based on upstream
[yu-i-i/overleaf-cep#179](https://github.com/yu-i-i/overleaf-cep/pull/179))
as a one-for-one replacement for a `languagetool` service container.
+72
View File
@@ -0,0 +1,72 @@
# syntax=docker/dockerfile:1
#
# grammar-llm container — LanguageTool-compatible (v2 API) grammar checker.
# Drop-in replacement for erikvl87/languagetool (docker-languagetool):
# same port (8010), same /v2/languages + /v2/check wire API, backed by the
# grammar-llm engine (GRMR-V3-G4B fine-tuned LLM via node-llama-cpp).
#
# Build (from this project directory):
# docker build -t grammar-llm .
#
# Run (model auto-downloads on first start unless pre-provisioned):
# docker run -p 8010:8010 --name grammar-llm -v grammar-llm-models:/models grammar-llm
#
# The model (~2.5 GB for Q4_K_M) is NOT baked into the image: put it in the
# /models volume (file name GRMR-V3-G4B-Q4_K_M.gguf by default) or let the
# container download it from Hugging Face on first start. See README.md.
ARG NODE_TAG=24
# ---------- build stage ----------
FROM node:${NODE_TAG}-bookworm-slim AS build
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /build
# Install all deps (needed for bundling: esbuild; runtime: node-llama-cpp).
# The @node-llama-cpp/* prebuilt platform package for THIS build platform is
# what makes the bundle loadable; we re-install runtime deps for the TARGET
# platform in the next stage (docker build --platform handles cross-arch).
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY build.mjs ./
COPY core/ core/
COPY src/ src/
RUN node build.mjs \
&& npm prune --omit=dev
# Sanity: the bundle must resolve its only external (node-llama-cpp).
RUN node --input-type=module -e "import('node-llama-cpp').then(() => process.exit(0), (e) => { console.error(e.message); process.exit(1); })"
# ---------- runtime stage ----------
FROM node:${NODE_TAG}-bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive \
NODE_ENV=production \
PORT=8010 \
GRAMMAR_LLM_MODEL_DIR=/models \
GRAMMAR_LLM_PRELOAD=true
WORKDIR /app
# Install runtime-only deps (node-llama-cpp + prebuilt native lib) for the
# target platform of the image.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund \
&& node -e "import('node-llama-cpp').then(() => process.exit(0)).catch(() => process.exit(1))"
COPY --from=build /build/dist ./dist
COPY start.sh ./
RUN chmod +x start.sh \
&& useradd -m -d /home/grammarllm -s /nonexistent grammarllm 2>/dev/null || true \
&& mkdir -p /models \
&& chown -R grammarllm:grammarllm /models /app
USER grammarllm
VOLUME ["/models"]
EXPOSE 8010
# Model download can take a while on first start → generous start period.
HEALTHCHECK --interval=30s --timeout=10s --start-period=1800s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 8010) + '/v2/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
ENTRYPOINT ["./start.sh"]
+133
View File
@@ -0,0 +1,133 @@
# grammar-llm container — development & ops tasks
#
# Everything maps to the documented npm scripts / docs; pick whichever you
# like. Run `make help` (the default) for the list.
SHELL := /bin/bash
NODE ?= node
DOCKER ?= docker
NODE_TAG ?= 24
IMAGE ?= grammar-llm
TAG ?= latest
PORT ?= 8010
CONTAINER_NAME := glm-run
MODEL_VOLUME := glm-models
MODEL_DIR ?= models
MODEL_FILE ?= GRMR-V3-G4B-Q4_K_M.gguf
MODEL_URL ?= https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf
# BASE for the smoke script (server to smoke-test against)
BASE ?= http://127.0.0.1:8010
.PHONY: help
help:
@echo "grammar-llm container — make targets"
@echo
@echo " Local dev"
@echo " install npm install (node-llama-cpp + esbuild)"
@echo " build bundle src/ -> dist/ (esbuild)"
@echo " test build + node --test test/ (no model needed)"
@echo " start run the server locally (needs model; see download-model)"
@echo " download-model fetch GRMR-V3-G4B GGUF into ./$(MODEL_DIR)/ (local server)"
@echo " sync-core re-vendor core/ from grammar-llm (scripts/sync-core.sh)"
@echo " smoke BASE=<url> e2e smoke against a RUNNING server (default $(BASE))"
@echo
@echo " Docker"
@echo " docker-build docker build -t $(IMAGE):$(TAG) ."
@echo " docker-smoke docker-build + run on :$(PORT) + smoke against it"
@echo " docker-run start container on :$(PORT) (model volume, detached)"
@echo " docker-stop stop + rm the container (volume kept)"
@echo " docker-clobber docker-stop + delete the model volume"
@echo
@echo " Misc"
@echo " all build the Docker image (compiles inside the node base image — no local Node needed)"
@echo " docker-test run the unit tests inside a node base-image container (no local Node needed)"
@echo " logs tail the running container's logs"
@echo " clean rm -rf dist node_modules (keep core/, models)"
.PHONY: install
install:
npm install
.PHONY: ensure-deps
ensure-deps:
@if [ ! -d node_modules ]; then \
echo "node_modules missing — running npm install…"; \
npm install; \
fi
.PHONY: build
build: ensure-deps
$(NODE) build.mjs
.PHONY: test
test: ensure-deps
$(NODE) build.mjs --quiet && $(NODE) --test test/*.test.mjs
.PHONY: start
start: ensure-deps build
GRAMMAR_LLM_MODEL_DIR=$(MODEL_DIR) PORT=$${PORT:-8010} $(NODE) dist/server.mjs
.PHONY: download-model
download-model: ensure-deps build
mkdir -p $(MODEL_DIR)
$(NODE) dist/download-model.mjs --url "$(MODEL_URL)" --out "$(MODEL_DIR)/$(MODEL_FILE)"
.PHONY: sync-core
sync-core:
bash scripts/sync-core.sh $(GRAMMAR_LLM_SRC)
.PHONY: smoke
smoke:
BASE=$(BASE) bash test/smoke.sh
# --- docker ---------------------------------------------------------------
.PHONY: docker-build
docker-build:
$(DOCKER) build -t $(IMAGE):$(TAG) .
.PHONY: docker-test
docker-test:
@echo "running the test suite inside the $(NODE_TAG:24=24)-bookworm-slim base image (no local Node needed)"
$(DOCKER) run --rm -v "$(PWD):/src:ro" -w /app node:$(NODE_TAG)-bookworm-slim sh -c \
'cp -r /src/. /app && npm ci --no-audit --no-fund && node build.mjs --quiet && node --test test/*.test.mjs'
.PHONY: docker-smoke
docker-smoke: docker-build
$(DOCKER) rm -f $(CONTAINER_NAME) 2>/dev/null || true
$(DOCKER) run -d --name $(CONTAINER_NAME) -p $(PORT):$(PORT) -v $(MODEL_VOLUME):/models $(IMAGE):$(TAG)
@echo "waiting for model load…"
@for i in $$(seq 1 120); do \
[ "$$($(DOCKER) logs $(CONTAINER_NAME) 2>&1 | grep -c 'model loaded and ready')" -ge 1 ] && break; \
sleep 5; \
done
BASE=http://127.0.0.1:$(PORT) bash test/smoke.sh
@echo "container $(CONTAINER_NAME) left running — stop with: make docker-stop"
.PHONY: docker-run
docker-run:
$(DOCKER) rm -f $(CONTAINER_NAME) 2>/dev/null || true
$(DOCKER) run -d --name $(CONTAINER_NAME) -p $(PORT):$(PORT) -v $(MODEL_VOLUME):/models $(IMAGE):$(TAG)
@echo "$(CONTAINER_NAME) is up on :$(PORT) — check with: make smoke BASE=http://127.0.0.1:$(PORT)"
.PHONY: docker-stop
docker-stop:
$(DOCKER) stop $(CONTAINER_NAME) 2>/dev/null || true
$(DOCKER) rm $(CONTAINER_NAME) 2>/dev/null || true
.PHONY: docker-clobber
docker-clobber: docker-stop
$(DOCKER) volume rm $(MODEL_VOLUME)
.PHONY: logs
logs:
$(DOCKER) logs -f $(CONTAINER_NAME)
.PHONY: clean
clean:
rm -rf dist node_modules
.PHONY: all
all: docker-build
@echo "all done — image: $(IMAGE):$(TAG) (compiled inside the node base image; no local Node needed)"
+373
View File
@@ -0,0 +1,373 @@
# grammar-llm container
A **drop-in replacement for the LanguageTool container**
([docker-languagetool](https://github.com/Erikvl87/docker-languagetool),
`erikvl87/languagetool`) for grammar checking — same port, same wire API —
powered by the **grammar-llm** engine: a fine-tuned local LLM
(`GRMR-V3-G4B`, [whiteh4cker-tr/grammar-llm](https://github.com/whiteh4cker-tr/grammar-llm))
running through [`node-llama-cpp`](https://github.com/withcatai/node-llama-cpp).
The intent: one admin can run the classic `languagetool` container, another
can run this container — and the rest of the stack (e.g. the Overleaf
LanguageTool module) points at whichever one is up, without code changes.
```
┌────────────┐ LanguageTool v2 API (port 8010) ┌───────────────────────────┐
│ Overleaf │ ───────────────────────────────────▶ │ grammar-llm container │
│ (or any │ ◀─────────────────────────────────── │ ┌─────────────────────┐ │
│ LT client) │ { software, matches[] … } JSON │ │ HTTP server (node) │ │
└────────────┘ │ │ grammar-llm core* │ │
│ │ node-llama-cpp + │ │
│ │ GRMR-V3-G4B model │ │
│ └─────────────────────┘ │
└───────────────────────────┘
* vendored from /data_1/compare/grammar-llm/src/electron/core (see scripts/sync-core.sh)
```
## Quickstart
```sh
# from this directory
docker build -t grammar-llm .
docker run -d --name grammar-llm -p 8010:8010 -v grammar-llm-models:/models grammar-llm
# or with compose
docker compose up -d
```
First start downloads the model (~2.4 GB) into the `/models` volume from
Hugging Face (`icecubetr/GRMR-V3-G4B-GGUF`, Q4_K_M). Subsequent starts use the
cached file. The server answers immediately; `/v2/health` reports
`state: loading/ready` and `/v2/check` returns `503` until the model is
loaded — the same "silent linter" behavior Overleaf already has for timeouts.
Probe it like LanguageTool:
```sh
curl -s http://localhost:8010/v2/languages | jq '.[0]'
curl -s -X POST http://localhost:8010/v2/check \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'language=auto' \
--data-urlencode 'level=picky' \
--data-urlencode 'text=I has two cats. They is very good.' | jq '.matches'
```
Use it as the Overleaf LT backend (see `services/web/modules/languagetool`):
```sh
# env for the overleaf web service — unchanged from the languagetool setup,
# just point at this container:
LANGUAGE_TOOL_ENABLED=true
LANGUAGE_TOOL_HOST=grammar-llm # or 127.0.0.1 on the host
LANGUAGE_TOOL_PORT=8010
# …or explicitly:
# LANGUAGE_TOOL_URL=http://grammar-llm:8010
```
## API (LanguageTool v2 subset)
| Method & path | LanguageTool equivalent | Notes |
|--------------------|-------------------------|-------|
| `GET /v2/languages` | `GET /v2/languages` | English variants only (model is English-only) |
| `POST /v2/check` | `POST /v2/check` | form-urlencoded **or** JSON body |
| `GET /v2/version` | version info | container/version + engine state |
| `GET /v2/health` | — (not an LT endpoint) | model state, counter; used by Docker HEALTHCHECK |
| `GET /` | — | JSON info payload (endpoints, software) |
### `POST /v2/check` parameters
| Param | Status | Notes |
|-------|--------|-------|
| `text` | **used** | plain text to check |
| `data` | **used** | JSON `{"text": "…"}` or `{"annotation": [{text|markup}, …]}` — Overleaf sends `annotation` (LaTeX markup = "ignore this text"); markup is skipped and offsets stay aligned with the editor document |
| `language` | **used** | English codes (`en`, `en-US`, `en-GB`, …) and `auto`. Non-English: by default → `200` with empty matches (linter silent; admin can set `GRAMMAR_LLM_UNSUPPORTED_LANGUAGE=error` for HTTP 400 instead) |
| `level` (`default`/`picky`) | **used** | **Picky mode for the LLM** (see below). `picky` → stricter prompt (style + word choice + phrasing on top of base grammar). `default` → base prompt only (byte-identical to the desktop app). Always forwarded verbatim to the real LanguageTool box when one is configured. |
| `disabledRules` | forwarded / ignored | Forwarded to the real LanguageTool box (understands LT rule ids); ignored by the LLM (it never produces them) |
| `enabled`, `disabled`, `motherTongue`, `replacedText`, … | accepted, ignored | tolerance for other LT clients |
### Response shape
Full LanguageTool envelope (schema reference:
`org.languagetool.tools.RuleMatchesAsJsonSerializer` in the `languagetool` source):
```jsonc
{
"software": { "name": "LanguageTool", "version": "6.8", "apiVersion": 1, "premium": false, "…": "…" },
"warnings": { "incompleteResults": false }, // true + reason if the time budget cut the check short
"language": { "name": "English (US)", "code": "en-US", "detectedLanguage": { … } },
"matches": [
{
"message": "Shouldn't this be 'the'?",
"shortMessage": "the",
"replacements": [ { "value": "the" } ], // Overleaf shows the first 5 as apply-buttons
"offset": 24, // absolute into the submitted document
"length": 3,
"context": { "text": "… I has two …", "offset": 19, "length": 3 },
"type": { "typeName": "grammar" },
"rule": {
"id": "GRAMMAR_LLM",
"description": "Grammar, spelling, or style issue detected by grammar-llm (GRMR-V3-G4B)",
"issueType": "Grammar",
"category": { "id": "GRAMMAR", "name": "Grammar" }, // deliberately NOT 'TYPOS' (see below)
"tags": ["LLM", "GRMR-V3-G4B"]
},
"ignoreForIncompleteSentence": false,
"contextForSureMatch": -1
}
],
"sentenceRanges": [[0, 16], [17, 33]]
}
```
Notes on the mapping:
- **Word-level fixes** (replacement / deletion from the core's `wordFixes`)
become one match each. **Pure insertions** (zero-width core fixes) are
anchored on the neighboring token — e.g. the core's `slow → slowly` +
insert-`,` becomes two matches that re-compose exactly to `slowly, …`.
- **Quoted-word artifact unwrapping**: GRMR models sometimes emit the
corrected word wrapped in quotes (`goed → "good"`). The desktop app shows
that in its review UI; a LanguageTool client would apply it verbatim and
inject literal quote characters into the document — so the adapter strips
one pair of matching surrounding quotes from single-word replacements.
(Vendor core is untouched; this happens in `src/languagetool.ts`.)
- **Sentence-level rewrites** without word fixes become one match over the
changed span.
- `category.id` is **`GRAMMAR`**, not `TYPOS`: Overleaf's editor filters out
`TYPOS` matches while the Hunspell spellchecker is active — this way
grammar-llm suggestions are never silently filtered, while both tools stay
usable together.
- Matches never span LaTeX `markup` segments: the core pipeline runs per
plain-text run (markup is invisible to the model, as in LanguageTool).
- Errors mirror LanguageTool: `400` missing `text`/`data`, `413` text too
long, `503` model not ready / server busy, `500` unexpected.
## Model
| What | Where |
|------|-------|
| Default model | `GRMR-V3-G4B-Q4_K_M.gguf` (2.4 GB) from `https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/` |
| Stored in | `/models` (container volume) |
| Higher quality | `GRMR-V3-G4B-Q8_0.gguf` (set `GRAMMAR_LLM_MODEL_FILE` + matching `GRAMMAR_LLM_MODEL_URL`) |
| Any custom GGUF | set `GRAMMAR_LLM_MODEL_FILE` and pre-place the file in `/models` (or point `GRAMMAR_LLM_MODEL_URL` at it) |
The model download is done by the container's own `dist/download-model.mjs`
(node-llama-cpp `createModelDownloader`, the same API grammar-llm's desktop
modelManager uses). It's skipped automatically if the file already exists.
## Configuration
| Env | Default | Description |
|-----|---------|-------------|
| `PORT` | `8010` | HTTP port (matches docker-languagetool & Overleaf's default) |
| `HOST` | `0.0.0.0` | bind address |
| `GRAMMAR_LLM_MODEL_DIR` | `/models` | model storage dir |
| `GRAMMAR_LLM_MODEL_FILE` | `GRMR-V3-G4B-Q4_K_M.gguf` | model file name inside the dir |
| `GRAMMAR_LLM_MODEL_URL` | HF Q4_K_M URL | download source (only used when file is absent) |
| `GRAMMAR_LLM_DOWNLOAD_AT_START` | `true` | auto-download at boot when file is absent |
| `GRAMMAR_LLM_PRELOAD` | `true` | start model load at boot (vs. first check) |
| `GRAMMAR_LLM_CONTEXT_SIZE` | `8192` | LLM context (desktop-compatible clamp 256…131072) |
| `GRAMMAR_LLM_CONCURRENCY` | `1` | (reserved; single shared session today) |
| `GRAMMAR_LLM_MAX_TEXT_LENGTH` | `100000` | max characters per request (→ 413; matches Overleaf's 100 KB cap) |
| `GRAMMAR_LLM_MAX_RUNS` | `0` (unlimited) | cap on checkable text runs per request (→ `incompleteResults`) |
| `GRAMMAR_LLM_REQUEST_TIMEOUT_MS` | `55000` | per-request time budget; remaining sentences pass through unchanged + `warnings.incompleteResults` (keep under Overleaf's 60 s proxy timeout) |
| `GRAMMAR_LLM_SENTENCE_TIMEOUT_MS` | `20000` | per-sentence guard (informational) |
| `GRAMMAR_LLM_MAX_IN_FLIGHT` | `4` | concurrent checks before 503 |
| `GRAMMAR_LLM_MAX_BODY_BYTES` | `2097152` | HTTP body cap |
| `GRAMMAR_LLM_LANGUAGES` | `auto,en,en-us,en-gb,en-au,en-ca,en-ie,en-nz,en-in,en-za` | accepted `language` codes (lower-cased) |
| `GRAMMAR_LLM_UNSUPPORTED_LANGUAGE` | `empty` | `empty` = 200 + no matches, `error` = 400 |
| `GRAMMAR_LLM_BACKEND` | `local` | `local` (node-llama-cpp) or `openai` (OpenAI-compatible remote endpoint) |
| `GRAMMAR_LLM_OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL (OpenAI, OpenRouter, vLLM, llama.cpp `llama-server`, Ollama `/v1`, a self-hosted GRMR endpoint, …) |
| `GRAMMAR_LLM_OPENAI_API_KEY` | _empty_ | Bearer token (only if the endpoint requires one) |
| `GRAMMAR_LLM_OPENAI_MODEL` | _required for openai_ | model name/id on the server (e.g. `gpt-4o-mini`, or your GRMR model) |
| `GRAMMAR_LLM_OPENAI_STYLE` | `openai` | `openai` = standard `/chat/completions`; `ollama` = native `/api/chat` with `think:false` (needed for Ollama Qwen3-family models, see below) |
| `GRAMMAR_LLM_OPENAI_TIMEOUT_MS` | `60000` | timeout for one remote call |
| `GRAMMAR_LLM_LANGUAGETOOL_URL` | _empty_ | optional **real LanguageTool container to query first** (e.g. `http://languagetool:8010`); empty = LLM only |
| `GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS` | `45000` | real-LT call timeout — its window to win the race |
| `GRAMMAR_LLM_CASCADE_BUDGET_MS` | `55000` | **shared wall-clock budget for the whole cascaded check** (LT attempt + LLM fallback). Must stay under the LT client's cap — Overleaf's controller times out at **60 s and then silently shows nothing** |
| `GRAMMAR_LLM_LLM_GRACE_MS` | `400` | how long a fast real LT can win before the LLM starts in parallel (keeps the compute-saving benefit; 0 = pure parallel) |
| `GRAMMAR_LLM_DEFAULT_LEVEL` | `default` | level used when the client sends none (`default` \| `picky`) |
| `GRAMMAR_LLM_ALLOW_ORIGIN` | `*` | CORS origin (docker-languagetool runs with `--allow-origin '*'`) |
| `GRAMMAR_LLM_TEMPERATURE` / `GRAMMAR_LLM_TOP_K` / `GRAMMAR_LLM_TOP_P` / `GRAMMAR_LLM_MIN_P` | `0.7 / 40 / 0.95 / 0.01` | sampling — identical defaults to the grammar-llm desktop app |
| `GRAMMAR_LLM_REPORTED_LT_VERSION` | `6.8` | version string reported as "LanguageTool" |
| `GRAMMAR_LLM_LOG_LEVEL` | `info` | `debug\|info\|warn\|error` |
| `LLAMA_USE_MLOCK`, `GGML_N_THREADS`, … | — | node-llama-cpp / llama.cpp passthrough env vars work as-is |
## Check routing & modes
Three independent, composable knobs control where and how a check is computed.
### 1) Optional real LanguageTool first (compute-saving cascade)
Point this container at a real LanguageTool box and it runs **first**; the LLM
only runs when LanguageTool found nothing — saving local inference on the
cases the rule engine already solved (typo, casing, punctuation):
```sh
GRAMMAR_LLM_LANGUAGETOOL_URL=http://languagetool:8010 # same compose network
```
**How the race works** (all on ONE shared budget, `GRAMMAR_LLM_CASCADE_BUDGET_MS,
default 55 s` — chosen to sit under Overleaf's 60 s controller timeout):
1. The request is forwarded **verbatim** (`text` **or** the original
`data.annotation`, `language`, `disabledRules`, and **`level` —
`picky`→`picky`**) to `$GRAMMAR_LLM_LANGUAGETOOL_URL/v2/check`.
2. After a short grace (`GRAMMAR_LLM_LLM_GRACE_MS`, default 400 ms — long
enough that a *warm* real LT, which answers in a few hundred ms, wins for
free and the LLM start is **cancelled**), the LLM runs **in parallel**.
3. Resolution:
- real-LT outcome with **≥1 match** → returned as-is (same envelope
Overleaf already consumes);
- LLM ready → returned; if the LT is still running it gets a final capped
grace (≤ 1.2 s) to land a match ("real LT first", with a lid on it);
- an LLM 400/503 outcome only surfaces when the LT had nothing either.
Why parallel: a *serial* "wait for LT, then run LLM" pushed the total past
clients' hard caps (measured 15 s LT timeout + 56 s LLM = 71 s > Overleaf's
60 s → the UI silently showed nothing). The shared budget guarantees the
response always lands in time — worst case is a *partial* result
(`warnings.incompleteResults`), never a client-side timeout. The fallback is
silent and automatic, so a dead LanguageTool box never breaks checking — it
just costs one attempt. Set the URL back to empty to disable.
### 2) Optional remote LLM (OpenAI-compatible API)
When the Overleaf host can't afford local inference, redirect the LLM step to
any OpenAI-compatible `chat/completions` endpoint (OpenAI, OpenRouter, vLLM,
llama.cpp `llama-server`, Ollama `/v1`, a hosted GRMR/V3 LoRA, …):
```sh
GRAMMAR_LLM_BACKEND=openai
GRAMMAR_LLM_OPENAI_BASE_URL=https://api.openai.com/v1 # or your endpoint
GRAMMAR_LLM_OPENAI_MODEL=gpt-4o-mini # required
GRAMMAR_LLM_OPENAI_API_KEY=sk-… # only if needed
```
**Ollama (Qwen3 and similar reasoning models):** use the native style instead. On
Ollama's OpenAI-compat layer the `think:false` flag is ignored for Qwen3-family
models and the response can spend *all* completion tokens on hidden reasoning
(measured: 111–400 tokens burned for ~15-token answers, occasionally hitting
max_tokens and returning an empty answer with HTTP 200). The native
`/api/chat` honors top-level `think:false` and suppresses reasoning entirely
(measured: ~15 completion tokens, same answer):
```sh
GRAMMAR_LLM_BACKEND=openai
GRAMMAR_LLM_OPENAI_STYLE=ollama
GRAMMAR_LLM_OPENAI_BASE_URL=http://127.0.0.1:11434 # /v1 suffix is stripped automatically
GRAMMAR_LLM_OPENAI_MODEL=qwen3.8:latest
GRAMMAR_LLM_OPENAI_API_KEY=ollama # Ollama ignores it
```
If the Ollama server only listens on the host's loopback, add a tiny TCP relay
container bound to the docker bridge gateway IP (see
`/data_1/docker/compose/ollama-gw/` in this deployment) so containers on the
bridge network can reach it without exposing Ollama to the LAN.
With `backend=openai` **no local model is downloaded or loaded** (`start.sh`
skips the model step; the image is unchanged). The same grammar-llm core
pipeline (sentence diff, word-fix extraction, quote cleanup, LanguageTool
mapping) runs locally — only the per-sentence generation call is remote.
Prompt/stop/sampling semantics match the local backend exactly.
### 3) Picky mode for the LLM (`level`)
LanguageTool's `default` vs `picky` maps to two LLM prompt modes, mirroring
LT's idea that *picky adds rule families on top of the default set*:
| level | LLM behavior |
|------------|--------------|
| `default` | base correction prompt only — **byte-identical to the grammar-llm desktop app** (grammar, spelling, punctuation). |
| `picky` | base prompt **plus** a "highly picky editor" addendum: word choice, awkward/redundant phrasing, weak punctuation, style. Produces the fuller suggestion set. |
`/v2/check` sends whatever `level` the client gave (Overleaf default is
`picky`). When a client sends no level, `GRAMMAR_LLM_DEFAULT_LEVEL` decides
(default `default`). The requested level is always forwarded verbatim to the
real LanguageTool box (step 1), so a `picky` Overleaf tab still gets
`level=picky` there.
> Tuning: the picky addendum text is fixed in code (
> `DEFAULT_PICKY_EXTRA` in `src/llama.ts`). If you want it stronger or
> weaker, send a PR or vendor the constant — the level *mechanism* (two
> prompts + LT forwarding) stays the same.
## Behavior & caveats (vs. LanguageTool)
| Topic | LanguageTool | grammar-llm container |
|-------|--------------|-----------------------|
| Languages | 30+ | English only (GRMR-V3-G4B). Non-English → silent (or 400, configurable) |
| Speed | ms | seconds per sentence (CPU). Short docs fine; very long docs hit the time budget → `incompleteResults` + partial matches (a LanguageTool-native concept) |
| Rules/levels | fine-grained rules, levels `default…picky` | **level supported**: `default` (base prompt, desktop parity) / `picky` (base + style addendum); `disabledRules` forward to a real LT box when configured, ignored by the LLM |
| Where it runs | same box | **local** node-llama-cpp, or **remote** OpenAI-compatible endpoint (`GRAMMAR_LLM_BACKEND=openai`), or a real-LT-first cascade (`GRAMMAR_LLM_LANGUAGETOOL_URL`) — pick your compute strategy per deployment |
| Dictionary | rule-based + user dicts | statistical (model) |
| Memory | ~1–2 GB | model ~2.6 GB (Q4) + context ≈ **give the container ≥ 4 GB RAM** |
| GPU | n/a | auto-detected by node-llama-cpp (build/run with a CUDA build + `--gpus all` for best performance) |
| Telemetry/premium endpoints | has some | not needed by Overleaf; others → 404 |
The engine output is byte-identical to the grammar-llm desktop app for a given
model: same system prompt, stop sequences, sampling, sentence pipeline
(vendored `core/` + mirrored `llamaService` semantics).
## Development
**Build the Docker image** — no local Node installation required; the
compile (`npm ci` + esbuild) happens inside the `node:24-bookworm-slim`
base image's own Node:
```sh
make all # docker build -t grammar-llm:latest .
```
**Local development** (optional; needs Node ≥ 20 + npm to run the bundle
and unit tests directly on the host):
```sh
npm install
npm run build # esbuild → dist/*.mjs (self-contained; node-llama-cpp external)
npm test # build + node --test (LLM-free: fake engine + unit tests)
npm run smoke # end-to-end curl against a running local server (needs model)
```
`Makefile` exposes the same targets plus Docker ops — `make help`:
```make
make all # build the Docker image (default target; no local Node needed)
make docker-test # run the unit tests inside a node base-image container (no local Node needed)
make docker-build # docker build -t grammar-llm:latest .
make docker-smoke # build + container on :8010 + e2e smoke
make docker-run # start the container (model volume kept)
make docker-stop # stop + rm (volume kept) / make docker-clobber (also deletes volume)
make logs # tail container logs
```
### Updating the vendored core
The functional core is vendored under `core/` (snapshot of
`grammar-llm/src/electron/core/*.ts` — provenance in `core/.sync-info`):
```sh
scripts/sync-core.sh # from /data_1/compare/grammar-llm (default)
scripts/sync-core.sh /path/to/grammar-llm
npm run build && npm test
```
`src/llama.ts` mirrors `grammar-llm/src/electron/llamaService.ts` — when the
desktop changes prompts/sampling, keep both in sync.
## Docker build layout
- **builder**: `node:24-bookworm-slim`, `npm ci` (installs
`node-llama-cpp` + platform prebuilds + `esbuild`), bundles `dist/`.
- **runtime**: `node:24-bookworm-slim`, `npm ci --omit=dev` (native libs for
the target platform), non-root user, `VOLUME /models`, `HEALTHCHECK` against
`/v2/health` (1800 s start-period to cover first-run model download).
## License & credits
See [CREDITS.md](CREDITS.md). The functional core is vendored from
[whiteh4cker-tr/grammar-llm](https://github.com/whiteh4cker-tr/grammar-llm)
(MIT, © 2025 whiteh4cker-tr) — provenance and per-file hashes in
`core/.sync-info`. The external protocol is a compatibility implementation of
the [LanguageTool](https://languagetool.org) v2 REST API (LGPL; API-level
only, no LT code included).
+25
View File
@@ -0,0 +1,25 @@
// Build: bundle the TypeScript server + vendored core into self-contained
// ES modules under dist/. node-llama-cpp stays external (native, platform
// packages installed by npm at install time).
import { build } from 'esbuild';
const quiet = process.argv.includes('--quiet');
await build({
entryPoints: ['src/server.ts', 'src/download-model.ts', 'src/languagetool.ts', 'src/config.ts'],
outdir: 'dist',
outExtension: { '.js': '.mjs' },
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
// Native module: keep as a runtime import; npm installs the right
// @node-llama-cpp/* prebuilt for the target platform.
external: ['node-llama-cpp'],
sourcemap: false,
minify: false,
logLevel: quiet ? 'warning' : 'info',
});
if (!quiet) console.log('✓ build complete → dist/ (server.mjs, download-model.mjs, languagetool.mjs, config.mjs)');
@@ -0,0 +1,34 @@
services:
grammar-llm:
image: grammar-llm:latest
container_name: languagetool
hostname: languagetool
restart: unless-stopped
# Optional environment (uncomment and set as a mapping — compose rejects
# an environment key with no entries):
# environment:
# GRAMMAR_LLM_MODEL_FILE: GRMR-V3-G4B-Q8_0.gguf # optional: higher-quality (slower) quant
# GRAMMAR_LLM_MODEL_URL: "..." # optional: custom model source
# GRAMMAR_LLM_DOWNLOAD_AT_START: "true" # default: download model on first start
# GRAMMAR_LLM_REQUEST_TIMEOUT_MS: "55000" # per-request time budget
# GRAMMAR_LLM_PRELOAD: "true" # load model at boot
volumes:
- ./models:/models # the model downloaded here (make download-model → ./models/GRMR-V3-G4B-Q4_K_M.gguf); survives container recreation
# For NVIDIA GPU inference (build with the linux-x64-cuda prebuilt and
# uncomment; node-llama-cpp auto-detects CUDA when available):
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
networks:
- overleaf-network
networks:
overleaf-network:
external: true
@@ -0,0 +1,10 @@
2026-10-28T01:38:51Z
source: /data_1/compare/grammar-llm/src/electron/core
ceff65a
57303d960bf50d2084f71c80149f94e593f1927f4cc6eb1019ce2bd8b2961f73 /data_1/compare/grammar-llm/src/electron/core/apply.ts
7c851d7a0c9fd3118a37119d9850ef149792381f82687cee70b58800dfda402d /data_1/compare/grammar-llm/src/electron/core/clean.ts
57d585afe1ac6e232d05f03e46c33a2b3ae2c65a18478c798ad3d6f9bd68892f /data_1/compare/grammar-llm/src/electron/core/correction.ts
d97ad1c71100da76c3ce0b356727aa51a5f66c43afaf4f73a75d0b125072c594 /data_1/compare/grammar-llm/src/electron/core/diff.ts
f750127beb34b35fb1de3dab02ab402d2a29c2e52d852079f49af22a4e923e8a /data_1/compare/grammar-llm/src/electron/core/reconstruct.ts
5588044015fde745805ad194d582a8604b3db2636b59c1c36aeda8ecec376593 /data_1/compare/grammar-llm/src/electron/core/sentences.ts
133f88bc76531a8727f7c0346f1ae2a4be7ec519532d16409b8c84648fa48ebc /data_1/compare/grammar-llm/src/electron/core/types.ts
+76
View File
@@ -0,0 +1,76 @@
import type { Suggestion } from './types.js';
export function findAllOccurrences(haystack: string, needle: string): Array<[number, number]> {
if (!needle || !haystack) return [];
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(escaped, 'g');
const matches: Array<[number, number]> = [];
let match: RegExpExecArray | null;
while ((match = re.exec(haystack)) !== null) {
matches.push([match.index, match.index + needle.length]);
if (match.index === re.lastIndex) re.lastIndex++;
}
return matches;
}
function nearestOccurrence(occurrences: Array<[number, number]>, approxStart: number): [number, number] {
return occurrences.reduce((best, occ) =>
Math.abs(occ[0] - approxStart) < Math.abs(best[0] - approxStart) ? occ : best,
);
}
export function applySuggestion(text: string, suggestionIndex: number, suggestions: Suggestion[]): string {
if (suggestionIndex < 0 || suggestionIndex >= suggestions.length) {
throw new RangeError('Invalid suggestion index');
}
const suggestion = suggestions[suggestionIndex];
const { start_index: start, end_index: end } = suggestion;
if (start >= 0 && start <= end && end <= text.length && text.slice(start, end) === suggestion.original) {
return text.slice(0, start) + suggestion.corrected + text.slice(end);
}
const occurrences = findAllOccurrences(text, suggestion.original);
if (occurrences.length > 0) {
const [tStart, tEnd] = nearestOccurrence(occurrences, start);
return text.slice(0, tStart) + suggestion.corrected + text.slice(tEnd);
}
return text;
}
export function applySuggestionsBulk(text: string, suggestions: Suggestion[]): string {
if (suggestions.length === 0) return text;
const sorted = suggestions
.filter((s) => s.start_index >= 0 && s.end_index >= 0 && s.start_index <= s.end_index)
.sort((a, b) => b.start_index - a.start_index);
const appliedIntervals: Array<[number, number]> = [];
let result = text;
for (const s of sorted) {
const { start_index: start, end_index: end } = s;
let candidate: [number, number] | null = null;
if (end <= result.length && result.slice(start, end) === s.original) {
candidate = [start, end];
} else {
const occurrences = findAllOccurrences(result, s.original);
if (occurrences.length > 0) {
candidate = nearestOccurrence(occurrences, start);
}
}
if (!candidate) continue;
const [cStart, cEnd] = candidate;
const overlaps = appliedIntervals.some(([aStart, aEnd]) => !(cEnd <= aStart || cStart >= aEnd));
if (overlaps) continue;
result = result.slice(0, cStart) + s.corrected + result.slice(cEnd);
appliedIntervals.push([cStart, cEnd]);
}
return result;
}
+87
View File
@@ -0,0 +1,87 @@
const TEMPLATE_TAG_RE = /<\|.*?\|>/g;
const QUOTE_PERIOD_RE = /([.!?])(["'])\s*\./g;
const QUOTE_DUP_RE = /([.!?])(["'])\s*\1/g;
// Mojibake: the UTF-8 bytes of smart quotes (e.g. U+201C = E2 80 9C) can arrive
// as Latin-1/CP1252 characters (\u00e2\u20ac\u0153 = "“", or \u00e2\u0080\u009c
// when the C1 control bytes survive). Models fine-tuned on scraped data often
// reproduce these instead of proper quotes. Replace longer patterns first.
const MOJIBAKE_PATTERNS: Array<[RegExp, string]> = [
[/\u00e2\u20ac\u0153/g, '\u201C'], // “
[/\u00e2\u0080\u009c/g, '\u201C'], // “ (raw control bytes)
[/\u00e2\u20ac\u009d/g, '\u201D'], // ”
[/\u00e2\u0080\u009d/g, '\u201D'], // ” (raw control bytes)
[/\u00e2\u20ac\u02dc/g, '\u2018'], // ‘
[/\u00e2\u0080\u0098/g, '\u2018'], // ‘ (raw control bytes)
[/\u00e2\u20ac\u2122/g, '\u2019'], // ’
[/\u00e2\u0080\u0099/g, '\u2019'], // ’ (raw control bytes)
[/\u00e2\u20ac\u00a6/g, '\u2026'], // …
[/\u00e2\u0080\u00a6/g, '\u2026'], // … (raw control bytes)
];
/** Normalize mojibake smart quotes back to proper Unicode characters. */
export function fixMojibakeQuotes(s: string): string {
let result = s;
for (const [pattern, replacement] of MOJIBAKE_PATTERNS) {
result = result.replace(pattern, replacement);
}
return result;
}
const INSTRUCTION_PREFIXES = [
'correct the grammar and spelling of this sentence:',
'here is the corrected sentence:',
'corrected sentence:',
'the corrected version is:',
'grammar correction:',
'corrected:',
];
export function cleanCorrectedText(corrected: string, original: string): string {
if (!corrected) return original;
let result = fixMojibakeQuotes(corrected).replace(TEMPLATE_TAG_RE, '').trim();
for (const prefix of INSTRUCTION_PREFIXES) {
if (result.toLowerCase().startsWith(prefix)) {
result = result.slice(prefix.length).trim();
result = result.replace(/^[:]\s*/, '');
}
}
const words = result.split(/\s+/);
if (words.length > 10) {
for (let i = 0; i < words.length - 5; i++) {
const segment = words.slice(i, i + 5).join(' ');
if (words.slice(i + 5).join(' ').includes(segment)) {
result = words.slice(0, i + 5).join(' ');
break;
}
}
}
if (original && /^[A-Z]/.test(original) && result && /^[a-z]/.test(result)) {
result = result[0].toUpperCase() + result.slice(1);
}
for (let i = 0; i < 3; i++) {
const before = result;
result = result.replace(QUOTE_PERIOD_RE, '$1$2');
result = result.replace(QUOTE_DUP_RE, '$1$2');
if (result === before) break;
}
if (original && /[.!?]$/.test(original) && result && !/[.!?]$/.test(result)) {
result += original[original.length - 1];
}
return result.trim();
}
export function isOnlyQuoteChange(original: string, corrected: string): boolean {
if (original === corrected) return false;
const normalize = (s: string): string => s.replace(/[\u2018\u2019]/g, "'").replace(/[\u201C\u201D]/g, '"');
if (normalize(original) === normalize(corrected)) return true;
if (normalize(original.trim()) === normalize(corrected.trim())) return true;
return false;
}
@@ -0,0 +1,76 @@
import { splitIntoSentences } from './sentences.js';
import { cleanCorrectedText, isOnlyQuoteChange } from './clean.js';
import { highlightWordDifferences, extractWordFixes } from './diff.js';
import { reconstructTextFromSentences } from './reconstruct.js';
import type { CorrectionResponse, SentenceCorrector } from './types.js';
export function cleanQuotePunctuation(s: string): string {
let result = s;
for (let i = 0; i < 3; i++) {
const before = result;
result = result.replace(/([.!?])(["'])\s*\./g, '$1$2');
result = result.replace(/([.!?])(["'])\s*\1/g, '$1$2');
if (result === before) break;
}
return result;
}
export async function correctText(text: string, corrector: SentenceCorrector): Promise<CorrectionResponse> {
const trimmed = text.trim();
if (!trimmed) return { suggestions: [], correctedText: '' };
const sentenceData = splitIntoSentences(text);
const suggestions: CorrectionResponse['suggestions'] = [];
const correctedSentences: string[] = [];
for (let i = 0; i < sentenceData.length; i++) {
const sent = sentenceData[i];
const sentence = sent.text;
if (sentence.length < 2) {
correctedSentences.push(sentence);
continue;
}
let corrected = await corrector.correct(sentence);
corrected = cleanCorrectedText(corrected, sentence);
if (isOnlyQuoteChange(sentence, corrected)) {
corrected = sentence;
}
if (corrected.length > sentence.length * 2) {
corrected = sentence;
}
corrected = cleanQuotePunctuation(corrected);
// Diff once: restore the original quote style (curly vs straight) so
// quote changes are never applied to the user's text.
const diff = highlightWordDifferences(sentence, corrected);
corrected = diff.preservedCorrected;
correctedSentences.push(corrected);
if (
corrected.toLowerCase().trim() !== sentence.toLowerCase().trim() &&
corrected.trim() !== sentence.trim() &&
corrected.length <= sentence.length * 1.5 &&
!isOnlyQuoteChange(sentence, corrected)
) {
suggestions.push({
original: sentence,
corrected,
sentence: `Sentence ${i + 1}`,
start_index: sent.start,
end_index: sent.end,
original_highlighted: diff.originalHighlighted,
corrected_highlighted: diff.correctedHighlighted,
wordFixes: extractWordFixes(sentence, corrected, sent.start),
});
}
}
const correctedText = cleanQuotePunctuation(
reconstructTextFromSentences(text, sentenceData, correctedSentences),
);
return { suggestions, correctedText };
}
+222
View File
@@ -0,0 +1,222 @@
import { diffArrays } from 'diff';
import type { WordFix } from './types.js';
export function tokenize(text: string): string[] {
const tokens: string[] = [];
let current = '';
for (const char of text) {
if (/\s/.test(char)) {
if (current) {
tokens.push(current);
current = '';
}
tokens.push(char);
} else if ('.,!?;:"()[]{}\u201C\u201D'.includes(char)) {
if (current) {
tokens.push(current);
current = '';
}
tokens.push(char);
} else {
current += char;
}
}
if (current) tokens.push(current);
return tokens;
}
// Curly quotes compare equal to their straight counterparts so that
// quote-style changes alone never trigger word highlighting.
function normalizeToken(token: string): string {
return token
.replace(/[\u201C\u201D]/g, '"')
.replace(/[\u2018\u2019]/g, "'");
}
export function highlightWordDifferences(
original: string,
corrected: string,
): {
originalHighlighted: string;
correctedHighlighted: string;
/** Corrected text with the original's quote characters restored. */
preservedCorrected: string;
} {
const originalTokens = tokenize(original);
const correctedTokens = tokenize(corrected);
const parts = diffArrays(originalTokens.map(normalizeToken), correctedTokens.map(normalizeToken));
let originalHighlighted = '';
let correctedHighlighted = '';
let preservedCorrected = '';
let originalIndex = 0;
let correctedIndex = 0;
for (const part of parts) {
const value = Array.isArray(part.value) ? part.value : [part.value];
const length = value.length;
if (part.added) {
for (let k = 0; k < length; k++) {
const token = correctedTokens[correctedIndex + k];
correctedHighlighted += token.trim()
? `<span class="corrected-word">${token}</span>`
: token;
preservedCorrected += token;
}
correctedIndex += length;
} else if (part.removed) {
for (let k = 0; k < length; k++) {
const token = originalTokens[originalIndex + k];
originalHighlighted += token.trim()
? `<span class="error-word">${token}</span>`
: token;
}
originalIndex += length;
} else {
// Equal after quote normalization: emit the ORIGINAL characters on both
// sides so quote style stays untouched and words aren't highlighted.
const originalSlice = originalTokens.slice(originalIndex, originalIndex + length).join('');
originalHighlighted += originalSlice;
correctedHighlighted += originalSlice;
preservedCorrected += originalSlice;
originalIndex += length;
correctedIndex += length;
}
}
return { originalHighlighted, correctedHighlighted, preservedCorrected };
}
export function extractWordFixes(
original: string,
corrected: string,
baseOffset: number,
): WordFix[] {
const originalTokens = tokenize(original);
const correctedTokens = tokenize(corrected);
const parts = diffArrays(originalTokens.map(normalizeToken), correctedTokens.map(normalizeToken));
// Sentence-relative start offset of each original token. Tokens are
// contiguous slices of the original, so cumulative lengths give offsets.
const originalStarts: number[] = [];
let pos = 0;
for (const token of originalTokens) {
originalStarts.push(pos);
pos += token.length;
}
const fixes: WordFix[] = [];
let originalIndex = 0;
let correctedIndex = 0;
let pendingRemoved: Array<{ token: string; start: number }> = [];
const emitDeletion = (removed: Array<{ token: string; start: number }>) => {
const originalText = removed.map((r) => r.token).join('');
if (originalText.trim() === '') return; // whitespace-only — nothing visible to click
const last = removed[removed.length - 1];
fixes.push({
original: originalText,
corrected: '',
start: baseOffset + removed[0].start,
end: baseOffset + last.start + last.token.length,
});
};
for (const part of parts) {
const value = Array.isArray(part.value) ? part.value : [part.value];
if (part.removed) {
for (let k = 0; k < value.length; k++) {
const idx = originalIndex + k;
pendingRemoved.push({ token: originalTokens[idx], start: originalStarts[idx] });
}
originalIndex += value.length;
} else if (part.added) {
const addedTokens = correctedTokens.slice(correctedIndex, correctedIndex + value.length);
if (pendingRemoved.length > 0 && addedTokens.length > 0) {
if (pendingRemoved.length === addedTokens.length) {
// 1:1 pairing.
for (let k = 0; k < pendingRemoved.length; k++) {
const removed = pendingRemoved[k];
const added = addedTokens[k];
if (removed.token !== added && removed.token.trim() !== '' && added.trim() !== '') {
fixes.push({
original: removed.token,
corrected: added,
start: baseOffset + removed.start,
end: baseOffset + removed.start + removed.token.length,
});
}
}
} else {
// Unequal run. Pair as many leading tokens as possible 1:1; if the
// leftovers are punctuation/whitespace only (e.g. 'slow' ->
// 'slowly,'), emit them as a separate insertion so the comma shows
// as its own marker in the editor. Otherwise keep the whole-run
// joined fix (e.g. 'is are' -> 'was' must stay a single unit).
const pairCount = Math.min(pendingRemoved.length, addedTokens.length);
const leftoverRemoved = pendingRemoved.slice(pairCount);
const leftoverAdded = addedTokens.slice(pairCount);
// eslint-disable-next-line no-useless-escape -- the [ ] escapes are required here; unescaped [] matches nothing
const punctOnly = (tokens: string[]) => tokens.every((t) => /^[\s.,!?;:'"()\[\]{}\u201C\u201D\u2018\u2019]*$/.test(t));
if (punctOnly(leftoverRemoved.map((r) => r.token)) && punctOnly(leftoverAdded)) {
for (let k = 0; k < pairCount; k++) {
const removed = pendingRemoved[k];
const added = addedTokens[k];
if (removed.token !== added && removed.token.trim() !== '' && added.trim() !== '') {
fixes.push({
original: removed.token,
corrected: added,
start: baseOffset + removed.start,
end: baseOffset + removed.start + removed.token.length,
});
}
}
const pairEnd = pendingRemoved[pairCount - 1].start + pendingRemoved[pairCount - 1].token.length;
const addedText = leftoverAdded.join('');
if (addedText.trim() !== '') {
fixes.push({ original: '', corrected: addedText, start: baseOffset + pairEnd, end: baseOffset + pairEnd });
}
// leftoverRemoved is whitespace/punctuation only — nothing
// visible to click, so no deletion fix is emitted.
} else {
const originalText = pendingRemoved.map((r) => r.token).join('');
const addedText = addedTokens.join('');
if (originalText.trim() !== '' && addedText.trim() !== '') {
const last = pendingRemoved[pendingRemoved.length - 1];
fixes.push({
original: originalText,
corrected: addedText,
start: baseOffset + pendingRemoved[0].start,
end: baseOffset + last.start + last.token.length,
});
}
}
}
} else if (pendingRemoved.length === 0 && addedTokens.length > 0) {
// Pure insertion: mark the point before the next original token.
const addedText = addedTokens.join('');
if (addedText.trim() !== '') {
const insertAt = originalIndex < originalTokens.length
? originalStarts[originalIndex]
: original.length;
fixes.push({ original: '', corrected: addedText, start: baseOffset + insertAt, end: baseOffset + insertAt });
}
}
pendingRemoved = [];
correctedIndex += value.length;
} else {
// Equal part closes any pending change group; a removed run with no
// added counterpart is a pure deletion.
if (pendingRemoved.length > 0) emitDeletion(pendingRemoved);
pendingRemoved = [];
originalIndex += value.length;
correctedIndex += value.length;
}
}
// A removed run at the very end of the diff is also a pure deletion.
if (pendingRemoved.length > 0) emitDeletion(pendingRemoved);
return fixes;
}
@@ -0,0 +1,56 @@
import type { SentenceData } from './types.js';
export function reconstructTextFromSentences(
originalText: string,
sentenceData: SentenceData[],
correctedSentences: string[],
): string {
if (sentenceData.length !== correctedSentences.length) return originalText;
const resultParts: string[] = [];
let lastSpanEnd = 0;
for (let i = 0; i < sentenceData.length; i++) {
const sent = sentenceData[i];
const corrected = correctedSentences[i];
const { start, end } = sent;
const spanEnd = sent.spanEnd ?? end;
const gapBeforeStart = sent.gapBeforeStart ?? lastSpanEnd;
if (gapBeforeStart > lastSpanEnd) {
resultParts.push(originalText.slice(lastSpanEnd, gapBeforeStart));
} else if (start > lastSpanEnd) {
resultParts.push(originalText.slice(lastSpanEnd, start));
}
resultParts.push(corrected);
if (spanEnd > end) {
resultParts.push(originalText.slice(end, spanEnd));
} else if (i < sentenceData.length - 1) {
if (end < originalText.length) {
let whitespaceEnd = end;
while (whitespaceEnd < originalText.length && /\s/.test(originalText[whitespaceEnd])) {
whitespaceEnd++;
}
if (whitespaceEnd > end) {
resultParts.push(originalText.slice(end, whitespaceEnd));
} else if (corrected && /[.!?]$/.test(corrected) && whitespaceEnd < originalText.length) {
const nextChar = originalText[whitespaceEnd];
// Python: next_char.isalpha() and next_char.isupper()
if (nextChar && /[A-Z]/.test(nextChar)) {
resultParts.push(' ');
}
}
}
}
lastSpanEnd = spanEnd;
}
if (lastSpanEnd < originalText.length) {
resultParts.push(originalText.slice(lastSpanEnd));
}
return resultParts.join('');
}
@@ -0,0 +1,125 @@
import type { SentenceData } from './types.js';
const ABBREVIATIONS = new Set([
'etc', 'eg', 'e.g', 'ie', 'i.e', 'vs', 'viz', 'cf', 'ca', 'approx',
'no', 'vol', 'fig', 'p', 'pp', 'ch', 'sec', 'ex', 'al', 'et', 'seq',
'etc.', 'e.g.', 'i.e.', 'vs.', 'viz.', 'cf.', 'ca.', 'approx.',
'no.', 'vol.', 'fig.', 'p.', 'pp.', 'ch.', 'sec.', 'ex.', 'et al.', 'seq.',
'mr', 'mrs', 'ms', 'dr', 'prof', 'rev', 'sr', 'jr', 'st',
]);
// Python: (?<=[.!?])(?!\w)(?<!\d\.\d)(?<!\s[A-Za-z]\.)\s+(?=[A-Z"'])|(?<=[.!?])\s*$
// (re.VERBOSE + re.IGNORECASE; \w is ASCII in JS — acceptable for English text)
const SENTENCE_BOUNDARY_RE = /(?<=[.!?])(?!\w)(?<!\d\.\d)(?<!\s[A-Za-z]\.)\s+(?=[A-Z"'])|(?<=[.!?])\s*$/gi;
// A missing space after terminal punctuation — "…on its functionality.The team
// were…". The original pattern needs \s+ after the punctuation, so such a run
// was swallowed into one sentence. This boundary is zero-width, so it is found
// separately, and only when:
// * the punctuated word has at least two letters — keeps single-letter
// initials glued ("U.S.Today") and decimals out ("3.5.Next"); abbreviations
// such as "etc.They" are rejected later by ABBREVIATIONS; and
// * the next word starts upper-case followed by a lower-case letter — keeps
// "README.TXT" glued and skips "happy.enough".
const MISSING_SPACE_BOUNDARY_RE = /(?<=[A-Za-z]{2}[.!?])(?=[A-Z][a-z])/g;
interface BoundaryMatch {
index: number; // position immediately after the terminal punctuation
length: number; // whitespace separating the two sentences (0 when missing)
}
function findBoundaryMatches(text: string): BoundaryMatch[] {
const matches: BoundaryMatch[] = [];
for (const pattern of [SENTENCE_BOUNDARY_RE, MISSING_SPACE_BOUNDARY_RE]) {
const re = new RegExp(pattern.source, pattern.flags);
let match: RegExpExecArray | null;
while ((match = re.exec(text)) !== null) {
matches.push({ index: match.index, length: match[0].length });
// JS exec() loops forever on zero-width matches; Python's finditer()
// auto-advances. Guard like findAllOccurrences does.
if (match.index === re.lastIndex) re.lastIndex++;
}
}
matches.sort((a, b) => a.index - b.index || b.length - a.length);
// The two patterns are mutually exclusive (one needs a non-word character
// right after the punctuation, the other an upper-case letter), but never
// emit the same split twice.
return matches.filter((m, i) => i === 0 || m.index !== matches[i - 1].index);
}
export function splitIntoSentences(text: string): SentenceData[] {
const sentences: SentenceData[] = [];
if (!text.trim()) return sentences;
let lastEnd = 0;
const potentialSplits = findBoundaryMatches(text);
for (const { index: splitPos, length: gapLength } of potentialSplits) {
const sentenceText = text.slice(lastEnd, splitPos).trim();
if (!sentenceText) {
lastEnd = splitPos + gapLength;
continue;
}
let isTrueBoundary = true;
const prevWords = sentenceText.toLowerCase().split(/\s+/);
if (prevWords.length > 0) {
const lastWord = prevWords[prevWords.length - 1].replace(/^[.,!?;:"']+|[.,!?;:"']+$/g, '');
if (ABBREVIATIONS.has(lastWord)) {
isTrueBoundary = false;
} else if (/\d\.\d/.test(sentenceText.slice(-10))) {
// Python quirk replicated: the single-letter-initial check was dead
// code in main.py (misspelled variable), so initials ARE split here.
isTrueBoundary = false;
}
}
// Only meaningful for whitespace boundaries: it inspects the character
// after the gap's first character to reject a lower-case continuation.
// A space-less boundary is already constrained to an upper-case start.
if (gapLength > 0 && splitPos + 2 < text.length) {
const nextChars = text.slice(splitPos + 1, splitPos + 3);
// Python: (next_chars and next_chars[0].islower()) or next_chars[0].isdigit()
if (/[a-z\d]/.test(nextChars[0])) {
isTrueBoundary = false;
}
}
if (!isTrueBoundary) continue;
let startNoWs = lastEnd;
while (startNoWs < splitPos && /\s/.test(text[startNoWs])) startNoWs++;
let spanEnd = splitPos;
while (spanEnd < text.length && /\s/.test(text[spanEnd])) spanEnd++;
// Nothing was consumed: only mark a one-character span when the boundary
// sits at the very end of the text. Mid-text space-less boundaries must keep
// spanEnd === end so reconstructTextFromSentences re-inserts the space.
if (spanEnd === splitPos && splitPos >= text.length) spanEnd = splitPos + 1;
sentences.push({ text: sentenceText, start: startNoWs, end: splitPos, spanEnd, gapBeforeStart: lastEnd });
lastEnd = spanEnd;
}
if (lastEnd < text.length) {
const remaining = text.slice(lastEnd).trim();
if (remaining) {
let startNoWs = lastEnd;
while (startNoWs < text.length && /\s/.test(text[startNoWs])) startNoWs++;
sentences.push({ text: remaining, start: startNoWs, end: text.length, spanEnd: text.length, gapBeforeStart: lastEnd });
}
}
if (sentences.length === 0) {
const content = text.trim();
if (content) {
let startNoWs = 0;
while (startNoWs < text.length && /\s/.test(text[startNoWs])) startNoWs++;
sentences.push({ text: content, start: startNoWs, end: text.length, spanEnd: text.length, gapBeforeStart: 0 });
}
}
return sentences;
}
+34
View File
@@ -0,0 +1,34 @@
export interface SentenceData {
text: string;
start: number;
end: number;
spanEnd: number;
gapBeforeStart: number;
}
export interface Suggestion {
original: string;
corrected: string;
sentence: string;
start_index: number;
end_index: number;
original_highlighted: string;
corrected_highlighted: string;
wordFixes: WordFix[];
}
export interface WordFix {
original: string; // misspelled word as it appears in the text
corrected: string; // replacement word
start: number; // absolute offset in the full input text
end: number; // absolute offset (exclusive)
}
export interface CorrectionResponse {
suggestions: Suggestion[];
correctedText: string;
}
export interface SentenceCorrector {
correct(sentence: string): Promise<string>;
}
@@ -0,0 +1,52 @@
# grammar-llm container — docker-compose (modeled on docker-languagetool's
# compose so admins can swap containers with a one-line service change).
#
# Mirrors erikvl87/languagetool: same default port 8010, /v2/* API.
# The model is kept in a named volume (auto-downloaded on first start from
# Hugging Face unless you pre-place it), so it survives container recreation.
services:
grammar-llm:
build:
context: .
dockerfile: Dockerfile
image: grammar-llm:latest
container_name: grammar-llm
restart: unless-stopped
ports:
- 8010:8010 # same default port as docker-languagetool
# Optional environment (uncomment and set as a mapping — compose rejects
# an environment key with no entries):
# environment:
# GRAMMAR_LLM_MODEL_FILE: GRMR-V3-G4B-Q8_0.gguf # optional: higher-quality (slower) quant
# GRAMMAR_LLM_MODEL_URL: "..." # optional: custom model source
# GRAMMAR_LLM_DOWNLOAD_AT_START: "true" # default: download model on first start
# GRAMMAR_LLM_REQUEST_TIMEOUT_MS: "55000" # per-request time budget
# GRAMMAR_LLM_PRELOAD: "true" # load model at boot
# # — (a) real LanguageTool first, LLM second (compute-saving cascade) —
# GRAMMAR_LLM_LANGUAGETOOL_URL: "http://languagetool:8010" # optional; empty = LLM only
# GRAMMAR_LLM_CASCADE_BUDGET_MS: "55000" # shared LT+LLM time budget (keep < LT-client cap; Overleaf: 60 s)
# GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: "45000" # LT's window to win the race
# GRAMMAR_LLM_LLM_GRACE_MS: "400" # ms before the LLM starts in parallel (0 = pure parallel)
# # — (b) route the LLM step to a remote OpenAI-compatible API —
# GRAMMAR_LLM_BACKEND: "openai" # local (default) | openai
# GRAMMAR_LLM_OPENAI_BASE_URL: "https://api.openai.com/v1"
# GRAMMAR_LLM_OPENAI_MODEL: "gpt-4o-mini" # required when backend=openai
# GRAMMAR_LLM_OPENAI_API_KEY: "sk-..." # only if the endpoint needs one
# # — (c) level used when the client sends none —
# GRAMMAR_LLM_DEFAULT_LEVEL: "default" # default | picky
volumes:
- grammar-llm-models:/models # persistent model storage (~2.5 GB)
# For NVIDIA GPU inference (build with the linux-x64-cuda prebuilt and
# uncomment; node-llama-cpp auto-detects CUDA when available):
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
volumes:
grammar-llm-models:
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "grammar-llm-container",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "LanguageTool-compatible (/v2/* API) HTTP grammar checker — drop-in replacement for docker-languagetool — powered by the grammar-llm engine (GRMR-V3-G4B via node-llama-cpp).",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "node build.mjs",
"start": "node dist/server.mjs",
"download-model": "node dist/download-model.mjs",
"test": "node build.mjs --quiet && node --test test/*.test.mjs",
"smoke": "bash test/smoke.sh",
"sync-core": "bash scripts/sync-core.sh"
},
"dependencies": {
"node-llama-cpp": "3.19.1"
},
"devDependencies": {
"diff": "^9.0.0",
"esbuild": "^0.25.0"
}
}
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
#
# sync-core.sh — refresh the vendored grammar-llm core in ./core/ from the
# grammar-llm source tree.
#
# The container's functional core lives in the grammar-llm project
# (/data_1/compare/grammar-llm on this machine). This project vendors a
# snapshot of src/electron/core/*.ts (the pure, framework-free engine) so the
# Docker build stays self-contained. Run this script after updating
# grammar-llm to pick up engine changes.
#
# Usage:
# scripts/sync-core.sh [path-to-grammar-llm-repo]
#
# The default location is the sibling repo at <compare-root>/grammar-llm.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Default: /data_1/compare/grammar-llm (project is at
# /data_1/compare/overleaf-cep/tools/grammar-llm-container)
DEFAULT_SRC="$(cd "$PROJECT_ROOT/../../.." && pwd)/grammar-llm"
SRC="${1:-$DEFAULT_SRC}"
if [ ! -d "$SRC/src/electron/core" ]; then
echo "ERROR: $SRC/src/electron/core not found." >&2
echo "Usage: $0 [path-to-grammar-llm-repo]" >&2
exit 1
fi
DEST="$PROJECT_ROOT/core"
mkdir -p "$DEST"
# Copy the non-test core modules (apply.ts is UI-side app logic, kept for
# completeness; the server uses sentences/clean/correction/diff/reconstruct/types).
shopt -s nullglob
for f in "$SRC"/src/electron/core/*.ts; do
b="$(basename "$f")"
case "$b" in
*.test.ts) ;;
*) cp -f "$f" "$DEST/$b" ;;
esac
done
# Record provenance
{
date -u +%Y-%m-%dT%H:%M:%SZ
echo "source: $SRC"
if git -C "$SRC" rev-parse --short HEAD >/dev/null 2>&1; then
echo "commit: $(git -C "$SRC" rev-parse --short HEAD)"
echo "status: $(git -C "$SRC" status --porcelain -- src/electron/core | head -1)"
fi
sha256sum "$SRC"/src/electron/core/*.ts | grep -v test || true
} > "$DEST/.sync-info"
echo "Synced $(ls "$DEST"/*.ts | wc -l) core module(s) from $SRC"
echo "Provenance written to $DEST/.sync-info"
+211
View File
@@ -0,0 +1,211 @@
import path from 'node:path';
const DEFAULT_MODEL_URL =
'https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf';
export interface Sampling {
temperature: number;
topK: number;
topP: number;
minP: number;
}
export interface Config {
host: string;
port: number;
allowOrigin: string;
/** Reported as the LanguageTool software version in response envelopes. */
reportedVersion: string;
modelDir: string;
modelFile: string;
modelUrl: string;
modelPath: string;
contextSize: number;
concurrency: number;
preload: boolean;
maxTextLength: number;
maxRuns: number; // 0 = unlimited checkable text runs per request
requestTimeoutMs: number;
sentenceTimeoutMs: number;
maxBodyBytes: number;
maxInFlight: number;
/** Language codes accepted by /v2/check (lower-cased, includes 'auto'). */
supportedLanguages: Set<string>;
/** What to do for a non-English language code: 'empty' (silent) or 'error' (HTTP 400). */
unsupportedLanguageMode: 'empty' | 'error';
/** LLM backend: 'local' (node-llama-cpp) or 'openai' (OpenAI-compatible API). */
backend: 'local' | 'openai';
/**
* Wire style for the 'openai' backend: 'openai' = standard /chat/completions
* (OpenAI, vLLM, llama-server, …); 'ollama' = Ollama's native /api/chat
* (needed for models like Qwen3 where the OpenAI-compat layer ignores
* think:false and burns the token budget on hidden reasoning).
*/
openaiStyle: 'openai' | 'ollama';
/**
* Optional custom base system prompt for the 'openai' backend (e.g. a
* fine-tuned model's own trained prompt, such as
* ifioravanti/mistral-grammar-checker's). Empty = the built-in grammar-llm
* prompt. The 'picky' level still appends its addendum on top.
*/
openaiSystemPrompt: string;
/** OpenAI-compatible base URL (e.g. https://api.openai.com/v1, a llama-server, vLLM, Ollama /v1, …). */
openaiBaseUrl: string;
openaiApiKey: string;
openaiModel: string;
/** Timeout for a single remote LLM call (ms). */
openaiTimeoutMs: number;
/**
* Completion-token headroom added to the sentence length (per-sentence
* max_tokens / num_predict). Some models (hidden reasoning, retokenization)
* need more than the visible output — raise when a model returns empty
* answers (e.g. muse-glimmer needs ~75 completion tokens per sentence).
*/
openaiMaxTokenPad: number;
/**
* Optional real LanguageTool container to query FIRST (cascade).
* LT runs with the requested level (picky→picky), and its matches win;
* when LT finds nothing, the LLM runs second. null = LLM only.
*/
gtUrl: string | null;
/** Timeout for the real-LanguageTool call (ms) — the LT's window to win the race. */
gtTimeoutMs: number;
/**
* Total wall-clock budget (ms) for a cascaded check (LT attempt + LLM
* fallback). Kept under LT-client caps (Overleaf's controller times out at
* 60 s and then shows nothing) so the response always arrives in time.
*/
cascadeBudgetMs: number;
/**
* How long (ms) to let a fast real-LT win before starting the LLM in the
* cascade. Keeps the "compute-saving" benefit when LT answers quickly, but
* the LLM is otherwise running in parallel so the fallback is not blocked.
* 0 = start the LLM immediately (pure parallel).
*/
llmGraceMs: number;
/** Level used when the client does not send one ('default' or 'picky'). */
defaultLevel: 'default' | 'picky';
sampling: Sampling;
logLevel: 'debug' | 'info' | 'warn' | 'error';
}
function envStr(env: NodeJS.ProcessEnv, key: string, def: string): string {
const v = env[key];
return v !== undefined && v !== '' ? v : def;
}
function envInt(env: NodeJS.ProcessEnv, key: string, def: number): number {
const v = env[key];
if (v === undefined || v === '') return def;
const n = Number(v);
if (!Number.isFinite(n)) throw new Error(`env ${key} must be an integer, got ${v}`);
return Math.trunc(n);
}
function envBool(env: NodeJS.ProcessEnv, key: string, def: boolean): boolean {
const v = env[key];
if (v === undefined || v === '') return def;
const s = String(v).toLowerCase();
return s === '1' || s === 'true' || s === 'yes' || s === 'on';
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const contextSize = Math.min(131_072, Math.max(256, envInt(env, 'GRAMMAR_LLM_CONTEXT_SIZE', 8192)));
const modelDir = envStr(env, 'GRAMMAR_LLM_MODEL_DIR', '/models');
const modelFile = envStr(env, 'GRAMMAR_LLM_MODEL_FILE', 'GRMR-V3-G4B-Q4_K_M.gguf');
const logLevel = (envStr(env, 'GRAMMAR_LLM_LOG_LEVEL', 'info').toLowerCase()) as Config['logLevel'];
if (!['debug', 'info', 'warn', 'error'].includes(logLevel)) {
throw new Error(`GRAMMAR_LLM_LOG_LEVEL must be one of debug|info|warn|error, got '${env.GRAMMAR_LLM_LOG_LEVEL}'`);
}
const unsupportedLanguageMode = envStr(env, 'GRAMMAR_LLM_UNSUPPORTED_LANGUAGE', 'empty').toLowerCase();
if (unsupportedLanguageMode !== 'empty' && unsupportedLanguageMode !== 'error') {
throw new Error(`GRAMMAR_LLM_UNSUPPORTED_LANGUAGE must be 'empty' or 'error', got '${env.GRAMMAR_LLM_UNSUPPORTED_LANGUAGE}'`);
}
const backend = envStr(env, 'GRAMMAR_LLM_BACKEND', 'local').toLowerCase();
if (backend !== 'local' && backend !== 'openai') {
throw new Error(`GRAMMAR_LLM_BACKEND must be 'local' or 'openai', got '${env.GRAMMAR_LLM_BACKEND}'`);
}
const openaiStyleRaw = envStr(env, 'GRAMMAR_LLM_OPENAI_STYLE', 'openai').toLowerCase();
if (openaiStyleRaw !== 'openai' && openaiStyleRaw !== 'ollama') {
throw new Error(`GRAMMAR_LLM_OPENAI_STYLE must be 'openai' or 'ollama', got '${env.GRAMMAR_LLM_OPENAI_STYLE}'`);
}
const openaiModel = envStr(env, 'GRAMMAR_LLM_OPENAI_MODEL', '');
if (backend === 'openai' && !openaiModel) {
throw new Error('GRAMMAR_LLM_BACKEND=openai requires GRAMMAR_LLM_OPENAI_MODEL (e.g. gpt-4o-mini, or your GRMR model id on the server)');
}
const defaultLevel = envStr(env, 'GRAMMAR_LLM_DEFAULT_LEVEL', 'default').toLowerCase();
if (defaultLevel !== 'default' && defaultLevel !== 'picky') {
throw new Error(`GRAMMAR_LLM_DEFAULT_LEVEL must be 'default' or 'picky', got '${env.GRAMMAR_LLM_DEFAULT_LEVEL}'`);
}
const gtUrlRaw = envStr(env, 'GRAMMAR_LLM_LANGUAGETOOL_URL', '');
if (gtUrlRaw && !/^https?:\/\//.test(gtUrlRaw)) {
throw new Error(`GRAMMAR_LLM_LANGUAGETOOL_URL must be an http(s) URL, got '${gtUrlRaw}'`);
}
return {
host: envStr(env, 'HOST', '0.0.0.0'),
port: Math.max(1, Math.min(65535, envInt(env, 'PORT', 8010))),
allowOrigin: envStr(env, 'GRAMMAR_LLM_ALLOW_ORIGIN', '*'),
reportedVersion: envStr(env, 'GRAMMAR_LLM_REPORTED_LT_VERSION', '6.8'),
modelDir,
modelFile,
modelUrl: envStr(env, 'GRAMMAR_LLM_MODEL_URL', DEFAULT_MODEL_URL),
modelPath: path.join(modelDir, modelFile),
contextSize,
concurrency: Math.max(1, envInt(env, 'GRAMMAR_LLM_CONCURRENCY', 1)),
preload: envBool(env, 'GRAMMAR_LLM_PRELOAD', true),
maxTextLength: Math.max(1000, envInt(env, 'GRAMMAR_LLM_MAX_TEXT_LENGTH', 100_000)),
maxRuns: Math.max(0, envInt(env, 'GRAMMAR_LLM_MAX_RUNS', 0)),
requestTimeoutMs: Math.max(1000, envInt(env, 'GRAMMAR_LLM_REQUEST_TIMEOUT_MS', 55_000)),
sentenceTimeoutMs: Math.max(1000, envInt(env, 'GRAMMAR_LLM_SENTENCE_TIMEOUT_MS', 20_000)),
maxBodyBytes: Math.max(10 * 1024, envInt(env, 'GRAMMAR_LLM_MAX_BODY_BYTES', 2 * 1024 * 1024)),
maxInFlight: Math.max(1, envInt(env, 'GRAMMAR_LLM_MAX_IN_FLIGHT', 4)),
cascadeBudgetMs: Math.max(2000, envInt(env, 'GRAMMAR_LLM_CASCADE_BUDGET_MS', 55_000)),
llmGraceMs: Math.max(0, envInt(env, 'GRAMMAR_LLM_LLM_GRACE_MS', 400)),
supportedLanguages: new Set(
envStr(env, 'GRAMMAR_LLM_LANGUAGES', 'auto,en,en-us,en-gb,en-au,en-ca,en-ie,en-nz,en-za,en-in')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
),
unsupportedLanguageMode,
backend,
openaiStyle: openaiStyleRaw as 'openai' | 'ollama',
openaiSystemPrompt: envStr(env, 'GRAMMAR_LLM_OPENAI_SYSTEM_PROMPT', ''),
openaiBaseUrl: envStr(env, 'GRAMMAR_LLM_OPENAI_BASE_URL', 'https://api.openai.com/v1').replace(/\/$/, ''),
openaiApiKey: envStr(env, 'GRAMMAR_LLM_OPENAI_API_KEY', ''),
openaiModel,
openaiTimeoutMs: Math.max(1000, envInt(env, 'GRAMMAR_LLM_OPENAI_TIMEOUT_MS', 60_000)),
openaiMaxTokenPad: Math.max(0, envInt(env, 'GRAMMAR_LLM_OPENAI_MAX_TOKEN_PAD', 40)),
gtUrl: gtUrlRaw ? gtUrlRaw.replace(/\/$/, '') : null,
gtTimeoutMs: Math.max(2000, envInt(env, 'GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS', 45_000)),
defaultLevel: defaultLevel === 'picky' ? 'picky' : 'default',
sampling: {
temperature: Number(envStr(env, 'GRAMMAR_LLM_TEMPERATURE', '0.7')),
topK: Math.max(1, envInt(env, 'GRAMMAR_LLM_TOP_K', 40)),
topP: Number(envStr(env, 'GRAMMAR_LLM_TOP_P', '0.95')),
minP: Number(envStr(env, 'GRAMMAR_LLM_MIN_P', '0.01')),
},
logLevel,
};
}
export function isLanguageSupported(cfg: Config, code: string | null | undefined): boolean {
if (code === undefined || code === null || String(code).trim() === '') return true; // LT default
const c = String(code).trim().toLowerCase();
return cfg.supportedLanguages.has(c);
}
@@ -0,0 +1,77 @@
// One-shot model downloader (start.sh calls this before the server boots).
// Uses node-llama-cpp's createModelDownloader (same API the desktop app's
// modelManager.ts adapter uses against v3.19:
// await createModelDownloader({ modelUri, dirPath, fileName, onProgress })
// → { download(): Promise, cancel(): Promise }
// onProgress(status: { downloadedSize, totalSize, ... })
//
// Usage:
// node dist/download-model.mjs --url <url> --out /models/GRMR-V3-G4B-Q4_K_M.gguf
// node dist/download-model.mjs --url <url> --dir /models --file name.gguf
import fs from 'node:fs';
import path from 'node:path';
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return undefined;
return process.argv[i + 1];
}
function die(msg: string): never {
console.error(`ERROR: ${msg}`);
process.exit(2);
}
const url = arg('url');
const out = arg('out');
const dir = arg('dir');
const file = arg('file');
if (!url) die('--url is required');
if (!out && !(dir && file)) die('either --out or both --dir and --file are required');
const outPath = out ?? path.join(dir as string, file as string);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const existing = fs.existsSync(outPath) ? fs.statSync(outPath).size : 0;
if (existing > 0) {
console.log(`Model already present (${(existing / 1024 / 1024).toFixed(0)} MB): ${outPath}`);
process.exit(0);
}
console.log(`Downloading model…`);
console.log(` url: ${url}`);
console.log(` target: ${outPath}`);
const { createModelDownloader } = await import('node-llama-cpp');
let lastLog = 0;
let lastPct = -1;
const downloader = await createModelDownloader({
modelUri: url,
dirPath: path.dirname(outPath),
fileName: path.basename(outPath),
onProgress: (status: { downloadedSize: number; totalSize: number }) => {
const now = Date.now();
const pct = status.totalSize > 0 ? Math.floor((status.downloadedSize / status.totalSize) * 100) : 0;
if (now - lastLog > 1000 || pct !== lastPct) {
lastLog = now;
lastPct = pct;
const mb = (status.downloadedSize / 1024 / 1024).toFixed(1);
const tot = status.totalSize > 0 ? ` / ${(status.totalSize / 1024 / 1024).toFixed(0)} MB` : '';
process.stdout.write(`\r ${pct}% ${mb} MB${tot} `);
}
},
});
try {
await downloader.download();
console.log('');
const size = fs.statSync(outPath).size;
console.log(`Model downloaded: ${outPath} (${(size / 1024 / 1024).toFixed(0)} MB)`);
} catch (err) {
console.error('');
console.error(`Model download failed: ${String(err)}`);
process.exit(1);
}
+264
View File
@@ -0,0 +1,264 @@
// Check engine: runs the grammar-llm core pipeline (sentence splitting, LLM
// correction, cleaning, diff/word-fix extraction, reconstruction) over the
// plain-text runs of a document, with a per-request time budget.
//
// The per-sentence guard pipeline mirrors grammar-llm's core/correction.ts
// (correctText) exactly — same order of guards, same suggestion shape — so
// container results stay identical to the desktop app for the same model.
import { cleanCorrectedText, isOnlyQuoteChange } from '../core/clean.js';
import { cleanQuotePunctuation } from '../core/correction.js';
import { extractWordFixes, highlightWordDifferences } from '../core/diff.js';
import { reconstructTextFromSentences } from '../core/reconstruct.js';
import { splitIntoSentences } from '../core/sentences.js';
import type { CorrectionResponse, Suggestion } from '../core/types.js';
import { LlamaService, type CheckLevel } from './llama.js';
import { OpenAIService } from './openai.js';
import type { Config } from './config.js';
import type { Logger } from './log.js';
import type { TextRun } from './languagetool.js';
/** Common contract of the two backends (local node-llama-cpp / OpenAI-compatible). */
interface SentenceBackend {
isReady: boolean;
ensureLoaded(): Promise<void>;
correct(sentence: string, level: CheckLevel): Promise<string>;
loadErrorText: string | null;
describe(): string;
}
function describeLocal(svc: LlamaService): string {
return `local model=${svc.modelPath}`;
}
export type EngineState = 'stopped' | 'loading' | 'ready' | 'error';
export interface EngineStatus {
state: EngineState;
errorMessage: string | null;
modelPath: string;
checksCompleted: number;
checksFailed: number;
}
export interface RunCheckResult extends CorrectionResponse {
/** true when the request time budget cut the check short. */
incomplete: boolean;
}
export class CheckEngine {
private backend: SentenceBackend;
private localLlama: LlamaService | null = null;
private state: EngineState = 'stopped';
private loadError: string | null = null;
private loading: Promise<void> | null = null;
/**
* Serialization chain: the LLM backend keeps ONE shared chat context
* (resetChatHistory() per sentence), so concurrent checks must not interleave.
* Checks queue here; each keeps its own internal deadline semantics.
*/
private checkChain: Promise<unknown> = Promise.resolve();
checksCompleted = 0;
checksFailed = 0;
constructor(private cfg: Config, private log: Logger) {
if (cfg.backend === 'openai') {
this.backend = new OpenAIService({
baseUrl: cfg.openaiBaseUrl,
apiKey: cfg.openaiApiKey,
model: cfg.openaiModel,
sampling: cfg.sampling,
timeoutMs: cfg.openaiTimeoutMs,
tokenPad: cfg.openaiMaxTokenPad,
logger: log,
style: cfg.openaiStyle,
systemPrompt: cfg.openaiSystemPrompt || undefined,
});
} else {
const svc = new LlamaService(
{
modelPath: cfg.modelPath,
contextSize: cfg.contextSize,
sampling: cfg.sampling,
logger: log,
},
log,
);
this.localLlama = svc;
this.backend = {
isReady: svc.isReady,
ensureLoaded: () => svc.ensureLoaded(),
correct: (s, l) => svc.correct(s, l),
get loadErrorText() {
return svc.loadErrorText;
},
describe: () => describeLocal(svc),
};
}
}
status(): EngineStatus {
return {
state: this.state,
errorMessage: this.loadError ?? this.backend.loadErrorText,
modelPath: this.cfg.backend === 'openai' ? `(remote: ${this.cfg.openaiModel})` : this.cfg.modelPath,
checksCompleted: this.checksCompleted,
checksFailed: this.checksFailed,
};
}
async ensureReady(): Promise<void> {
if (this.state === 'ready') return;
if (this.loading) await this.loading;
if (this.state === 'ready') return;
this.state = 'loading';
this.loading = this.backend
.ensureLoaded()
.then(() => {
this.state = 'ready';
this.loadError = null;
})
.catch((err) => {
this.state = 'error';
this.loadError = String(err);
throw err;
})
.finally(() => {
this.loading = null;
});
await this.loading;
}
/** Check a single plain-text run. Never throws for per-sentence failures. */
async correctRun(run: TextRun, deadline: number, level: CheckLevel = 'default'): Promise<RunCheckResult> {
const text = run.text;
const trimmed = text.trim();
if (!trimmed) return { suggestions: [], correctedText: '', incomplete: false };
const sentenceData = splitIntoSentences(text);
const n = sentenceData.length;
const deadlineExceeded = () => Date.now() > deadline;
// Process sentences one at a time (single shared LLM session), stopping
// at the request deadline; unprocessed sentences pass through unchanged.
const suggestions: Suggestion[] = [];
const correctedSentences: string[] = [];
let incomplete = false;
for (let i = 0; i < n; i++) {
const sent = sentenceData[i];
const sentence = sent.text;
if (sentence.length < 2) {
correctedSentences.push(sentence);
continue;
}
if (deadlineExceeded()) {
incomplete = true;
correctedSentences.push(sentence);
continue;
}
let corrected = await this.backend.correct(sentence, level);
corrected = cleanCorrectedText(corrected, sentence);
if (isOnlyQuoteChange(sentence, corrected)) corrected = sentence;
if (corrected.length > sentence.length * 2) corrected = sentence;
corrected = cleanQuotePunctuation(corrected);
// Diff once: restore the original quote style (curly vs straight) so
// quote changes are never applied to the user's text.
const diff = highlightWordDifferences(sentence, corrected);
corrected = diff.preservedCorrected;
correctedSentences.push(corrected);
if (
corrected.toLowerCase().trim() !== sentence.toLowerCase().trim() &&
corrected.trim() !== sentence.trim() &&
corrected.length <= sentence.length * 1.5 &&
!isOnlyQuoteChange(sentence, corrected)
) {
suggestions.push({
original: sentence,
corrected,
sentence: `Sentence ${i + 1}`,
start_index: sent.start,
end_index: sent.end,
original_highlighted: diff.originalHighlighted,
corrected_highlighted: diff.correctedHighlighted,
wordFixes: extractWordFixes(sentence, corrected, sent.start),
});
}
}
const correctedText = cleanQuotePunctuation(reconstructTextFromSentences(text, sentenceData, correctedSentences));
return { suggestions, correctedText, incomplete };
}
/**
* Check all runs of a resolved input (sequentially; each run is bounded by
* the shared deadline). Returns per-run results in order.
* Concurrent callers are serialized on the shared LLM context.
*/
check(
runs: TextRun[],
opts: { maxRuns?: number; requestTimeoutMs?: number; level?: CheckLevel },
): Promise<{ results: RunCheckResult[]; incomplete: boolean }> {
const task = this.checkChain.then(() => this.runCheck(runs, opts));
// Keep the chain alive without swallowing task errors for the caller.
this.checkChain = task.catch(() => { /* errors reach the caller via `task` */ });
return task;
}
private async runCheck(
runs: TextRun[],
opts: { maxRuns?: number; requestTimeoutMs?: number; level?: CheckLevel },
): Promise<{ results: RunCheckResult[]; incomplete: boolean }> {
await this.ensureReady();
const start = Date.now();
const budget = opts.requestTimeoutMs ?? this.cfg.requestTimeoutMs;
const deadline = start + budget;
const maxRuns = opts.maxRuns ?? this.cfg.maxRuns;
const level = opts.level ?? this.cfg.defaultLevel;
const results: RunCheckResult[] = [];
let incomplete = false;
const considered = maxRuns > 0 ? runs.slice(0, maxRuns) : runs;
if (maxRuns > 0 && runs.length > maxRuns) incomplete = true;
for (let i = 0; i < considered.length; i++) {
const run = considered[i];
if (Date.now() > deadline) {
incomplete = true;
this.log.warn('check time budget exhausted; remaining sentences passed through unchanged', {
budgetMs: budget,
ranMs: Date.now() - start,
run: i,
});
results.push({ suggestions: [], correctedText: run.text, incomplete: true });
continue;
}
try {
const r = await this.correctRun(run, deadline, level);
results.push(r);
if (r.incomplete) incomplete = true;
} catch (err) {
this.checksFailed++;
this.log.error('check run failed', { err: String(err) });
results.push({ suggestions: [], correctedText: run.text, incomplete: true });
incomplete = true;
}
}
this.checksCompleted++;
this.log.info('check completed', {
backend: this.backend.describe(),
level,
runs: results.length,
suggestions: results.reduce((a, r) => a + r.suggestions.length, 0),
ranMs: Date.now() - start,
incomplete,
});
return { results, incomplete };
}
}
export interface SentenceCorrectorShim {
correct(sentence: string): Promise<string>;
}
@@ -0,0 +1,439 @@
// LanguageTool response builder + request resolution.
//
// Converts grammar-llm core output (suggestions/wordFixes) into the
// LanguageTool HTTP v2 "check" JSON shape so Overleaf — and any other client
// speaking the LanguageTool protocol — works unchanged.
//
// Key invariant (from Overleaf's LanguageTool module):
// annotations.map(a => a.text ?? a.markup).join('') === editor source
// so match offsets must be offsets into the EDITOR SOURCE document.
//
// Strategy:
// - Reconstruct the full document text (text + markup segments in order).
// - Identify maximal plain-text runs (language: markup = "ignore this text").
// - Run the grammar-llm core pipeline on each run (the LLM never sees LaTeX
// markup, and no match can ever span it).
// - Rebase suggestion offsets by the run's document offset — matches end up
// in editor-source coordinates, as Overleaf expects.
//
// Response schema reference
// (languagetool: org.languagetool.tools.RuleMatchesAsJsonSerializer):
// top-level : { software, warnings, language, matches[], sentenceRanges }
// match : { message, shortMessage?, replacements[], offset, length,
// context, type, rule, ignoreForIncompleteSentence, contextForSureMatch }
// replacement: { value, suffix?, type? }
// rule : { id, description, issueType, category, tags? }
// category : { id, name }
//
// Overleaf consumes: matches[].offset/.length, matches[].message,
// matches[].replacements[] (first 5 values), matches[].rule.category.id
// (filters id === 'TYPOS' while Hunspell is active). We use
// category.id = 'GRAMMAR' so our results survive that filter.
import type { Suggestion, WordFix } from '../core/types.js';
export interface LtReplacement {
value: string;
}
export interface LtRule {
id: string;
description: string;
issueType: string;
category: { id: string; name: string };
tags?: string[];
}
export interface LtMatch {
message: string;
shortMessage?: string;
replacements: LtReplacement[];
offset: number;
length: number;
context: { text: string; offset: number; length: number };
type: { typeName: string };
rule: LtRule;
ignoreForIncompleteSentence: boolean;
contextForSureMatch: number;
}
export interface LtLanguage {
name: string;
code: string;
longCode: string;
}
export interface LtCheckResponse {
software: {
name: string;
version: string;
buildDate: string;
apiVersion: number;
premium: boolean;
status: string;
};
warnings: { incompleteResults: boolean; incompleteResultsReason?: string };
language: {
name: string;
code: string;
detectedLanguage: { name: string; code: string; confidence: number; source: string };
};
matches: LtMatch[];
sentenceRanges: Array<[number, number]>;
}
/** Static rule object reported on every match. */
const RULE: LtRule = {
id: 'GRAMMAR_LLM',
description: 'Grammar, spelling, or style issue detected by grammar-llm (GRMR-V3-G4B)',
issueType: 'Grammar',
category: { id: 'GRAMMAR', name: 'Grammar' },
tags: ['LLM', 'GRMR-V3-G4B'],
};
/** Languages we advertise (GRMR-V3-G4B is English-only). */
const LANGUAGES: LtLanguage[] = [
{ name: 'English (US)', code: 'en-US', longCode: 'en-US' },
{ name: 'English (UK)', code: 'en-GB', longCode: 'en-GB' },
{ name: 'English (Australia)', code: 'en-AU', longCode: 'en-AU' },
{ name: 'English (Canada)', code: 'en-CA', longCode: 'en-CA' },
{ name: 'English (Ireland)', code: 'en-IE', longCode: 'en-IE' },
{ name: 'English (New Zealand)', code: 'en-NZ', longCode: 'en-NZ' },
{ name: 'English (India)', code: 'en-IN', longCode: 'en-IN' },
{ name: 'English (South Africa)', code: 'en-ZA', longCode: 'en-ZA' },
{ name: 'English', code: 'en', longCode: 'en' },
];
export function getLanguages(): LtLanguage[] {
return LANGUAGES;
}
export function normalizeLanguage(code: string | null | undefined): LtLanguage | null {
if (code === null || code === undefined || String(code).trim() === '' || String(code).trim().toLowerCase() === 'auto') {
return LANGUAGES[0]; // default: English (US)
}
const c = String(code).trim().toLowerCase();
return LANGUAGES.find((l) => l.code.toLowerCase() === c) ?? null;
}
/** A plain-text segment with its absolute offset into the full document. */
export interface TextRun {
text: string;
offset: number;
}
export interface ResolvedCheckInput {
/** Full document text (markup included) — offsets/limits are against this. */
text: string;
/** Maximal checkable runs (plain text), offsets absolute into `text`. */
runs: TextRun[];
}
export type CheckInput = ResolvedCheckInput | { error: string };
/**
* Resolve the text to check from a request body (LanguageTool `text`/`data`
* parameters; `data` may be a JSON object {text, annotation:[{text|markup}]}
* — Overleaf sends annotation when it has a Lezer syntax tree).
*/
export function resolveCheckInput(body: Record<string, unknown>): CheckInput {
const rawText = body['text'];
const rawData = body['data'];
if (rawText === undefined && rawData === undefined) {
return { error: "Missing 'text' or 'data' parameter" };
}
// 1) plain `text` parameter
if (typeof rawText === 'string') {
return { text: rawText, runs: [{ text: rawText, offset: 0 }] };
}
// 2) `data` parameter (string JSON or object)
let data: unknown = rawData;
if (typeof rawData === 'string') {
try {
data = JSON.parse(rawData);
} catch {
// Not JSON — treat the raw string as plain text.
return { text: rawData, runs: [{ text: rawData, offset: 0 }] };
}
}
if (data && typeof data === 'object') {
const d = data as Record<string, unknown>;
if (typeof d['text'] === 'string' && d['annotation'] === undefined) {
return { text: d['text'], runs: [{ text: d['text'], offset: 0 }] };
}
if (Array.isArray(d['annotation'])) {
// Reconstruct the full document and collect maximal plain-text runs
// with offsets absolute into it (markup segments keep alignment).
let text = '';
let pendingStart = -1;
const runs: TextRun[] = [];
const flush = () => {
if (pendingStart >= 0) {
runs.push({ text: text.slice(pendingStart), offset: pendingStart });
pendingStart = -1;
}
};
for (const seg of d['annotation']) {
if (!seg || typeof seg !== 'object') continue;
const s = seg as Record<string, unknown>;
if (typeof s['text'] === 'string') {
if (pendingStart < 0) pendingStart = text.length;
text += s['text'];
} else if (typeof s['markup'] === 'string') {
flush();
text += s['markup'];
}
}
flush();
return { text, runs: runs.filter((r) => r.text.trim().length > 0) };
}
}
return { error: "Missing 'text' or 'data' parameter" };
}
function buildContext(fullText: string, offset: number, length: number, radius = 25): { text: string; offset: number; length: number } {
const from = Math.max(0, offset - radius);
const to = Math.min(fullText.length, offset + length + radius);
return {
text: fullText.slice(from, to),
offset: offset - from,
length: Math.max(0, length),
};
}
interface BaseMatchOpts {
message: string;
shortMessage?: string;
replacement: string;
}
function makeMatch(fullText: string, offset: number, length: number, opts: BaseMatchOpts): LtMatch {
return {
message: opts.message,
...(opts.shortMessage !== undefined ? { shortMessage: opts.shortMessage } : {}),
replacements: [{ value: opts.replacement }],
offset,
length,
context: buildContext(fullText, offset, length),
type: { typeName: 'grammar' },
rule: RULE,
ignoreForIncompleteSentence: false,
contextForSureMatch: -1,
};
}
/**
* Anchor a zero-width (pure insertion) fix to the nearest non-whitespace
* token, expressed as a LanguageTool replacement match:
* - insertion before token T (at or before T): match T, replace with added+T
* - insertion at run end: match the last token, replace with T+added
*/
function anchorInsertion(
fullText: string,
run: TextRun,
pos: number,
addedText: string,
): LtMatch | null {
const added = addedText.trim();
if (!added) return null;
const base = run.offset;
const isPunct = /^\s*[.,!?;:'"()\[\]{}]+\s*$/.test(added);
// Next non-whitespace token at/after pos (within the run).
const rest = run.text.slice(pos);
const mNext = rest.match(/^\s*(\S+)/);
if (mNext) {
const wsLen = mNext[0].length - mNext[1].length;
const tok = mNext[1];
const tokStart = pos + wsLen;
const tokEnd = tokStart + tok.length;
if (tokEnd <= run.text.length) {
const gap = run.text.slice(pos, tokStart);
// Punctuation attaches to the preceding word (no leading gap); a word
// keeps the preceding gap and gets exactly one space before the token.
const replacement = (isPunct ? added + (gap ? ' ' : '') + tok : gap + added + ' ' + tok) as string;
const collapsed = replacement.replace(/\s{2,}/g, ' ');
return makeMatch(fullText, base + pos, tokEnd - pos, {
message: isPunct ? `Add '${added}' after the previous word` : `Add '${added}' before '${tok}'`,
shortMessage: `add "${added}"`,
replacement: collapsed,
});
}
}
// No token after the insertion point → anchor on the last non-whitespace
// token of the run (insertion at the run's end).
const before = run.text.slice(0, pos);
const mLast = before.match(/(\S+)\s*$/);
if (mLast && mLast.index !== undefined) {
const tok = mLast[1];
const tokStart = mLast.index;
const replacement = (tok + (isPunct ? added : ` ${added}`)).replace(/\s{2,}/g, ' ');
return makeMatch(fullText, base + tokStart, tok.length, {
message: `Add '${added}' at the end of the sentence`,
shortMessage: `add "${added}"`,
replacement,
});
}
return null;
}
/**
* Map one core suggestion (offsets are absolute into the original full input
* text passed to correctText) to LanguageTool matches, rebased onto the
* editor document via `run`.
*/
export function suggestionToMatches(fullText: string, run: TextRun, suggestion: Suggestion): LtMatch[] {
const out: LtMatch[] = [];
const fixes = suggestion.wordFixes ?? [];
let usedWordFixes = false;
for (const fix of fixes) {
const m = wordFixToMatch(fullText, run, fix);
if (m) {
out.push(m);
usedWordFixes = true;
}
}
// Full rewrite with no word-level fixes: one sentence-level match over the
// changed middle span, replacing it with the corrected middle.
if (!usedWordFixes) {
const span = sentenceChangeSpan(run, suggestion);
if (span) {
out.push(
makeMatch(fullText, span.offset, span.length, {
message: 'Suggested grammar/style correction (rewrite by grammar-llm)',
shortMessage: 'rewrite',
replacement: span.replacement,
}),
);
}
}
return out;
}
/**
* GRMR models sometimes emit the corrected word wrapped in quotes:
* 'goed' -> '"good"', 'cat' -> '"Cat"'.
* The desktop app shows that verbatim in the review UI, but a LanguageTool-style
* client applies replacements[] verbatim, which would inject literal quote
* characters into the document (bad in LaTeX). The adapter layer therefore
* unwraps one pair of matching surrounding quotes from a word fix.
*/
function unwrapQuotedCorrection(corrected: string): string {
if (!corrected || corrected.length < 3) return corrected;
const pairs: Array<[string, string]> = [
['\u201C', '\u201D'], // “ ”
['"', '"'], // “ ”
['\u2018', '\u2019'], // ‘ ’
["'", "'"], // ' '
];
for (const [open, close] of pairs) {
if (corrected.startsWith(open) && corrected.endsWith(close) && corrected.length >= open.length + close.length + 1) {
const inner = corrected.slice(open.length, corrected.length - close.length);
if (inner.trim().length > 0 && !inner.includes('\n') && (inner.match(/\s/g) ?? []).length <= 1) {
return inner;
}
}
}
return corrected;
}
function wordFixToMatch(fullText: string, run: TextRun, fix: WordFix): LtMatch | null {
// WordFix offsets are absolute in the text passed to correctText (the run's
// text, 0-based) — rebase via run.offset.
const relStart = fix.start - 0; // fix.start is already run-relative
const relEnd = fix.end;
if (relStart < 0 || relStart > run.text.length) return null;
const base = run.offset;
if (relEnd > relStart) {
const length = Math.min(relEnd - relStart, run.text.length - relStart);
if (length <= 0) return null;
const replacement = unwrapQuotedCorrection(fix.corrected ?? '');
const message = replacement === '' ? `Remove '${fix.original}'` : `Shouldn't this be '${replacement}'?`;
return makeMatch(fullText, base + relStart, length, {
message,
shortMessage: replacement === '' ? undefined : replacement,
replacement,
});
}
// Zero width → pure insertion; anchor on a neighbouring token.
return anchorInsertion(fullText, run, relEnd, fix.corrected ?? '');
}
interface SpanResult {
offset: number;
length: number;
replacement: string;
}
/** Changed middle span of a sentence-level rewrite (run-relative). */
function sentenceChangeSpan(run: TextRun, s: Suggestion): SpanResult | null {
const original = s.original;
const corrected = s.corrected ?? '';
if (!original || !corrected || original === corrected) return null;
if (s.start_index < 0 || s.end_index <= s.start_index) return null;
const a = original;
const b = corrected;
let p = 0;
const max = Math.min(a.length, b.length);
while (p < max && a[p] === b[p]) p++;
let suffix = 0;
while (suffix < a.length - p && suffix < b.length - p && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) suffix++;
const relOffset = s.start_index + p;
const relLength = a.length - p - suffix;
if (relOffset < 0 || relLength <= 0 || relOffset >= run.text.length) return null;
const length = Math.min(relLength, run.text.length - relOffset);
if (length <= 0) return null;
return {
offset: run.offset + relOffset,
length,
replacement: b.slice(p, b.length - suffix),
};
}
/**
* Build the full LanguageTool v2 "check" JSON body.
*/
export function buildCheckResponse(opts: {
fullText: string;
matches: LtMatch[];
sentenceRanges: Array<[number, number]>;
languageCode?: string;
incomplete?: string;
reportedVersion?: string;
}): LtCheckResponse {
const lang = normalizeLanguage(opts.languageCode) ?? LANGUAGES[0];
const matches = [...opts.matches].sort((x, y) => x.offset - y.offset || x.length - y.length);
return {
software: {
name: 'LanguageTool',
version: opts.reportedVersion ?? '6.8',
buildDate: new Date().toISOString().slice(0, 10),
apiVersion: 1,
premium: false,
status: '',
},
warnings: {
incompleteResults: Boolean(opts.incomplete),
...(opts.incomplete ? { incompleteResultsReason: opts.incomplete } : {}),
},
language: {
name: lang.name,
code: lang.code,
detectedLanguage: { name: lang.name, code: lang.code, confidence: 1, source: 'given' },
},
matches,
sentenceRanges: opts.sentenceRanges ?? [],
};
}
+165
View File
@@ -0,0 +1,165 @@
// LLM inference wrapper. Mirrors grammar-llm's src/electron/llamaService.ts
// (SYSTEM_PROMPT, STOP_SEQUENCES, sampling params, per-sentence semantics) so
// the container produces the same corrections as the desktop app.
//
// node-llama-cpp v3.19 API (verified against the desktop's usage, which is
// itself "API verified against installed node-llama-cpp v3.19 types"):
// - getLlama() auto-detects CUDA / Metal / Vulkan / CPU
// - llama.loadModel({ modelPath }) -> model
// - contextSize belongs on model.createContext(), not loadModel()
// - sequences are created via context.getSequence()
// - Sampling params (temperature/topK/topP/minP/maxTokens/repeatPenalty/
// customStopTriggers) are per-prompt options for session.prompt(), NOT
// session options.
// - session.resetChatHistory() is called before each sentence.
// - node-llama-cpp is ESM with top-level await, so it is loaded via dynamic
// import() (see desktop modelManager.ts notes).
import type { Logger } from './log.js';
export interface Sampling {
temperature: number;
topK: number;
topP: number;
minP: number;
}
export interface LlamaServiceOptions {
modelPath: string;
contextSize: number;
sampling: Sampling;
pickyExtraPrompt?: string;
logger?: Logger;
}
/**
* Check level (LanguageTool's `level` parameter).
* - 'default': base correction prompt — byte-identical to the desktop app.
* - 'picky': base prompt + strict picky addendum (style, word choice,
* phrasing, punctuation — like LanguageTool's picky level
* adding rule families on top of the default set).
*/
export type CheckLevel = 'default' | 'picky';
export const SYSTEM_PROMPT =
'You are a grammar correction assistant. Correct the grammar, spelling, and punctuation of the given text. Return only the corrected text without any additional explanations or prefixes.';
export const DEFAULT_PICKY_EXTRA =
'Additionally, be highly picky, like a careful editor: also fix word choice, awkward or redundant phrasing, weak punctuation, and style issues. Prefer the most precise and idiomatic English. ';
export const STOP_SEQUENCES = ['<|endoftext|>', '<|corrected_end|>', '\n\n', 'Corrected:', 'Here is'];
interface SessionLike {
resetChatHistory(): void;
prompt(sentence: string, options: Record<string, unknown>): Promise<unknown>;
}
export class LlamaService {
private baseSession: SessionLike | null = null;
private pickySession: SessionLike | null = null;
private loading: Promise<void> | null = null;
private loadError: unknown = null;
readonly modelPath: string;
readonly contextSize: number;
readonly sampling: Sampling;
readonly pickyExtraPrompt: string;
private log: Logger;
constructor(opts: LlamaServiceOptions, log?: Logger) {
this.modelPath = opts.modelPath;
this.contextSize = opts.contextSize;
this.sampling = opts.sampling;
this.pickyExtraPrompt = opts.pickyExtraPrompt ?? DEFAULT_PICKY_EXTRA;
this.log = log ?? defaultLogger;
}
get loadErrorText(): string | null {
return this.loadError ? String(this.loadError) : null;
}
get isReady(): boolean {
return this.baseSession !== null;
}
async ensureLoaded(): Promise<void> {
if (this.baseSession) return;
if (this.loading) return this.loading;
this.loading = (async () => {
if (!this.modelPath) throw new Error('No model path configured');
// Dynamic import: node-llama-cpp is ESM with top-level await.
const { getLlama, LlamaChatSession } = await import('node-llama-cpp');
const llama = await getLlama(); // auto-detects CUDA / Metal / Vulkan / CPU
const model = await llama.loadModel({ modelPath: this.modelPath });
// sequences: 2 → one chat session per level (shared KV cache context).
const context = await model.createContext({ contextSize: this.contextSize, sequences: 2 });
// Two chat sessions on the shared KV context (separate sequences):
// - base: the desktop system prompt (default level = desktop parity)
// - picky: base + picky addendum in the SYSTEM slot. (A GRMR fine-tune
// echoes instructions placed in the user message, so the
// addendum must not go there.)
this.baseSession = new LlamaChatSession({
contextSequence: context.getSequence(),
systemPrompt: SYSTEM_PROMPT,
}) as unknown as SessionLike;
this.pickySession = new LlamaChatSession({
contextSequence: context.getSequence(),
systemPrompt: SYSTEM_PROMPT + ' ' + this.pickyExtraPrompt,
}) as unknown as SessionLike;
this.log.info('model loaded and ready', {
modelPath: this.modelPath,
contextSize: this.contextSize,
levels: ['default(base)', 'picky(base+editor addendum)'],
});
})();
try {
await this.loading;
} catch (err) {
this.loadError = err;
this.log.error('failed to load model', { err: String(err) });
throw err;
} finally {
this.loading = null;
}
}
/**
* Correct a single sentence — raw sentence in the user slot (desktop
* parity; GRMR fine-tunes echo user-slot instructions, so never put
* instructions there). Per-level selection happens via the session's
* system prompt (base vs base+picky addendum).
* Mirrors the desktop corrector: reset chat history, per-prompt sampling,
* trim, fall back to the original sentence on error so the pipeline keeps
* going.
*/
async correct(sentence: string, level: CheckLevel = 'default'): Promise<string> {
await this.ensureLoaded();
const session = level === 'picky' ? this.pickySession : this.baseSession;
if (!session) throw new Error('session not ready');
try {
session.resetChatHistory();
const response = (await session.prompt(sentence, {
maxTokens: sentence.length + 20,
temperature: this.sampling.temperature,
topK: this.sampling.topK,
topP: this.sampling.topP,
minP: this.sampling.minP,
repeatPenalty: { penalty: 1 }, // penalty 1 = disabled (desktop parity)
customStopTriggers: STOP_SEQUENCES,
})) as unknown as string;
return response.trim();
} catch (error) {
this.log.error(`Error correcting sentence '${sentence}':`, { err: String(error) });
return sentence;
}
}
}
const defaultLogger: Logger = {
debug: () => undefined,
info: (m) => console.log(m),
warn: (m) => console.warn(m),
error: (m, x) => console.error(m, x ? String(x) : ''),
};
+29
View File
@@ -0,0 +1,29 @@
/* Minimal leveled logger (no dependencies). Writes ISO-timestamped lines. */
type Level = 'debug' | 'info' | 'warn' | 'error';
const RANK: Record<Level, number> = { debug: 10, info: 20, warn: 30, error: 40 };
export interface Logger {
debug: (msg: string, extra?: Record<string, unknown>) => void;
info: (msg: string, extra?: Record<string, unknown>) => void;
warn: (msg: string, extra?: Record<string, unknown>) => void;
error: (msg: string, extra?: Record<string, unknown>) => void;
}
export function createLogger(minLevel: Level = 'info'): Logger {
const threshold = RANK[minLevel];
const emit = (level: Level, msg: string, extra?: Record<string, unknown>) => {
if (RANK[level] < threshold) return;
const line = extra && Object.keys(extra).length > 0 ? `${msg} ${JSON.stringify(extra)}` : msg;
const ts = new Date().toISOString();
if (level === 'error') console.error(`[${ts}] ${level.toUpperCase()} ${line}`);
else if (level === 'warn') console.warn(`[${ts}] ${level.toUpperCase()} ${line}`);
else console.log(`[${ts}] ${level.toUpperCase()} ${line}`);
};
return {
debug: (m, x) => emit('debug', m, x),
info: (m, x) => emit('info', m, x),
warn: (m, x) => emit('warn', m, x),
error: (m, x) => emit('error', m, x),
};
}
+204
View File
@@ -0,0 +1,204 @@
// OpenAI-compatible (chat/completions) backend — same contract as LlamaService
// so an Overleaf server without local inference capacity can redirect the LLM
// step to a hosted model (OpenAI, OpenRouter, vLLM, llama.cpp llama-server,
// Ollama /v1, a self-hosted GRMR-V3 LoRA endpoint, …).
//
// Prompt/stop/sampling semantics match src/llama.ts (and therefore the
// grammar-llm desktop app): same system prompt, same level addendum, same
// stop sequences, same sampling, same "fall back to the original sentence on
// error" behavior so the pipeline keeps going.
import type { Logger } from './log.js';
import type { Sampling, CheckLevel } from './llama.js';
import { SYSTEM_PROMPT, DEFAULT_PICKY_EXTRA, STOP_SEQUENCES } from './llama.js';
export interface OpenAIServiceOptions {
baseUrl: string;
apiKey: string;
model: string;
sampling: Sampling;
timeoutMs: number;
/** completion-token headroom over sentence length (num_predict / max_tokens margin). */
tokenPad?: number;
pickyExtraPrompt?: string;
/** Custom base system prompt (e.g. a fine-tune's trained prompt). Undefined = built-in. */
systemPrompt?: string;
logger?: Logger;
/** 'openai' = /chat/completions (default); 'ollama' = native /api/chat with think:false. */
style?: 'openai' | 'ollama';
}
export class OpenAIService {
readonly model: string;
private baseUrl: string;
private apiKey: string;
private sampling: Sampling;
private timeoutMs: number;
private tokenPad: number;
private pickyExtraPrompt: string;
private sysPrompt: string;
private log: Logger;
private style: 'openai' | 'ollama';
private pingDone = false;
constructor(opts: OpenAIServiceOptions) {
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
if (opts.style === 'ollama') this.baseUrl = this.baseUrl.replace(/\/v1$/, '');
this.apiKey = opts.apiKey;
this.model = opts.model;
this.sampling = opts.sampling;
this.timeoutMs = opts.timeoutMs;
this.tokenPad = Math.max(0, opts.tokenPad ?? 40);
this.pickyExtraPrompt = opts.pickyExtraPrompt ?? DEFAULT_PICKY_EXTRA;
this.sysPrompt = (opts.systemPrompt ?? '').trim();
this.log = opts.logger ?? defaultLogger;
this.style = opts.style ?? 'openai';
}
get isReady(): boolean {
return true; // remote backend — readiness is a connectivity question
}
get loadErrorText(): string | null {
return null;
}
/** No local model to load. Best-effort availability ping (never fatal). */
async ensureLoaded(): Promise<void> {
if (this.pingDone) return;
this.pingDone = true;
this.log.info('openai-compatible backend', { baseUrl: this.baseUrl, model: this.model, style: this.style });
try {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 5000);
const r = await fetch(`${this.baseUrl}/${this.style === 'ollama' ? 'api/tags' : 'models'}`, {
headers: this.authHeaders(),
signal: ctl.signal,
});
clearTimeout(t);
if (!r.ok) this.log.warn('model list endpoint not reachable/ok (will still attempt completions)', { status: r.status });
} catch (err) {
this.log.warn('model list endpoint unreachable (will still attempt completions)', { err: String(err) });
}
}
describe(): string {
return `openai:${this.baseUrl} model=${this.model}`;
}
private authHeaders(): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.apiKey) h['Authorization'] = `Bearer ${this.apiKey}`;
return h;
}
/**
* One chat call per sentence. `level === 'picky'` uses the system prompt +
* picky addendum; 'default' is the base prompt (identical to the desktop
* app). Returns the original sentence on any error.
*/
async correct(sentence: string, level: CheckLevel = 'default'): Promise<string> {
const base = this.sysPrompt || SYSTEM_PROMPT;
const system = level === 'picky' ? `${base} ${this.pickyExtraPrompt}` : base;
if (this.style === 'ollama') return this.correctOllama(sentence, system);
const body = {
model: this.model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: sentence },
],
temperature: this.sampling.temperature,
top_p: this.sampling.topP,
top_k: this.sampling.topK,
max_tokens: sentence.length + this.tokenPad,
stop: STOP_SEQUENCES,
};
try {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), this.timeoutMs);
const r = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(body),
signal: ctl.signal,
});
clearTimeout(t);
if (!r.ok) {
const text = await r.text().catch(() => '');
this.log.error('openai backend error', { status: r.status, body: text.slice(0, 300) });
return sentence;
}
const json: { choices?: Array<{ message?: { content?: string } }> } = await r.json();
const content = json.choices?.[0]?.message?.content;
if (typeof content !== 'string' || content.length === 0) {
this.log.error('openai backend returned no usable content', {});
return sentence;
}
return content.trim();
} catch (err) {
this.log.error(`Error correcting sentence '${sentence}' via openai backend:`, { err: String(err) });
return sentence;
}
}
/**
* Ollama native /api/chat path. Used for Qwen3-family and similar thinking
* models: the OpenAI-compat layer of this Ollama build ignores the
* top-level think flag and ~100× burns the completion budget on hidden
* reasoning (measured: 111–400 completion tokens for ~15-token answers,
* occasionally hitting max_tokens with an EMPTY answer). Native think:false
* suppresses reasoning entirely (measured: ~15 completion tokens, same
* answer) — same contract, deterministic budget.
*/
private async correctOllama(sentence: string, system: string): Promise<string> {
const body = {
model: this.model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: sentence },
],
stream: false,
think: false,
options: {
temperature: this.sampling.temperature,
top_k: this.sampling.topK,
top_p: this.sampling.topP,
stop: STOP_SEQUENCES,
num_predict: Math.max(sentence.length + this.tokenPad, 128),
},
};
try {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), this.timeoutMs);
const r = await fetch(`${this.baseUrl}/api/chat`, {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(body),
signal: ctl.signal,
});
clearTimeout(t);
if (!r.ok) {
const text = await r.text().catch(() => '');
this.log.error('ollama backend error', { status: r.status, body: text.slice(0, 300) });
return sentence;
}
const json: { message?: { content?: string } } = await r.json();
const content = json.message?.content;
if (typeof content !== 'string' || content.length === 0) {
this.log.error('ollama backend returned no usable content', {});
return sentence;
}
return content.trim();
} catch (err) {
this.log.error(`Error correcting sentence '${sentence}' via ollama backend:`, { err: String(err) });
return sentence;
}
}
}
const defaultLogger: Logger = {
debug: () => undefined,
info: (m) => console.log(m),
warn: (m) => console.warn(m),
error: (m, x) => console.error(m, x ? String(x) : ''),
};
+647
View File
@@ -0,0 +1,647 @@
// grammar-llm server — LanguageTool-compatible HTTP API (v2).
//
// Endpoints:
// GET /v2/languages LanguageTool language list
// POST /v2/check grammar check (form-urlencoded or JSON body)
// GET /v2/version version info
// GET /v2/health engine/model status (container HEALTHCHECK)
// GET / short info payload
//
// Drop-in for erikvl87/languagetool (docker-languagetool): same default port
// (8010), same wire API, `GET /v2/languages` + `POST /v2/check` semantics.
import http from 'node:http';
import { pathToFileURL } from 'node:url';
import { isLanguageSupported, loadConfig, type Config } from './config.js';
import { CheckEngine, type CheckEngine } from './engine.js';
import { createLogger, type Logger } from './log.js';
import {
buildCheckResponse,
getLanguages,
resolveCheckInput,
suggestionToMatches,
type LtMatch,
type TextRun,
} from './languagetool.js';
import { splitIntoSentences } from '../core/sentences.js';
import type { Suggestion } from '../core/types.js';
// Re-export for consumers/tests (e.g. building the real engine with the
// openai backend directly).
export { CheckEngine };
/** LLM half of a check (cascade race participant). */
interface LlmOutcome {
status: number;
body: unknown;
}
/** Real-LanguageTool half of the cascade race: null = nothing found / down. */
interface LtOutcome {
kind: 'lt';
value: Record<string, unknown> | null;
}
export interface EngineLike {
status(): {
state: string;
errorMessage: string | null;
modelPath: string;
checksCompleted: number;
checksFailed: number;
};
ensureReady(): Promise<void>;
check(
runs: TextRun[],
opts: { maxRuns?: number; requestTimeoutMs?: number; level?: 'default' | 'picky' },
): Promise<{ results: Array<{ suggestions: Suggestion[]; correctedText: string; incomplete: boolean }>; incomplete: boolean }>;
}
export interface ServerDeps {
cfg: Config;
engine: EngineLike;
log?: Logger;
}
interface RequestBody {
params: Record<string, string | undefined>;
json: Record<string, unknown> | null;
}
function readBody(req: http.IncomingMessage, maxBytes: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
let done = false;
req.on('data', (c: Buffer) => {
if (done) return;
size += c.length;
if (size > maxBytes) {
done = true;
req.removeAllListeners('data');
req.resume();
reject(Object.assign(new Error('body too large'), { statusCode: 413 }));
return;
}
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', (err) => reject(err));
});
}
function parseRequestBody(body: string, req: http.IncomingMessage): RequestBody {
const ctype = String(req.headers['content-type'] ?? '').toLowerCase();
const params: Record<string, string | undefined> = {};
let json: Record<string, unknown> | null = null;
if (ctype.includes('application/json')) {
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
json = parsed;
// Also expose scalar params (some clients mix both encodings).
for (const [k, v] of Object.entries(parsed)) {
if (typeof v === 'string') params[k] = v;
}
} catch {
// fall through to urlencoded
}
}
if (!json && (ctype.includes('application/x-www-form-urlencoded') || ctype === '' || ctype.includes('text/plain'))) {
const sp = new URLSearchParams(body);
for (const [k, v] of sp.entries()) {
params[k] = v;
}
// If it was actually JSON (no content-type), be helpful.
if (params['text'] === undefined && params['data'] === undefined && body.trim().startsWith('{')) {
try {
json = JSON.parse(body) as Record<string, unknown>;
for (const [k, v] of Object.entries(json)) {
if (typeof v === 'string') params[k] = v;
}
} catch {
/* not JSON after all */
}
}
}
return { params, json };
}
function jsonOut(res: http.ServerResponse, status: number, obj: unknown, allowOrigin: string): void {
const payload = JSON.stringify(obj);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': allowOrigin,
'Cache-Control': 'no-store',
});
res.end(payload);
}
function errorOut(res: http.ServerResponse, status: number, message: string, allowOrigin: string): void {
jsonOut(res, status, { message }, allowOrigin);
}
export class LtServer {
private inFlight = 0;
private readonly log: Logger;
constructor(private deps: ServerDeps) {
this.log = deps.log ?? createLogger(deps.cfg.logLevel);
this.httpServer = http.createServer((req, res) => {
this.log.debug('request', { method: req.method, url: req.url ?? '' });
void this.handle(req, res).catch((err) => {
this.log.error('unhandled error', { err: String(err) });
if (!res.headersSent) errorOut(res, 500, 'internal error', deps.cfg.allowOrigin);
else res.end();
});
});
}
readonly httpServer: http.Server;
listen(port: number, host: string): Promise<void> {
return new Promise((resolve, reject) => {
this.httpServer.once('error', reject);
this.httpServer.listen(port, host, () => {
this.log.info(`listening on http://${host}:${port} (LanguageTool-compatible API)`);
resolve();
});
});
}
close(): Promise<void> {
return new Promise((resolve) => this.httpServer.close(() => resolve()));
}
private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const { cfg } = this.deps;
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
const path = url.pathname;
const method = (req.method ?? 'GET').toUpperCase();
if (method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': cfg.allowOrigin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept',
'Access-Control-Max-Age': '86400',
});
res.end();
return;
}
if (method === 'GET' && (path === '/v2/languages' || path === '/languages')) {
jsonOut(res, 200, getLanguages(), cfg.allowOrigin);
return;
}
if (method === 'GET' && path === '/v2/version') {
jsonOut(res, 200, this.versionObject(), cfg.allowOrigin);
return;
}
if (method === 'GET' && (path === '/v2/health' || path === '/healthz' || path === '/health')) {
this.handleHealth(res);
return;
}
if (method === 'GET' && (path === '/' || path === '/index.html')) {
jsonOut(res, 200, { ...this.versionObject(), endpoints: ['/v2/languages', 'POST /v2/check', '/v2/version', '/v2/health'] }, cfg.allowOrigin);
return;
}
if (method === 'POST' && (path === '/v2/check' || path === '/check')) {
await this.handleCheck(req, res, url);
return;
}
if (path.startsWith('/v2/')) {
errorOut(res, 404, `unknown v2 endpoint: ${path}`, cfg.allowOrigin);
return;
}
errorOut(res, 404, `not found: ${path}`, cfg.allowOrigin);
}
private versionObject(): Record<string, unknown> {
const { cfg } = this.deps;
const st = this.deps.engine.status();
return {
name: 'LanguageTool',
version: cfg.reportedVersion,
apiVersion: 1,
engine: {
name: 'grammar-llm',
backend: cfg.backend,
...(cfg.backend === 'openai' ? { model: cfg.openaiModel } : { model: cfg.modelFile }),
state: st.state,
},
languageToolProxy: cfg.gtUrl ?? null,
defaultLevel: cfg.defaultLevel,
};
}
private handleHealth(res: http.ServerResponse): void {
const { cfg } = this.deps;
const st = this.deps.engine.status();
const ok = st.state === 'ready';
jsonOut(res, ok ? 200 : 503, {
status: ok ? 'ok' : 'degraded',
state: st.state,
...(st.errorMessage ? { error: st.errorMessage } : {}),
model: {
file: cfg.modelFile,
path: cfg.modelPath,
},
checks: { completed: st.checksCompleted, failed: st.checksFailed },
backend: cfg.backend,
languageToolProxy: cfg.gtUrl ?? null,
defaultLevel: cfg.defaultLevel,
inFlight: this.inFlight,
version: cfg.reportedVersion,
uptimeSec: Math.round(process.uptime()),
}, cfg.allowOrigin);
}
/**
* Forward the request to a real LanguageTool container (v2 API), exactly
* like LanguageTool itself would see it:
* - original `text` or `data` (annotation) payload — LT handles Overleaf's
* LaTeX annotations natively and returns offsets in editor-document space
* - `level` always present ('picky'→'picky', otherwise 'default')
* - `disabledRules` / `language` passed through
* Returns the LT response when it produced ≥1 match, otherwise null (the
* caller then runs the LLM). Never throws — LT failure means "try the LLM".
*/
private async forwardToLanguagetool(args: {
cfg: Config;
body: Buffer;
params: Record<string, string | undefined>;
bodyObj: Record<string, unknown>;
language: string;
level: 'default' | 'picky';
deadline: number;
}): Promise<Record<string, unknown> | null> {
const { cfg, body, params, bodyObj, language, level, deadline } = args;
const gtUrl = cfg.gtUrl;
if (!gtUrl) return null;
// Rebuild a clean form body from the merged params (LT v2 is form-based).
const text = typeof bodyObj['text'] === 'string' ? bodyObj['text'] : params['text'];
const data = typeof bodyObj['data'] === 'string' ? bodyObj['data'] : bodyObj['data'] ? JSON.stringify(bodyObj['data']) : params['data'];
if (!text && !data) return null; // nothing to forward (caller 400s)
const form = new URLSearchParams();
form.set('language', language);
form.set('level', level); // ← the picky flag, always explicit
if (typeof text === 'string') form.set('text', text);
if (typeof data === 'string') form.set('data', data);
const disabledRules = params['disabledRules'];
if (disabledRules) form.set('disabledRules', disabledRules);
const remaining = Math.max(500, Math.min(cfg.gtTimeoutMs, deadline - Date.now()));
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), remaining);
try {
const res = await fetch(`${gtUrl}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
signal: ctl.signal,
});
if (!res.ok) {
this.log.warn('real LanguageTool returned an error — falling back to grammar-llm', { status: res.status });
return null;
}
const json = (await res.json()) as Record<string, unknown>;
const matches = Array.isArray(json['matches']) ? (json['matches'] as unknown[]) : [];
if (matches.length === 0) return null; // nothing found → LLM runs second
// Pass the genuine LT response through (add missing envelope bits if
// the upstream LT is an unusual build).
if (json['software'] === undefined) json['software'] = { name: 'LanguageTool', version: cfg.reportedVersion, apiVersion: 1 };
if (json['warnings'] === undefined) json['warnings'] = { incompleteResults: false };
return json;
} catch (err) {
this.log.warn('real LanguageTool unreachable — falling back to grammar-llm', { err: String(err), gtUrl });
return null;
} finally {
clearTimeout(timer);
}
}
private async handleCheck(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {
const { cfg, engine } = this.deps;
// In-flight guard (protect the single LLM session from pileups).
if (this.inFlight >= cfg.maxInFlight) {
errorOut(res, 503, 'server busy — too many checks in flight, retry shortly', cfg.allowOrigin);
return;
}
this.inFlight++;
try {
const body = await readBody(req, cfg.maxBodyBytes).catch((err: unknown) => {
if ((err as { statusCode?: number })?.statusCode === 413) {
errorOut(res, 413, 'request body too large', cfg.allowOrigin);
return null;
}
throw err;
});
if (body === null) return;
// Query-string params may also carry the request (LT allows both); merged into bodyObj below.
const parsed = parseRequestBody(body.toString('utf8'), req);
const bodyObj: Record<string, unknown> = parsed.json ?? {};
// LanguageTool accepts text/data/level/language as query or body params;
// merge so both styles work.
if (bodyObj['text'] === undefined && parsed.params['text'] !== undefined) bodyObj['text'] = parsed.params['text'];
if (bodyObj['data'] === undefined && parsed.params['data'] !== undefined) bodyObj['data'] = parsed.params['data'];
const params: Record<string, string | undefined> = {
...Object.fromEntries(url.searchParams.entries()),
...Object.fromEntries(Object.entries(parsed.params)),
};
const language = params['language'] ?? 'auto';
// LanguageTool's `level`: 'picky' or 'default'. The level is ALWAYS forwarded
// to the real LanguageTool container (picky→picky) and drives the LLM mode.
const levelRaw = String(params['level'] ?? bodyObj['level'] ?? '').trim().toLowerCase();
const level: 'default' | 'picky' = levelRaw === 'picky' ? 'picky' : levelRaw === 'default' ? 'default' : cfg.defaultLevel;
if (levelRaw) this.log.debug('level requested', { levelRaw, resolved: level });
const disabledRules = params['disabledRules'] ?? (parsed.json?.['disabledRules'] as string | undefined);
if (disabledRules) this.log.debug('disabledRules (forwarded to real LT when configured; ignored by grammar-llm)', { disabledRules });
// 2) resolve text/runs (also validates text length)
const input = resolveCheckInput(bodyObj);
if ('error' in input) {
errorOut(res, 400, input.error, cfg.allowOrigin);
return;
}
const fullText = input.text;
if (fullText.trim().length === 0) {
jsonOut(res, 200, buildCheckResponse({ fullText, matches: [], sentenceRanges: [], languageCode: language, reportedVersion: cfg.reportedVersion }), cfg.allowOrigin);
return;
}
if (fullText.length > cfg.maxTextLength) {
errorOut(res, 413, `text too long (${fullText.length} > ${cfg.maxTextLength})`, cfg.allowOrigin);
return;
}
// 3) LANGUAGE GATE for the LLM (English model only). The real LT (when
// configured as a cascade front) can still serve other languages below.
// (Applied early so an unsupported language never spends LLM compute.)
if (!isLanguageSupported(cfg, language)) {
if (cfg.unsupportedLanguageMode === 'error') {
errorOut(res, 400, `language not supported (this deployment runs the English grammar-llm model; got '${language}')`, cfg.allowOrigin);
return;
}
this.log.info('unsupported language requested — returning empty matches', { language });
jsonOut(res, 200, buildCheckResponse({ fullText: '', matches: [], sentenceRanges: [], languageCode: language, reportedVersion: cfg.reportedVersion }), cfg.allowOrigin);
return;
}
// 4) CASCADE (when a real LanguageTool is configured): run the real LT
// and the LLM on ONE shared wall-clock budget (cascadeBudgetMs, kept
// under Overleaf's 60 s controller timeout — over that it silently shows
// nothing). Rules:
// - real LT with ≥1 match wins the moment it resolves (fast path,
// compute-saving: the LLM start is cancelled if it has not begun);
// - the LLM starts after llmGraceMs so a warm LT can win for free, but
// it then runs in PARALLEL — a slow/empty LT no longer blocks it;
// - whichever resolves first with a 200 result is served; 400/503 LLM
// outcomes only surface when the LT had nothing either.
if (cfg.gtUrl) {
const cascadeDeadline = Date.now() + cfg.cascadeBudgetMs;
const t0 = Date.now();
const ltPromise = this.forwardToLanguagetool({
cfg, body, params, bodyObj, language, level,
deadline: cascadeDeadline,
}).then((value) => ({ kind: 'lt' as const, value }));
const gate = { cancelled: false };
let cancelGate = (): void => { /* default: grace is 0 */ };
const gatePromise = new Promise<void>((resolve) => {
if (cfg.llmGraceMs === 0) { resolve(); return; }
const t = setTimeout(resolve, cfg.llmGraceMs);
cancelGate = () => { clearTimeout(t); gate.cancelled = true; resolve(); };
});
const llmPromise = (async () => {
await gatePromise;
if (gate.cancelled) {
// LT already won with matches: never start the LLM (zero waste).
return new Promise<LlmOutcome>(() => { /* suppressed on purpose */ });
}
return this.runLlmCheck({ cfg, language, level, fullText, runs: input.runs, cascadeDeadline });
})();
const outcome = await this.resolveCascade(ltPromise, llmPromise, () => cancelGate());
if (outcome.ltWon) {
this.log.info('real LanguageTool produced matches — LLM skipped', { gtUrl: cfg.gtUrl, level, ms: Date.now() - t0, matches: (outcome.body as { matches?: unknown[] })?.matches?.length });
} else {
this.log.info('real LT found no matches (or was unreachable) — serving LLM result', { gtUrl: cfg.gtUrl, level });
}
jsonOut(res, outcome.status, outcome.body, cfg.allowOrigin);
return;
}
// 5) LLM-ONLY path (no real LT configured).
const outcome = await this.runLlmCheck({ cfg, language, level, fullText, runs: input.runs, cascadeDeadline: null });
if (outcome.status !== 200) {
errorOut(res, outcome.status as 400 | 500 | 503, String((outcome.body as { message?: string })?.message ?? 'check failed'), cfg.allowOrigin);
return;
}
jsonOut(res, 200, outcome.body, cfg.allowOrigin);
} finally {
this.inFlight--;
}
}
/**
* The LLM half of a check (language gate → engine → LT JSON). Resolves to a
* full HTTP outcome, never throws (internal errors become 500 outcomes).
*/
private async runLlmCheck(args: {
cfg: Config;
language: string;
level: 'default' | 'picky';
fullText: string;
runs: TextRun[];
/** shared cascade deadline (Date.now()+budget), or null for LLM-only. */
cascadeDeadline: number | null;
}): Promise<LlmOutcome> {
const { cfg, language, level, fullText, runs, cascadeDeadline } = args;
const { engine } = this.deps;
try {
await engine.ensureReady();
} catch { /* surfaced via status() */ }
const st = engine.status();
if (st.state !== 'ready') {
const hint = st.state === 'error'
? ` engine error: ${st.errorMessage ?? 'unknown'}`
: ' (model still loading or downloading)';
return { status: 503, body: { message: `grammar-llm engine not ready${hint}` } };
}
try {
// The LLM gets the budget that is LEFT of the shared cascade window
// (or its own default in LLM-only mode), so the total response time
// stays under LT-client caps like Overleaf's 60 s.
const budgetMs = cascadeDeadline
? Math.min(cfg.requestTimeoutMs, Math.max(1500, cascadeDeadline - Date.now()))
: cfg.requestTimeoutMs;
const { results, incomplete } = await engine.check(runs, {
maxRuns: cfg.maxRuns,
requestTimeoutMs: budgetMs,
level,
});
const matches: LtMatch[] = [];
const sentenceRanges: Array<[number, number]> = [];
results.forEach((r, i) => {
for (const s of r.suggestions) {
matches.push(...suggestionToMatches(fullText, runs[i], s));
}
for (const s of splitIntoSentences(runs[i].text)) {
sentenceRanges.push([runs[i].offset + s.start, runs[i].offset + s.end]);
}
});
sentenceRanges.sort((a, b) => a[0] - b[0]);
this.log.info('LLM check completed', {
language, level, runs: results.length,
matches: matches.length, incomplete,
budgetMs,
});
const incompleteReason = incomplete
? 'Check stopped early: per-request time budget exhausted. Only a subset of the document was checked.'
: undefined;
return {
status: 200,
body: buildCheckResponse({
fullText,
matches,
sentenceRanges,
languageCode: language,
incomplete: incompleteReason,
reportedVersion: cfg.reportedVersion,
}),
};
} catch (err) {
this.log.error('LLM check failed', { err: String(err) });
return { status: 500, body: { message: 'grammar-llm engine failed during check' } };
}
}
/**
* Race between the real-LT outcome and the LLM outcome with LT preference:
* - LT resolves with matches → served immediately (LLM cancelled/ignored);
* - LLM resolves with 200 → served immediately (LT is a lost cause then);
* - a 400/503 LLM outcome is held back while the LT is still running;
* it is served only if the LT also had nothing.
*/
private async resolveCascade(
ltPromise: Promise<LtOutcome>,
llmPromise: Promise<LlmOutcome>,
onLltWin: () => void,
/**
* When the LLM is ready but the LT is still running, give the LT this many
* extra ms (capped) to land a match before serving the LLM. Keeps "real LT
* first" without waiting out the LT's full budget.
*/
ltExtraWaitMs = 1200,
): Promise<{ status: number; body: unknown; ltWon: boolean }> {
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
// Tag each side so we can identify which promise settled (identity is not
// preserved through Promise.race). The ids are unique, one per side.
const ltTagged = ltPromise.then((outcome) => ({ id: 'lt' as const, outcome }));
const llmTagged = llmPromise.then((outcome) => ({ id: 'llm' as const, outcome }));
const pending: Array<Promise<{ id: 'lt' | 'llm'; outcome: LtOutcome | LlmOutcome }>> = [ltTagged, llmTagged];
let staleLlm: { status: number; body: unknown } | undefined;
while (pending.length > 0) {
const { id, outcome } = await Promise.race(pending);
// Drop whichever entry settled, by its unique tag (not by object identity).
const settledWrapper = id === 'lt' ? ltTagged : llmTagged;
const at = pending.indexOf(settledWrapper);
if (at >= 0) pending.splice(at, 1);
if (id === 'lt') {
if (outcome.value) {
onLltWin(); // cancel the LLM start if it has not begun yet
return { status: 200, body: outcome.value, ltWon: true };
}
if (pending.length === 0) {
return staleLlm
? { status: staleLlm.status, body: staleLlm.body, ltWon: false }
: { status: 200, body: { software: { name: 'LanguageTool', version: '6.8', apiVersion: 1 }, warnings: {}, matches: [] }, ltWon: false };
}
continue; // wait for the LLM
}
// LLM outcome:
if (outcome.status === 200) {
if (pending.length > 0) {
// The LT is still running: give it a short capped chance to win.
const maybeLt = await Promise.race([
pending[0].then((o) => o),
sleep(Math.max(0, Math.min(ltExtraWaitMs, 1200))).then(() => null),
]);
if (maybeLt && maybeLt.id === 'lt' && maybeLt.outcome.value) {
onLltWin();
return { status: 200, body: maybeLt.outcome.value, ltWon: true };
}
}
return { status: 200, body: outcome.body, ltWon: false };
}
staleLlm = { status: outcome.status, body: outcome.body };
if (pending.length === 0) return { status: outcome.status, body: outcome.body, ltWon: false };
}
throw new Error('cascade resolved without an outcome');
}
}
export async function main(): Promise<void> {
const cfg = loadConfig();
const log = createLogger(cfg.logLevel);
const engine = new CheckEngine(cfg, log);
const lt = new LtServer({ cfg, engine, log });
if (cfg.preload) {
if (cfg.backend === 'openai') {
log.info('openai-compatible backend — no local model to preload', { baseUrl: cfg.openaiBaseUrl, model: cfg.openaiModel });
} else {
log.info('preloading model (first check waits for this to finish)…', { modelPath: cfg.modelPath });
}
engine.ensureReady().catch(() => {
// surfaced via /v2/health and as 503s on /v2/check
});
}
await lt.listen(cfg.port, cfg.host);
const shutdown = (sig: string) => {
log.info(`${sig} received, shutting down`);
void lt.close().then(() => process.exit(0));
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
// Auto-start when executed directly (node dist/server.mjs); no-op when imported by tests.
const isMain = (() => {
const entry = process.argv[1];
if (!entry) return false;
try {
return import.meta.url === pathToFileURL(entry).href;
} catch {
return false;
}
})();
if (isMain) {
main().catch((err) => {
console.error('fatal:', err);
process.exit(1);
});
}
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# grammar-llm container entrypoint.
#
# 1. Warn about LanguageTool-container env vars that don't apply here
# (drop-in sanity: admins often copy the old container's env verbatim).
# 2. Ensure the model file is present (download on first start by default).
# 3. Exec the HTTP server.
set -euo pipefail
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- drop-in compatibility warnings ---------------------------------------
while IFS= read -r name; do
[ -n "$name" ] || continue
echo "WARN: env '$name' is a LanguageTool (Java) setting; the grammar-llm container ignores it." >&2
done < <(env | grep -Eo '^langtool_[A-Za-z0-9_]*' | sort -u)
for var in Java_Xms Java_Xmx; do
case "$var" in Java_Xms) val="${Java_Xms:-}" ;; Java_Xmx) val="${Java_Xmx:-}" ;; *) val="" ;; esac
if [ -n "$val" ]; then
echo "WARN: env '$var' is a LanguageTool (Java) setting; the grammar-llm container ignores it." >&2
fi
done
# --- configuration (defaults match docker-languagetool expectations) -------
BACKEND="${GRAMMAR_LLM_BACKEND:-local}"
MODEL_DIR="${GRAMMAR_LLM_MODEL_DIR:-/models}"
MODEL_FILE="${GRAMMAR_LLM_MODEL_FILE:-GRMR-V3-G4B-Q4_K_M.gguf}"
MODEL_URL="${GRAMMAR_LLM_MODEL_URL:-https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf}"
MODEL_PATH="$MODEL_DIR/$MODEL_FILE"
DOWNLOAD_AT_START="${GRAMMAR_LLM_DOWNLOAD_AT_START:-true}"
export GRAMMAR_LLM_MODEL_DIR="$MODEL_DIR"
export GRAMMAR_LLM_MODEL_FILE="$MODEL_FILE"
export GRAMMAR_LLM_MODEL_URL="$MODEL_URL"
mkdir -p "$MODEL_DIR"
if [ "${BACKEND}" != "openai" ]; then
if [ ! -s "$MODEL_PATH" ]; then
if [ "$DOWNLOAD_AT_START" = "true" ]; then
echo "INFO: model not found at $MODEL_PATH — downloading $(basename "$MODEL_PATH") ..."
node "$APP_DIR/dist/download-model.mjs" --url "$MODEL_URL" --out "$MODEL_PATH"
else
echo "ERROR: model not found at $MODEL_PATH and GRAMMAR_LLM_DOWNLOAD_AT_START=false" >&2
echo " Mount the model file into $MODEL_DIR or set GRAMMAR_LLM_DOWNLOAD_AT_START=true." >&2
exit 1
fi
fi
if [ ! -s "$MODEL_PATH" ]; then
echo "ERROR: model still missing at $MODEL_PATH after download step" >&2
exit 1
fi
echo "INFO: using model $MODEL_PATH (local backend)"
else
echo "INFO: backend=openai (model=${GRAMMAR_LLM_OPENAI_MODEL:-<unset>} base=${GRAMMAR_LLM_OPENAI_BASE_URL:-https://api.openai.com/v1}) — no local model needed"
fi
echo "INFO: starting grammar-llm server (LanguageTool-compatible API) on port ${PORT:-8010}"
exec node "$APP_DIR/dist/server.mjs"
@@ -0,0 +1,303 @@
// Unit tests for the LanguageTool mapper & request resolution.
// Runs against the esbuild bundle (dist/languagetool.mjs) with the vendored
// core — no LLM, no network.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildCheckResponse,
getLanguages,
normalizeLanguage,
resolveCheckInput,
suggestionToMatches,
} from '../dist/languagetool.mjs';
const RUN = { text: 'He said teh thing.', offset: 0 };
function suggestionWith(fixes, original = 'He said teh thing.', corrected = 'He said the thing.') {
return {
original,
corrected,
sentence: 'Sentence 1',
start_index: 0,
end_index: original.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: fixes,
};
}
test('replacement word fix → LT match with absolute offset', () => {
const matches = suggestionToMatches(RUN.text, RUN, suggestionWith([{ original: 'teh', corrected: 'the', start: 8, end: 11 }]));
assert.equal(matches.length, 1);
const m = matches[0];
assert.equal(m.offset, 8);
assert.equal(m.length, 3);
assert.deepEqual(m.replacements, [{ value: 'the' }]);
assert.match(m.message, /'the'/);
assert.equal(m.rule.category.id, 'GRAMMAR'); // must not be 'TYPOS' (Overleaf filter)
assert.equal(m.context.text.slice(m.context.offset, m.context.offset + m.length), 'teh');
});
test('deletion word fix → empty replacement', () => {
const text = 'He said very very thing.';
const run = { text, offset: 0 };
const matches = suggestionToMatches(text, run, {
original: text,
corrected: 'He said very thing.',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: 'very', corrected: '', start: 13, end: 17 }],
});
assert.equal(matches.length, 1);
assert.equal(matches[0].offset, 13);
assert.equal(matches[0].length, 4);
assert.deepEqual(matches[0].replacements, [{ value: '' }]);
assert.match(matches[0].message, /Remove/);
});
test('punctuation insertion after a word attaches to the previous word (core ground truth)', () => {
// core emit for 'slow and happy' -> 'slowly, and happy':
// slow->slowly [0,4) + pure insertion ',' at pos 4
const text = 'slow and happy';
const run = { text, offset: 0 };
const matches = suggestionToMatches(text, run, {
original: text,
corrected: 'slowly, and happy',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: 'slow', corrected: 'slowly', start: 0, end: 4 }, { original: '', corrected: ',', start: 4, end: 4 }],
});
const m = matches[1];
assert.equal(m.offset, 4);
assert.equal(m.length, 4); // covers ' and' (gap + token)
assert.equal(m.replacements[0].value, ', and');
// Apply both matches (reverse offset order) to the original doc.
let doc = text;
for (const m2 of [...matches].sort((a, b) => b.offset - a.offset)) {
doc = doc.slice(0, m2.offset) + m2.replacements[0].value + doc.slice(m2.offset + m2.length);
}
assert.equal(doc, 'slowly, and happy');
});
test('word insertion before a token keeps spacing (core ground truth)', () => {
// core emit for 'quick and happy' -> 'quick and very happy':
// pure insertion 'very ' at pos 10 (before 'happy')
const text = 'quick and happy';
const run = { text, offset: 0 };
const matches = suggestionToMatches(text, run, {
original: text,
corrected: 'quick and very happy',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: '', corrected: 'very ', start: 10, end: 10 }],
});
const m = matches[0];
assert.equal(m.offset, 10);
assert.equal(m.length, 5); // token 'happy'
assert.equal(m.replacements[0].value, 'very happy');
const result = text.slice(0, m.offset) + m.replacements[0].value + text.slice(m.offset + m.length);
assert.equal(result, 'quick and very happy');
});
test('punctuation insertion right before a token (no gap)', () => {
const text = 'He ate andbutter fast';
const run = { text, offset: 0 };
const matches = suggestionToMatches(text, run, {
original: text,
corrected: 'He ate and,butter fast',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: '', corrected: ',', start: 10, end: 10 }],
});
const m = matches[0];
assert.equal(m.offset, 10);
assert.equal(m.length, 6); // token 'butter'
assert.equal(m.replacements[0].value, ',butter');
const result = text.slice(0, m.offset) + m.replacements[0].value + text.slice(m.offset + m.length);
assert.equal(result, 'He ate and,butter fast');
});
test('insertion at run end anchors on last token', () => {
const text = 'He ran quick';
const run = { text, offset: 0 };
const matches = suggestionToMatches(text, run, {
original: text,
corrected: 'He ran quick.',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: '', corrected: '.', start: 12, end: 12 }],
});
const m = matches[0];
const result = text.slice(0, m.offset) + m.replacements[0].value + text.slice(m.offset + m.length);
assert.equal(result, 'He ran quick.');
});
test('model quote artifacts are unwrapped from replacements', () => {
// GRMR sometimes wraps the corrected word in quotes: 'goed' -> '"good"'
const text = 'The cat is goed.';
const run = { text, offset: 0 };
for (const corrected of ['"good"', '\u201Cgood\u201D', "'good'"]) {
const matches = suggestionToMatches(text, run, {
original: text,
corrected: 'The cat is good.',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: 'goed', corrected, start: 11, end: 15 }],
});
assert.equal(matches.length, 1, 'one match for ' + corrected);
assert.equal(matches[0].replacements[0].value, 'good');
}
// …same for a different word — a grammar model quoting its replacement is a
// display artifact, not a request to add quotes to the document
const m2 = suggestionToMatches(text, run, {
original: text,
corrected: 'The cat is wonderful.',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: 'goed', corrected: '"wonderful"', start: 11, end: 15 }],
});
assert.equal(m2[0].replacements[0].value, 'wonderful');
});
test('sentence-level rewrite (no word fixes) → partial-span replacement, offset-rebased', () => {
const text = 'The committee met every day';
const run = { text, offset: 100 };
const s = suggestionWith([], 'The committee met every day', 'The committee has met daily');
const matches = suggestionToMatches(text, run, s);
assert.equal(matches.length, 1);
const m = matches[0];
const result = text.slice(0, m.offset - 100) + m.replacements[0].value + text.slice(m.offset - 100 + m.length);
assert.equal(result, 'The committee has met daily');
assert.ok(m.offset >= 100); // rebased onto the document
});
test('matches outside the run text are dropped', () => {
const run = { text: 'short', offset: 0 };
const matches = suggestionToMatches('short', run, suggestionWith([{ original: 'x', corrected: 'y', start: 99, end: 100 }]));
assert.equal(matches.length, 0);
});
test('resolveCheckInput: plain text param', () => {
const r = resolveCheckInput({ text: 'Hello world' });
assert.ok(!('error' in r));
assert.equal(r.text, 'Hello world');
assert.deepEqual(r.runs, [{ text: 'Hello world', offset: 0 }]);
});
test('resolveCheckInput: annotation (Overleaf LaTeX payload) keeps offset alignment', () => {
const annotation = [
{ text: 'The dog ' },
{ markup: '{\\textbf{runs}} ' },
{ text: 'fast.' },
];
const r = resolveCheckInput({ data: JSON.stringify({ annotation }) });
assert.ok(!('error' in r));
assert.equal(r.text, 'The dog {\\textbf{runs}} fast.');
assert.deepEqual(r.runs, [
{ text: 'The dog ', offset: 0 },
{ text: 'fast.', offset: 24 },
]);
});
test('resolveCheckInput: { text } data object', () => {
const r = resolveCheckInput({ data: { text: 'abc' } });
assert.equal(r.text, 'abc');
assert.deepEqual(r.runs, [{ text: 'abc', offset: 0 }]);
});
test('resolveCheckInput: missing text and data → error', () => {
const r = resolveCheckInput({});
assert.ok('error' in r);
assert.match(r.error, /Missing/);
});
test('resolveCheckInput: non-JSON string data is treated as text', () => {
const r = resolveCheckInput({ data: 'plain string data' });
assert.equal(r.text, 'plain string data');
});
test('normalizeLanguage: auto + en variants accepted, de rejected', () => {
assert.equal(normalizeLanguage(null).code, 'en-US');
assert.equal(normalizeLanguage('auto').code, 'en-US');
assert.equal(normalizeLanguage('en-GB').code, 'en-GB');
assert.equal(normalizeLanguage('EN').code, 'en');
assert.equal(normalizeLanguage('de'), null);
});
test('getLanguages: LanguageTool list shape (name/code/longCode)', () => {
const langs = getLanguages();
assert.ok(Array.isArray(langs));
for (const l of langs) {
assert.ok(typeof l.name === 'string');
assert.ok(typeof l.code === 'string');
assert.ok(typeof l.longCode === 'string');
}
assert.ok(langs.some((l) => l.code === 'en-US'));
});
test('buildCheckResponse: LT envelope shape + sorted matches + warnings', () => {
const body = buildCheckResponse({
fullText: 'He said teh thing.',
matches: [
{
message: 'm2',
replacements: [{ value: 'b' }],
offset: 10,
length: 1,
context: { text: 'x', offset: 0, length: 1 },
type: { typeName: 'grammar' },
rule: { id: 'GRAMMAR_LLM', description: 'd', issueType: 'Grammar', category: { id: 'GRAMMAR', name: 'Grammar' } },
ignoreForIncompleteSentence: false,
contextForSureMatch: -1,
},
{
message: 'm1',
replacements: [{ value: 'a' }],
offset: 3,
length: 1,
context: { text: 'x', offset: 0, length: 1 },
type: { typeName: 'grammar' },
rule: { id: 'GRAMMAR_LLM', description: 'd', issueType: 'Grammar', category: { id: 'GRAMMAR', name: 'Grammar' } },
ignoreForIncompleteSentence: false,
contextForSureMatch: -1,
},
],
sentenceRanges: [
[0, 18],
],
languageCode: 'en-GB',
incomplete: 'stopped early',
});
assert.equal(body.software.name, 'LanguageTool');
assert.equal(body.software.apiVersion, 1);
assert.equal(body.software.premium, false);
assert.equal(body.language.code, 'en-GB');
assert.equal(body.language.detectedLanguage.source, 'given');
assert.deepEqual(body.matches.map((m) => m.offset), [3, 10]);
assert.equal(body.warnings.incompleteResults, true);
assert.equal(body.warnings.incompleteResultsReason, 'stopped early');
assert.deepEqual(body.sentenceRanges, [[0, 18]]);
});
@@ -0,0 +1,737 @@
// HTTP API tests with a deterministic fake engine (no LLM, no network).
// Mirrors how Overleaf's LanguageToolController talks to a LT-compatible box:
// GET /v2/languages
// POST /v2/check (x-www-form-urlencoded: language, level, data/annotation,
// disabledRules — see LanguageToolController.mjs)
import test from 'node:test';
import assert from 'node:assert/strict';
import { LtServer } from '../dist/server.mjs';
import { CheckEngine } from '../dist/server.mjs';
import { loadConfig } from '../dist/config.mjs';
import http from 'node:http';
function makeConfig(over = {}) {
const env = {
PORT: '8010',
HOST: '127.0.0.1',
GRAMMAR_LLM_MODEL_DIR: '/tmp',
GRAMMAR_LLM_PRELOAD: 'false',
GRAMMAR_LLM_LOG_LEVEL: 'warn',
...over,
};
return loadConfig(env);
}
/** Deterministic "engine": corrects the literal word 'teh' → 'the' wherever it
* appears, and inserts a comma after 'quick'. */
class FakeEngine {
state = 'ready';
statusError = null;
checkCalls = 0;
lastLevel = null;
constructor() {}
status() {
return {
state: this.state,
errorMessage: this.statusError,
modelPath: '/tmp/fake.gguf',
checksCompleted: 0,
checksFailed: 0,
};
}
async ensureReady() {
if (this.state !== 'ready') throw new Error('model not ready');
}
async check(runs, opts = {}) {
if (this.state !== 'ready') throw new Error('not ready');
this.checkCalls++;
this.lastLevel = opts.level ?? null;
const results = [];
for (const run of runs) {
const out = { suggestions: [], correctedText: run.text, incomplete: false };
const t = run.text;
const idx = t.indexOf('teh');
if (idx >= 0) {
const corrected = t.slice(0, idx) + 'the' + t.slice(idx + 3);
out.correctedText = corrected;
out.suggestions = [
{
original: t,
corrected,
sentence: 'Sentence 1',
start_index: 0,
end_index: t.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: 'teh', corrected: 'the', start: idx, end: idx + 3 }],
},
];
}
const kidx = t.indexOf('quick');
if (kidx >= 0 && out.suggestions.length === 0) {
// insertion after 'quick' (pos = kidx+5)
const pos = kidx + 5;
const corrected = t.slice(0, pos) + ',' + t.slice(pos);
out.correctedText = corrected;
out.suggestions = [
{
original: t,
corrected,
sentence: 'Sentence 1',
start_index: 0,
end_index: t.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: '', corrected: ',', start: pos, end: pos }],
},
];
}
results.push(out);
}
return { results, incomplete: false };
}
}
async function boot(_over = {}, engineOverride, cfgOver = {}) {
const cfg = makeConfig(cfgOver);
const engine = engineOverride ?? new FakeEngine();
const server = new LtServer({ cfg, engine });
await server.listen(0, '127.0.0.1');
const port = server.httpServer.address().port;
return { cfg, engine, server, base: `http://127.0.0.1:${port}` };
}
async function close(server) {
await server.close();
}
test('GET /v2/languages: LT shape (Overleaf reads name+code)', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/languages`);
assert.equal(res.status, 200);
const body = (await res.json());
assert.ok(Array.isArray(body));
assert.ok(body.some((l) => l.code === 'en-US'));
} finally {
await close(server);
}
});
test('POST /v2/check (Overleaf style: form + annotation data)', async (t) => {
const { server, base } = await boot();
try {
const source = 'He wrote \\textbf{report} about teh project.';
const annotation = [
{ text: 'He wrote ' },
{ markup: '\\textbf{report} ' },
{ text: 'about teh project.' },
];
const params = new URLSearchParams();
params.set('language', 'auto');
params.set('level', 'picky');
params.set('data', JSON.stringify({ annotation }));
params.set('disabledRules', 'WHITESPACE_RULE,CONSECUTIVE_SPACES');
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: params.toString(),
});
assert.equal(res.status, 200);
const body = (await res.json());
assert.equal(body.software.name, 'LanguageTool');
assert.equal(body.software.apiVersion, 1);
assert.equal(body.warnings.incompleteResults, false);
assert.equal(body.language.code, 'en-US');
// 'teh' is at absolute offset: len('He wrote ') + len('\\textbf{report} ')
const baseOffset = 'He wrote '.length + '\\textbf{report} '.length;
assert.equal(body.matches.length, 1);
const m = body.matches[0];
assert.equal(m.offset, baseOffset + 'about '.length);
assert.equal(m.length, 3);
assert.deepEqual(m.replacements, [{ value: 'the' }]);
assert.equal(m.rule.category.id, 'GRAMMAR');
assert.ok(m.context.text.includes('teh'));
// overleaf invariant: offset within doc
assert.ok(m.offset + m.length <= source.length);
} finally {
await close(server);
}
});
test('POST /v2/check with JSON body', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: 'en-GB', level: 'default', text: 'I has teh book.' }),
});
assert.equal(res.status, 200);
const body = (await res.json());
assert.equal(body.language.code, 'en-GB');
assert.equal(body.matches.length, 1);
assert.equal(body.matches[0].offset, 6); // 'has ' before 'teh' → 'I has teh' → t@6
assert.deepEqual(body.matches[0].replacements, [{ value: 'the' }]);
} finally {
await close(server);
}
});
test('POST /v2/check: no text and no data → 400', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en',
});
assert.equal(res.status, 400);
const body = (await res.json());
assert.match(body.message, /Missing/);
} finally {
await close(server);
}
});
test('POST /v2/check: too-long text → 413', async (t) => {
const { server, base } = await boot({}, undefined, { GRAMMAR_LLM_MAX_TEXT_LENGTH: '3000' });
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&text=' + encodeURIComponent('a'.repeat(3500)),
});
assert.equal(res.status, 413);
} finally {
await close(server);
}
});
test('POST /v2/check: unsupported language → empty matches (default mode)', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=de&text=' + encodeURIComponent('Ich habe teh Buch.'),
});
assert.equal(res.status, 200);
const body = (await res.json());
assert.deepEqual(body.matches, []);
} finally {
await close(server);
}
});
test('POST /v2/check: unsupported language → 400 (error mode)', async (t) => {
const { server, base } = await boot({}, undefined, { GRAMMAR_LLM_UNSUPPORTED_LANGUAGE: 'error' });
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=de&text=' + encodeURIComponent('Ich habe teh Buch.'),
});
assert.equal(res.status, 400);
} finally {
await close(server);
}
});
test('POST /v2/check: engine not ready → 503 with message', async (t) => {
const engine = new FakeEngine();
engine.state = 'loading';
const { server, base } = await boot({}, engine);
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&text=' + encodeURIComponent('I has teh book.'),
});
assert.equal(res.status, 503);
const body = (await res.json());
assert.match(body.message, /not ready/);
} finally {
await close(server);
}
});
test('GET /v2/health reflects engine state', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/health`);
assert.equal(res.status, 200);
const body = (await res.json());
assert.equal(body.status, 'ok');
assert.equal(body.state, 'ready');
} finally {
await close(server);
}
});
test('GET /v2/health: not ready → 503', async (t) => {
const engine = new FakeEngine();
engine.state = 'error';
engine.statusError = 'boom';
const { server, base } = await boot({}, engine);
try {
const res = await fetch(`${base}/v2/health`);
assert.equal(res.status, 503);
const body = (await res.json());
assert.equal(body.state, 'error');
} finally {
await close(server);
}
});
test('GET /v2/version and / info payloads', async (t) => {
const { server, base } = await boot();
try {
const v = (await (await fetch(`${base}/v2/version`)).json());
assert.equal(v.name, 'LanguageTool');
assert.ok(v.engine);
const i = (await (await fetch(`${base}/`)).json());
assert.ok(Array.isArray(i.endpoints));
} finally {
await close(server);
}
});
test('unknown /v2 endpoint → 404 JSON', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/nope`);
assert.equal(res.status, 404);
const body = (await res.json());
assert.match(body.message, /unknown v2 endpoint/);
} finally {
await close(server);
}
});
test('OPTIONS preflight → CORS headers', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/check`, { method: 'OPTIONS' });
assert.ok([200, 204].includes(res.status));
assert.ok((res.headers.get('access-control-allow-origin') ?? '').includes('*'));
} finally {
await close(server);
}
});
test('responses carry CORS + no-store headers (browser client compat)', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/languages`);
assert.ok((res.headers.get('access-control-allow-origin') ?? '').includes('*'));
assert.equal(res.headers.get('cache-control'), 'no-store');
assert.match(res.headers.get('content-type') ?? '', /application\/json/);
} finally {
await close(server);
}
});
// ---------------------------------------------------------------------------
// New: LT-first cascade, LLM level modes, OpenAI-compatible remote backend
// ---------------------------------------------------------------------------
/** In-process fake LanguageTool (v2 /check). Optional {delayMs} to simulate a slow LT. */
function fakeLt(handler, { delayMs = 0 } = {}) {
const seen = { bodies: [] };
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
const body = new URLSearchParams(raw);
seen.bodies.push(Object.fromEntries(body.entries()));
const done = () => {
const out = handler(body, seen.bodies.length - 1) ?? { matches: [] };
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(out));
};
if (delayMs > 0) setTimeout(done, delayMs);
else done();
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({
url: `http://127.0.0.1:${port}`,
seen,
close: () => new Promise((r) => server.close(() => r())),
});
});
});
}
/** In-process fake OpenAI-compatible chat/completions endpoint. */
function fakeOpenAi(corrections = {}) {
const seen = { requests: [] };
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url?.endsWith('/models')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"data":[{"id":"fake"}]}');
return;
}
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', async () => {
const json = JSON.parse(raw || '{}');
seen.requests.push(json);
const user = json.messages?.[1]?.content ?? '';
const corrected = corrections[user] ?? user;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
choices: [{ message: { content: corrected } }],
}));
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({
baseUrl: `http://127.0.0.1:${port}/v1`,
seen,
close: () => new Promise((r) => server.close(() => r())),
});
});
});
}
test('cascade: real LT produces matches → LT wins, LLM skipped, level forwarded', async () => {
const lt = await fakeLt(() => ({
software: { name: 'LanguageTool', version: '6.4' },
warnings: { incompleteResults: false },
matches: [
{
message: 'FROM-REAL-LT',
shortMessage: 'LT-match',
replacements: [{ value: 'the' }],
offset: 2,
length: 3,
context: { text: 'I teh has', offset: 0, length: 9 },
type: { typeName: 'Typographical' },
rule: { id: 'RULE_LT', description: 'x', issueType: 'Typo', category: { id: 'TYPOS', name: 'Typos' } },
},
],
}));
try {
const engine = new FakeEngine();
const { server, base } = await boot(
{},
engine,
{ GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url, GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '2000' },
);
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=picky&text=' + encodeURIComponent('I teh has cats'),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.matches.length, 1);
assert.equal(body.matches[0].message, 'FROM-REAL-LT');
assert.equal(body.software.version, '6.4');
assert.equal(engine.checkCalls, 0, 'LLM must be skipped when LT has matches');
// the picky flag must reach the real LT verbatim:
assert.equal(lt.seen.bodies[0].level, 'picky');
assert.equal(lt.seen.bodies[0].language, 'en');
} finally {
await close(server);
}
} finally {
await lt.close();
}
});
test('cascade: real LT finds nothing → LLM runs second (fallback)', async () => {
const lt = await fakeLt(() => ({ matches: [] }));
try {
const engine = new FakeEngine();
const { server, base } = await boot(
{},
engine,
{ GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url, GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '2000' },
);
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=default&text=' + encodeURIComponent('I teh has cats'),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.matches.length, 1, 'fallback LLM match expected');
assert.match(body.matches[0].message, /the/);
assert.equal(engine.checkCalls, 1, 'LLM must run exactly once as fallback');
assert.equal(lt.seen.bodies[0].level, 'default');
} finally {
await close(server);
}
} finally {
await lt.close();
}
});
test('cascade: real LT down → LLM fallback still returns 200', async () => {
const engine = new FakeEngine();
const { server, base } = await boot(
{},
engine,
{
GRAMMAR_LLM_LANGUAGETOOL_URL: 'http://127.0.0.1:9', // closed port
GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '800',
},
);
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&text=' + encodeURIComponent('I teh has cats'),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.matches.length, 1);
assert.equal(engine.checkCalls, 1);
} finally {
await close(server);
}
});
test('cascade race: slow empty LT does NOT block the LLM (the 71 s bug)', async () => {
// The real-world bug: LT took (or was given) a long time and answered "0
// matches"; the LLM then ran AFTER it, pushing the total past Overleaf's
// 60 s controller timeout → the UI silently showed nothing.
// With the shared-budget concurrent cascade the LLM must be served as soon
// as IT is ready, even while the LT is still running.
const lt = await fakeLt(() => ({ matches: [] }), { delayMs: 2000 });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const engine = new FakeEngine();
const origCheck = engine.check.bind(engine);
// make the LLM take ~300 ms so it settles well before the 2 s LT
engine.check = async (runs, opts = {}) => {
await sleep(300);
return origCheck(runs, opts);
};
const { server, base } = await boot(
{},
engine,
{
GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url,
GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '4000',
GRAMMAR_LLM_CASCADE_BUDGET_MS: '6000',
GRAMMAR_LLM_LLM_GRACE_MS: '50', // LLM starts quickly, as in production
},
);
try {
const t0 = Date.now();
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&text=' + encodeURIComponent('I teh has cats'),
});
const elapsed = Date.now() - t0;
const body = await res.json();
assert.equal(res.status, 200);
assert.ok(body.matches.length >= 1, 'LLM fallback match must be present');
assert.ok(
elapsed < 2000,
`LLM result must be served before the slow empty LT settles (elapsed ${elapsed} ms; LT was 2000 ms)`,
);
assert.equal(engine.checkCalls, 1, 'the LLM ran');
} finally {
await close(server);
await lt.close();
}
});
test('cascade race: LT with matches still wins even when slower than the LLM grace', async () => {
const lt = await fakeLt(() => ({
software: { name: 'LanguageTool', version: '6.4' },
warnings: { incompleteResults: false },
matches: [
{
message: 'FROM-REAL-LT',
replacements: [{ value: 'the' }],
offset: 2,
length: 3,
context: { text: 'I teh has', offset: 0, length: 9 },
type: { typeName: 'Typographical' },
rule: { id: 'RULE_LT', category: { id: 'TYPOS', name: 'Typos' } },
},
],
}), { delayMs: 700 });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const engine = new FakeEngine();
const origCheck = engine.check.bind(engine);
engine.check = async (runs, opts = {}) => {
await sleep(200); // LLM would "finish" first — LT (700 ms) must still win
return origCheck(runs, opts);
};
const { server, base } = await boot(
{},
engine,
{
GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url,
GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '4000',
GRAMMAR_LLM_CASCADE_BUDGET_MS: '6000',
GRAMMAR_LLM_LLM_GRACE_MS: '50',
},
);
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=picky&text=' + encodeURIComponent('I teh has cats'),
});
const body = await res.json();
assert.equal(res.status, 200);
assert.equal(body.matches.length, 1);
assert.equal(body.matches[0].message, 'FROM-REAL-LT', 'the LT match must win');
} finally {
await close(server);
await lt.close();
}
});
test('level: absent/invalid → cfg.defaultLevel; explicit respected (LLM receives it)', async () => {
const mk = (env) => {
const engine = new FakeEngine();
return { engine, env: { ...env } };
};
// absent level
{
const { engine } = mk({});
const { server, base } = await boot({}, engine, { GRAMMAR_LLM_DEFAULT_LEVEL: 'picky' });
await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&text=' + encodeURIComponent('I teh has'),
});
assert.equal(engine.lastLevel, 'picky');
await close(server);
}
// invalid level → falls back to defaultLevel
{
const { engine } = mk({});
const { server, base } = await boot({}, engine, { GRAMMAR_LLM_DEFAULT_LEVEL: 'picky' });
await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=bogus&text=' + encodeURIComponent('I teh has'),
});
assert.equal(engine.lastLevel, 'picky');
await close(server);
}
// explicit picky
{
const { engine } = mk({});
const { server, base } = await boot({}, engine, {});
await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=picky&text=' + encodeURIComponent('I teh has'),
});
assert.equal(engine.lastLevel, 'picky');
await close(server);
}
// explicit default
{
const { engine } = mk({});
const { server, base } = await boot({}, engine, {});
await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=default&text=' + encodeURIComponent('I teh has'),
});
assert.equal(engine.lastLevel, 'default');
await close(server);
}
void mk;
});
test('openai backend: real engine pipeline against a fake OpenAI endpoint (default level prompt)', async () => {
const fake = await fakeOpenAi({ 'He run fast to school.': 'He ran fast to school.' });
try {
const cfg = makeConfig({
GRAMMAR_LLM_BACKEND: 'openai',
GRAMMAR_LLM_OPENAI_MODEL: 'fake-model-123',
GRAMMAR_LLM_OPENAI_BASE_URL: fake.baseUrl,
GRAMMAR_LLM_OPENAI_TIMEOUT_MS: '5000',
GRAMMAR_LLM_DEFAULT_LEVEL: 'default',
});
const log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
const engine = new CheckEngine(cfg, log);
const server = new LtServer({ cfg, engine, log });
await server.listen(0, '127.0.0.1');
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&text=' + encodeURIComponent('He run fast to school.'),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.matches.length, 1, 'core diff must produce the ran-fix');
assert.equal(body.matches[0].replacements[0].value, 'ran');
// request shape: model + system prompt
const req = fake.seen.requests[0];
assert.equal(req.model, 'fake-model-123');
assert.equal(req.messages[0].role, 'system');
assert.match(req.messages[0].content, /grammar correction assistant/);
assert.doesNotMatch(req.messages[0].content, /highly picky/);
} finally {
await server.close();
}
} finally {
await fake.close();
}
});
test('openai backend: picky level gets the stricter prompt; default does not', async () => {
const fake = await fakeOpenAi({ 'He run fast to school.': 'He ran fast to school.' });
try {
const mk = (defaultLevel) => {
const cfg = makeConfig({
GRAMMAR_LLM_BACKEND: 'openai',
GRAMMAR_LLM_OPENAI_MODEL: 'fake-model-123',
GRAMMAR_LLM_OPENAI_BASE_URL: fake.baseUrl,
GRAMMAR_LLM_OPENAI_TIMEOUT_MS: '5000',
GRAMMAR_LLM_DEFAULT_LEVEL: defaultLevel,
});
const log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
const engine = new CheckEngine(cfg, log);
return new LtServer({ cfg, engine, log });
};
// explicit picky
{
const server = mk('default');
await server.listen(0, '127.0.0.1');
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=picky&text=' + encodeURIComponent('He run fast to school.'),
});
await server.close();
}
const pickyReq = fake.seen.requests[fake.seen.requests.length - 1];
assert.match(pickyReq.messages[0].content, /highly picky/);
assert.match(pickyReq.messages[0].content, /grammar correction assistant/);
} finally {
await fake.close();
}
});
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# smoke.sh — end-to-end check against a RUNNING grammar-llm server.
#
# This does NOT start the server (it would need the model). Run a server
# first, e.g.:
#
# node dist/download-model.mjs --out /tmp/models/GRMR-V3-G4B-Q4_K_M.gguf \
# --url https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf
# GRAMMAR_LLM_MODEL_DIR=/tmp/models PORT=8090 node dist/server.mjs &
#
# then:
#
# BASE=http://127.0.0.1:8090 npm run smoke
#
# or against the docker container:
#
# BASE=http://127.0.0.1:8010 npm run smoke
set -euo pipefail
BASE="${BASE:-http://127.0.0.1:8010}"
pass=0
fail=0
say() { printf '\n\033[1m== %s ==\033[0m\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; pass=$((pass+1)); }
bad() { printf ' \033[31m✗\033[0m %s\n' "$*"; fail=$((fail+1)); }
say "GET $BASE/v2/languages"
body="$(curl -fsS "$BASE/v2/languages")"
if echo "$body" | jq -e '.[0].name and .[0].code' >/dev/null 2>&1; then
ok "languages list has name+code"
echo "$body" | jq -c '.[0]'
else
bad "languages list unexpected: $body"
fi
say "GET $BASE/v2/health"
body="$(curl -fsS "$BASE/v2/health" || true)"
state="$(echo "$body" | jq -r '.state // "missing"' 2>/dev/null || echo missing)"
echo " state: $state"
[ "$state" = "ready" ] && ok "model ready" || { echo " (model not ready — /v2/check will 503; continuing for shape checks)"; }
say "POST $BASE/v2/check (text=…, Overleaf style)"
code="$(curl -s -o /tmp/glm-smoke-check.json -w '%{http_code}' -X POST "$BASE/v2/check" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'language=auto' \
--data-urlencode 'level=picky' \
--data-urlencode 'disabledRules=WHITESPACE_RULE,CONSECUTIVE_SPACES' \
--data-urlencode 'text=I has two cats. They is very goed cat.')"
resp="$(cat /tmp/glm-smoke-check.json)"
echo "$resp" | head -c 400; echo
if [ "$code" = "200" ]; then
n="$(echo "$resp" | jq '.matches | length' 2>/dev/null || echo '?')"
echo " matches: $n"
echo "$resp" | jq -c '.matches[]? | {offset, length, replacements: [ .replacements[].value ], message}' 2>/dev/null | head -5 || true
if [ "$n" != "?" ] && [ "$n" -ge 1 ]; then ok "produced matches"; else bad "expected >=1 match (model may have found none)"; fi
if echo "$resp" | jq -e '.software // .software' >/dev/null 2>&1; then ok "has software block"; else bad "no software block"; fi
else
# 503 (model not ready) is acceptable for a shape test, but report status
echo " status: $code"
[ "$code" = "503" ] && { echo " (model still loading — rerun smoke once /v2/health shows ready)"; } || bad "unexpected status $code"
fi
say "POST /v2/check with annotation (LaTeX markup must be skipped)"
resp="$(curl -s -X POST "$BASE/v2/check" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'language=en-US' \
--data-urlencode 'data={"annotation":[{"text":"The "},{"markup":"{\\textbf{bold}} "},{"text":"cat is goed."}]}' )"
echo "$resp" | head -c 400; echo
# No match may fall inside the markup span [4,16)
if echo "$resp" | jq -e '.matches[] | select(.offset >= 4 and .offset < 16)' >/dev/null 2>&1; then
bad "a match overlaps the LaTeX markup span (offsets misaligned?)"
else
ok "no match crosses the markup span"
fi
say "negative: missing text/data → 400"
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/v2/check" -H 'Content-Type: application/x-www-form-urlencoded' --data 'language=en')"
[ "$code" = "400" ] && ok "400 as expected" || bad "expected 400, got $code"
say "CORS preflight"
code="$(curl -s -o /dev/null -w '%{http_code}' -X OPTIONS "$BASE/v2/check")"
[ "$code" = "204" ] || [ "$code" = "200" ] && ok "preflight $code" || bad "preflight $code"
say "summary"
echo " pass=$pass fail=$fail"
[ "$fail" -eq 0 ]
@@ -0,0 +1,77 @@
import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js'
import { promiseMapWithLimit } from '@overleaf/promise-utils'
import { db } from './lib/mongodb.mjs'
const tags = ['server-ce', 'server-pro']
const WRITE_CONCURRENCY = parseInt(process.env.WRITE_CONCURRENCY, 10) || 10
// The Zotero module used to store linkedFileData with its own key names.
// "Zotero: fix for 6.3.0" switched to the names accepted by the
// overleaf-editor-core schema, but files imported before that still carry the
// old keys, which document-updater rejects (e.g. when cloning the project).
const LEGACY_KEYS = [
'zoteroGroupId',
'importedByUserId',
'importedByName',
'bibFormat',
]
function hasLegacyKeys(data) {
return (
data?.provider === 'zotero' && LEGACY_KEYS.some(key => key in data)
)
}
function convert(data) {
const importerId = data.importer_id ?? data.importedByUserId
const format = data.format ?? data.bibFormat
return {
provider: 'zotero',
group_id: data.group_id ?? data.zoteroGroupId ?? null,
...(data.importedAt && { importedAt: data.importedAt }),
...(importerId && { importer_id: importerId.toString() }),
importer_name: data.importer_name ?? data.importedByName ?? 'Unknown',
format: format === 'biblatex' ? 'biblatex' : 'bibtex',
}
}
function collectUpdates(folder, mongoPath, updates) {
if (!folder) return
;(folder.fileRefs || []).forEach((file, i) => {
if (hasLegacyKeys(file?.linkedFileData)) {
updates[`${mongoPath}.fileRefs.${i}.linkedFileData`] = convert(
file.linkedFileData
)
}
})
;(folder.folders || []).forEach((subfolder, i) => {
collectUpdates(subfolder, `${mongoPath}.folders.${i}`, updates)
})
}
const migrate = async () => {
await batchedUpdate(
db.projects,
{},
async nextBatch => {
await promiseMapWithLimit(WRITE_CONCURRENCY, nextBatch, async project => {
const updates = {}
;(project.rootFolder || []).forEach((folder, i) => {
collectUpdates(folder, `rootFolder.${i}`, updates)
})
if (Object.keys(updates).length === 0) return
await db.projects.updateOne({ _id: project._id }, { $set: updates })
})
},
{ _id: 1, rootFolder: 1 }
)
}
const rollback = async () => {}
export default {
tags,
migrate,
rollback,
}
+2
View File
@@ -0,0 +1,2 @@
.env
backup_seaweedfs_online
+1
View File
@@ -0,0 +1 @@
docker pull chrislusf/seaweedfs:latest
+2
View File
@@ -0,0 +1,2 @@
sudo apt update
sudo apt install -y awscli
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
# Load environment variables from .env file
if [ -f .env ]; then
set -a
source .env
set +a
else
echo "Error: .env file not found!" >&2
exit 1
fi
export AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY
ENDPOINT="http://localhost:8333"
echo "Testing connection to SeaweedFS S3 gateway..."
aws --endpoint-url "$ENDPOINT" s3 ls
# Array of required Overleaf buckets
BUCKETS=(
"overleaf-user-files"
"overleaf-template-files"
"overleaf-project-blobs"
"overleaf-chunks"
)
echo "Checking and creating Overleaf buckets..."
for bucket in "${BUCKETS[@]}"; do
if ! aws --endpoint-url "$ENDPOINT" s3 ls "s3://$bucket" >/dev/null 2>&1; then
echo "Creating bucket: s3://$bucket"
aws --endpoint-url "$ENDPOINT" s3 mb "s3://$bucket"
else
echo "Bucket already exists: s3://$bucket"
fi
done
echo "Bucket setup complete. Current buckets:"
aws --endpoint-url "$ENDPOINT" s3 ls
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
# Path to your .env file (adjust if your script runs from a different directory)
ENV_FILE="./.env"
if [ -f "$ENV_FILE" ]; then
set -a
source "$ENV_FILE"
set +a
else
echo "Error: .env file not found at $ENV_FILE" >&2
exit 1
fi
# Ensure AWS variables are exported for the AWS CLI
export AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY
ENDPOINT="http://localhost:8333"
BACKUP_DIR="./backup_seaweedfs_online"
TIMESTAMP=$(date +%F_%H-%M-%S)
mkdir -p "$BACKUP_DIR/$TIMESTAMP"
echo "1. Capturing online metadata snapshot..."
docker exec seaweedfs-filer weed shell -master="master:9333" <<< "fs.meta.save /data/filer_meta_$TIMESTAMP.json"
echo "2. Syncing S3 buckets live..."
for bucket in overleaf-user-files overleaf-template-files overleaf-project-blobs overleaf-chunks; do
echo "Syncing s3://$bucket..."
aws --endpoint-url "$ENDPOINT" s3 sync "s3://$bucket" "$BACKUP_DIR/$TIMESTAMP/$bucket"
done
echo "Online backup completed successfully at $TIMESTAMP"
+169
View File
@@ -0,0 +1,169 @@
# SeaweedFS S3 Server (Docker Compose)
A small single-node SeaweedFS cluster with a hosted S3 API. It serves as the
S3 backend (filestore *and* history).
All four services share one image (`chrislusf/seaweedfs:latest`); the root
`compose.yaml` merges them from the per-service files with `extends:`.
## Layout
```
.
├── compose.yaml # root compose (merges the 4 services, depends_on order)
├── seaweedfs-master/compose.yaml # cluster metadata + data/ (persisted)
├── seaweedfs-volume/compose.yaml # data bricks + data/ (persisted)
├── seaweedfs-filer/compose.yaml # file namespace + data/ (persisted)
├── seaweedfs-s3/compose.yaml # S3 API gateway (no data dir)
├── .env / env.sample # S3 credentials (never commit .env)
├── 0_pull_other_containers.sh # setup: pull the shared image
├── 1_install_aws.sh # setup: awscli on the host
├── 2_create_s3_buckets.sh # setup: create the Overleaf buckets (idempotent)
├── 3_online_backup_s3.sh # backup: metadata snapshot + bucket sync
├── autoupdate_seaweedfs-*.sh # update: one service at a time, only if image is newer
├── up.sh / down.sh # whole stack up / down
└── up|down|logs_seaweedfs-*.sh # per-service shortcuts
```
## Services, ports, network aliases
Start-up order is enforced with `depends_on ... condition: service_healthy`:
`master` → `volume` → `filer` → `s3`.
| Service | Network alias | Host ports (host → container) | Role | Healthcheck |
|--------------------|---------------|------------------------------------------------|-------------------------------|---------------------------|
| `seaweedfs-master` | `master` | `9333` (master HTTP API), `19333` (master gRPC), `9327` (Prometheus metrics) | cluster metadata | `:9333/cluster/healthz` |
| `seaweedfs-volume` | `volume` | `18080` → `8080` (volume gRPC; host `8080` is taken by another service) | data bricks | `:8080/status` |
| `seaweedfs-filer` | `filer` | `8888` (filer HTTP), `18888` (filer gRPC) | file namespace / directories | `:8888/` |
| `seaweedfs-s3` | `s3` | `8333` (S3 API) | S3 gateway for AWS-SDK clients | `:8333/` answers HTTP |
**Where the aliases resolve (common gotcha):**
- The aliases (`s3`, `filer`, `master`, `volume`) are registered on the
compose network (default project network of this folder, i.e.
`s3_server_compose_default`).
- ✅ They resolve from **any container attached to that network** — that is
why `http://s3:8333` works in the Overleaf config below. Check with
`docker exec seaweedfs-filer ping -c1 s3` (must reply from the stack IP).
- ❌ They do **not** resolve from the host machine — Docker's embedded DNS
exists only inside the network. From the host, always use the published
ports, e.g. `http://localhost:8333` (your `2_`/`3_` scripts already do this).
- A container on a *different* network cannot reach the aliases at all; it
must either be attached to this network (`docker network connect
s3_server_compose_default <container>`) or use `http://<host>:8333`.
## First-time setup
```bash
cp env.sample .env # then fill in keys (generation one-liners are in the file)
./0_pull_other_containers.sh
./1_install_aws.sh # installs awscli via apt on the host
./up.sh # = docker compose up -d (wait until all 4 are healthy)
./2_create_s3_buckets.sh # idempotent: creates the 4 Overleaf buckets if missing
```
Verify:
```bash
docker ps | grep seaweedfs # 4 × healthy
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8333/ # 403 = S3 API answers (expected)
```
The Overleaf buckets created by `2_create_s3_buckets.sh`:
`overleaf-user-files`, `overleaf-template-files`, `overleaf-project-blobs`, `overleaf-chunks`
## Overleaf integration
For the Overleaf container compose.yaml file (it is assume that the overleaf compose contains AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY):
```
#########################################
## Overleaf Filestore S3 Configuration ##
#########################################
OVERLEAF_FILESTORE_BACKEND: s3
OVERLEAF_FILESTORE_USER_FILES_BUCKET_NAME: overleaf-user-files
OVERLEAF_FILESTORE_TEMPLATE_FILES_BUCKET_NAME: overleaf-template-files
OVERLEAF_FILESTORE_S3_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
OVERLEAF_FILESTORE_S3_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
OVERLEAF_FILESTORE_S3_ENDPOINT: http://s3:8333
OVERLEAF_FILESTORE_S3_PATH_STYLE: "true"
OVERLEAF_FILESTORE_S3_REGION: "us-east-1"
#########################################
## Overleaf History S3 Configuration ##
#########################################
OVERLEAF_HISTORY_BACKEND: s3
OVERLEAF_HISTORY_PROJECT_BLOBS_BUCKET: overleaf-project-blobs
OVERLEAF_HISTORY_CHUNKS_BUCKET: overleaf-chunks
OVERLEAF_HISTORY_S3_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
OVERLEAF_HISTORY_S3_ENDPOINT: http://s3:8333
OVERLEAF_HISTORY_S3_PATH_STYLE: "true"
OVERLEAF_HISTORY_S3_REGION: "us-east-1"
```
Notes:
- `http://s3:8333` relies on the `s3` network alias — it only works while the
Overleaf **server container is attached to this stack's network**
(same compose network as the `seaweedfs-*` containers).
- If the Overleaf server runs elsewhere (other host, other compose network),
replace the endpoint with `http://<reachable-host>:8333` in both
`OVERLEAF_FILESTORE_S3_ENDPOINT` and `OVERLEAF_HISTORY_S3_ENDPOINT`.
- `OVERLEAF_*_S3_PATH_STYLE: "true"` is required (SeaweedFS is a virtual-host
style-incompatible gateway), and the region value is cosmetic for a local
gateway but must be set consistently.
## Scripts
### Setup (numbered)
| Script | What it does |
|---------------------------|--------------|
| `0_pull_other_containers.sh` | `docker pull chrislusf/seaweedfs:latest` — all four services share this one image, so a single pull covers the whole stack. |
| `1_install_aws.sh` | Installs `awscli` on the host via `apt` (needed by the bucket/backup scripts, which use `aws s3` locally). |
| `2_create_s3_buckets.sh` | Loads `.env`, checks reachability of `http://localhost:8333`, then creates the four Overleaf buckets if they don't exist yet. **Idempotent** — safe to re-run. |
| `3_online_backup_s3.sh` | **Online** backup (no downtime): ① snapshots filer metadata into the running filer via `weed shell` (`fs.meta.save`, reached through the `master:9333` alias), ② `aws s3 sync`s all four buckets into `./backup_seaweedfs_online/<TIMESTAMP>/<bucket>/`. |
### Updates
| Script | What it does |
|-------------------------------|--------------|
| `autoupdate_seaweedfs-master.sh` / `…-volume.sh` / `…-filer.sh` / `…-s3.sh` | Pulls the latest image, compares the short image ID of the running container with the local one; if unchanged → no-op, if newer → `docker compose down` + `up -d` **of that service only**; if the container is missing → just starts it. Sources `.env` first. |
> The autoupdate scripts `cd "$COMPOSE_DIRECTORY"` before calling
> `docker compose`, which now defaults to the **current directory**
> (`${COMPOSE_DIRECTORY:-$(pwd)}`). Just run them from this folder,
> or point `COMPOSE_DIRECTORY` somewhere else to override.
To update the whole stack, run the four in this order: `master` → `volume` →
`filer` → `s3`.
### Everyday
| Script | Equivalent |
|---------------------------------|------------|
| `up.sh` / `down.sh` | `docker compose up -d` / `docker compose down` |
| `up_seaweedfs-<svc>.sh` | `docker compose up -d <svc>` |
| `down_seaweedfs-<svc>.sh` | `docker compose down <svc>` |
| `logs_seaweedfs-<svc>.sh` | `docker compose logs -f <svc>` |
## Data & operations
- Persistent data: `seaweedfs-master/data/`, `seaweedfs-volume/data/`,
`seaweedfs-filer/data/` (bind-mounted at `/data` in each container).
The S3 gateway needs no data dir. Back up the **volume** data dir (the
actual file bytes) — metadata + buckets are reconstructed/synced by the
other pieces.
- All services run `restart: unless-stopped`; healthchecks per the table
above gate the start-up order and `depends_on`.
- Metrics: Prometheus endpoint at `http://localhost:9327` (master).
- Security: the S3 gateway binds `0.0.0.0:8333` — the keypair in `.env` is
the only gate (plus a `403` on anonymous requests). Keep `.env` private
(already git-ignored) and firewall `8333` if this host is reachable from
the network.
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
# Load environment variables from .env file
if [ -f .env ]; then
set -a
source .env
set +a
else
echo "Error: .env file not found!" >&2
exit 1
fi
SERVICE_NAME="seaweedfs-filer"
IMAGE_NAME="chrislusf/seaweedfs:latest"
YAML_DIRECTORY_NAME="${COMPOSE_DIRECTORY:-$(pwd)}"
# Get current image ID (short format)
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
# Pull the latest image
echo "Pulling latest image..."
docker pull "${IMAGE_NAME}"
# Get new image ID (short format)
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
echo "Current image: $CURRENT_IMAGE"
echo "Latest image: $NEW_IMAGE"
# Compare and restart only if different
if [ -z "$CURRENT_IMAGE" ]; then
echo "Container not found, starting it up..."
cd "${YAML_DIRECTORY_NAME}"
docker compose up -d "${SERVICE_NAME}"
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
echo "New image detected, restarting container..."
cd "${YAML_DIRECTORY_NAME}"
docker compose down "${SERVICE_NAME}"
docker compose up -d "${SERVICE_NAME}"
echo "Update completed!"
else
echo "Already running the latest version, no restart needed."
fi
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
# Load environment variables from .env file
if [ -f .env ]; then
set -a
source .env
set +a
else
echo "Error: .env file not found!" >&2
exit 1
fi
SERVICE_NAME="seaweedfs-master"
IMAGE_NAME="chrislusf/seaweedfs:latest"
YAML_DIRECTORY_NAME="${COMPOSE_DIRECTORY:-$(pwd)}"
# Get current image ID (short format)
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
# Pull the latest image
echo "Pulling latest image..."
docker pull "${IMAGE_NAME}"
# Get new image ID (short format)
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
echo "Current image: $CURRENT_IMAGE"
echo "Latest image: $NEW_IMAGE"
# Compare and restart only if different
if [ -z "$CURRENT_IMAGE" ]; then
echo "Container not found, starting it up..."
cd "${YAML_DIRECTORY_NAME}"
docker compose up -d "${SERVICE_NAME}"
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
echo "New image detected, restarting container..."
cd "${YAML_DIRECTORY_NAME}"
docker compose down "${SERVICE_NAME}"
docker compose up -d "${SERVICE_NAME}"
echo "Update completed!"
else
echo "Already running the latest version, no restart needed."
fi
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
# Load environment variables from .env file
if [ -f .env ]; then
set -a
source .env
set +a
else
echo "Error: .env file not found!" >&2
exit 1
fi
SERVICE_NAME="seaweedfs-s3"
IMAGE_NAME="chrislusf/seaweedfs:latest"
YAML_DIRECTORY_NAME="${COMPOSE_DIRECTORY:-$(pwd)}"
# Get current image ID (short format)
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
# Pull the latest image
echo "Pulling latest image..."
docker pull "${IMAGE_NAME}"
# Get new image ID (short format)
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
echo "Current image: $CURRENT_IMAGE"
echo "Latest image: $NEW_IMAGE"
# Compare and restart only if different
if [ -z "$CURRENT_IMAGE" ]; then
echo "Container not found, starting it up..."
cd "${YAML_DIRECTORY_NAME}"
docker compose up -d "${SERVICE_NAME}"
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
echo "New image detected, restarting container..."
cd "${YAML_DIRECTORY_NAME}"
docker compose down "${SERVICE_NAME}"
docker compose up -d "${SERVICE_NAME}"
echo "Update completed!"
else
echo "Already running the latest version, no restart needed."
fi
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
# Load environment variables from .env file
if [ -f .env ]; then
set -a
source .env
set +a
else
echo "Error: .env file not found!" >&2
exit 1
fi
SERVICE_NAME="seaweedfs-volume"
IMAGE_NAME="chrislusf/seaweedfs:latest"
YAML_DIRECTORY_NAME="${COMPOSE_DIRECTORY:-$(pwd)}"
# Get current image ID (short format)
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
# Pull the latest image
echo "Pulling latest image..."
docker pull "${IMAGE_NAME}"
# Get new image ID (short format)
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
echo "Current image: $CURRENT_IMAGE"
echo "Latest image: $NEW_IMAGE"
# Compare and restart only if different
if [ -z "$CURRENT_IMAGE" ]; then
echo "Container not found, starting it up..."
cd "${YAML_DIRECTORY_NAME}"
docker compose up -d "${SERVICE_NAME}"
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
echo "New image detected, restarting container..."
cd "${YAML_DIRECTORY_NAME}"
docker compose down "${SERVICE_NAME}"
docker compose up -d "${SERVICE_NAME}"
echo "Update completed!"
else
echo "Already running the latest version, no restart needed."
fi
+32
View File
@@ -0,0 +1,32 @@
services:
seaweedfs-master:
extends:
file: ./seaweedfs-master/compose.yaml
service: seaweedfs-master
seaweedfs-volume:
extends:
file: ./seaweedfs-volume/compose.yaml
service: seaweedfs-volume
depends_on:
seaweedfs-master:
condition: service_healthy
seaweedfs-filer:
extends:
file: ./seaweedfs-filer/compose.yaml
service: seaweedfs-filer
depends_on:
seaweedfs-master:
condition: service_healthy
seaweedfs-volume:
condition: service_healthy
seaweedfs-s3:
extends:
file: ./seaweedfs-s3/compose.yaml
service: seaweedfs-s3
depends_on:
seaweedfs-filer:
condition: service_healthy
+2
View File
@@ -0,0 +1,2 @@
docker compose down
@@ -0,0 +1,2 @@
docker compose down seaweedfs-filer
@@ -0,0 +1,2 @@
docker compose down seaweedfs-master
@@ -0,0 +1,2 @@
docker compose down seaweedfs-s3
@@ -0,0 +1,2 @@
docker compose down seaweedfs-volume
+7
View File
@@ -0,0 +1,7 @@
# You need a .env based on this env.sample
#S3 Buckets
# openssl rand -base64 30 | tr -dc 'a-zA-Z0-9' | head -c 20; echo
AWS_ACCESS_KEY_ID=[MISSING]
# openssl rand -base64 60 | tr -dc 'a-zA-Z0-9' | head -c 40; echo
AWS_SECRET_ACCESS_KEY=[MISSING]
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f seaweedfs-filer
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f seaweedfs-master
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f seaweedfs-s3
+2
View File
@@ -0,0 +1,2 @@
docker compose logs -f seaweedfs-volume
@@ -0,0 +1 @@
data
@@ -0,0 +1,24 @@
services:
seaweedfs-filer:
image: chrislusf/seaweedfs:latest
container_name: seaweedfs-filer
hostname: seaweedfs-filer
ports:
- "8888:8888" # filer HTTP
- "18888:18888" # filer gRPC
command: 'filer -master="master:9333" -ip=filer -ip.bind=0.0.0.0'
volumes:
- ./data:/data
networks:
default:
aliases:
- filer
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8888/"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
@@ -0,0 +1 @@
data
@@ -0,0 +1,26 @@
services:
seaweedfs-master:
image: chrislusf/seaweedfs:latest
container_name: seaweedfs-master
hostname: seaweedfs-master
ports:
- "9333:9333" # master HTTP
- "19333:19333" # master gRPC
command: "master -ip=master -ip.bind=0.0.0.0 -metricsPort=9327"
volumes:
- ./data:/data
networks:
default:
# classic seaweedfs names (master:9333 etc.) must resolve on the shared
# network while the container keeps its seaweedfs-* name.
aliases:
- master
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9333/cluster/healthz"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
@@ -0,0 +1,26 @@
services:
seaweedfs-s3:
image: chrislusf/seaweedfs:latest
container_name: seaweedfs-s3
hostname: seaweedfs-s3
ports:
- "8333:8333" # S3 API
command: 's3 -filer="filer:8888" -ip.bind=0.0.0.0'
environment:
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
networks:
default:
aliases:
- s3
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -S -o- http://localhost:8333/ 2>&1 | grep -q 'HTTP/'"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
@@ -0,0 +1 @@
data
@@ -0,0 +1,24 @@
services:
seaweedfs-volume:
image: chrislusf/seaweedfs:latest
container_name: seaweedfs-volume
# no host port mapping: volume is only reached inside the network as
# volume:8080 (host 8080 is taken by another service) — gRPC stays mapped.
ports:
- "18080:18080" # volume gRPC
command: 'volume -mserver="master:9333" -ip=volume -ip.bind=0.0.0.0 -port=8080 -dir=/data -max=0'
volumes:
- ./data:/data
networks:
default:
aliases:
- volume
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/status"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
+2
View File
@@ -0,0 +1,2 @@
docker compose up -d
@@ -0,0 +1,2 @@
docker compose up -d seaweedfs-filer
@@ -0,0 +1,2 @@
docker compose up -d seaweedfs-master
@@ -0,0 +1,2 @@
docker compose up -d seaweedfs-s3
@@ -0,0 +1,2 @@
docker compose up -d seaweedfs-volume