Compare commits

..
Author SHA1 Message Date
Baiqiao Zhang f1b9809f35 Fix: set analyticsId when registering via the Sign Up page (#256)
registerNewUser() requires analyticsId (UserCreator throws
'bug: attributes.analyticsId is missing'), so every sign-up through the
registration-page module failed with a 500. Same fix as #208 for admin-tools.

Fixes #255
2026-10-07 14:45:40 +02:00
132 changed files with 85 additions and 11632 deletions
-3
View File
@@ -8,6 +8,3 @@ server-ce/test
services/web/scripts/plan-prices
services/web/scripts/translations
services/web/scripts/ukamf
# local LanguageTool ngrams data (deploy-only, not part of the image)
tools/languagetools_server_compose
+1 -1
View File
@@ -24,7 +24,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
&& apt-get install -y \
unattended-upgrades \
build-essential wget net-tools unzip time poppler-utils optipng strace nginx git python3 python-is-python3 zlib1g-dev libpcre3-dev gettext-base libwww-perl ca-certificates curl gnupg \
qpdf imagemagick ghostscript \
qpdf \
# upgrade base-image, batch all the upgrades together, rather than installing them on-by-one (which is slow!)
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
# install Node.js https://github.com/nodesource/distributions#nodejs
-7
View File
@@ -1,7 +0,0 @@
FROM pandoc/core:3.11-alpine
ENTRYPOINT []
RUN apk add --no-cache zip
-24
View File
@@ -1,24 +0,0 @@
# replacement for quay.io/sharelatex/pdftocairo:24.02 (clsitex PDF→JPEG)
#
# ALIASES: pdftocairo-ol:25.12.0
#
# alpine:3.24 with poppler (25.12.0, >= 24.02 required by CLSI).
#
# Why: CLSI convertPDFToJPEG runs `pdftocairo -jpeg ...` against this image
# for preview (794px, q90) and thumbnail (190px, q50) JPEGs of compiled PDFs.
#
# The SaaS image is Ubuntu-based; alpine 3.24's poppler 25.12.0 covers exactly
# the flags CLSI uses (-jpeg -jpegopt -singlefile -scale-to-x -scale-to-y), and
# the image is tiny.
#
# images/pandoc-amd64/Dockerfile is the sibling (pandoc/core + zip). Same pattern:
# ENTRYPOINT [] because CLSI docker-runs with an explicit command (no
# --entrypoint override).
FROM alpine:3.24
ENTRYPOINT []
RUN apk update \
&& apk add --no-cache poppler-utils \
&& rm -rf /var/cache/apk/*
-51
View File
@@ -1,51 +0,0 @@
# replacement for quay.io/sharelatex/png2pdf:2026-06-24 (clsitex PNG→PDF)
#
# Engine: img2pdf (josch) — lossless. For PNG input the IDAT streams are
# embedded verbatim into the PDF (no re-encode); the alpha channel becomes
# a PDF soft mask (/SMask), also lossless. That is the mechanism of the
# whole feature: pdfTeX copies PDF image streams cheaply, while PNGs with
# alpha/gamma/palette/interlace are re-decoded on every compile, which is
# what made them "slow" and what fills CLSI's slow-PNG list.
#
# The SaaS tool is proprietary. The Go port (go/services/clsitex/png2pdf)
# pins the exact container contract this image fulfils:
# ENTRYPOINT + ["--in-place", "--", "<relpath>", ...] CWD=/compile
# stdout: one "Converted <f> to PDF" line per file (counted by Go)
# exit non-zero on first failure (CLSI falls back to the original PNGs)
#
# Why git@commit instead of `pip install img2pdf==0.6.3`:
# 0.6.3 (2025-11-06) is the last PyPI release, and it PRE-DATES the
# parse_png quadratic-rewrite fix (2025-11-16, "my 311MB ARCHD at 600dpi
# with 17638 IDAT records went from many minutes to 320ms"). The slow-PNG
# list is populated by BIG PNGs (min 1MB, no upper bound), so we pin main
# at this commit: it is still reproducible (exact hash) and carries the
# fix plus the JPEG2000/ICC and pikepdf-writer-close follow-ups.
#
# pikepdf + pillow have musllinux cp314 wheels for this python version, so
# no compiler is needed; git is needed only for the pinned checkout.
FROM python:3.14-alpine
ARG IMG2PDF_REF=0fafa2c312be50c68a07e2331a89bf4adbd274ed
RUN apk update \
&& apk add --no-cache git \
&& pip install --no-cache-dir --disable-pip-version-check \
"git+https://gitlab.mister-muffin.de/josch/img2pdf.git@${IMG2PDF_REF}#egg=img2pdf" \
pikepdf==10.15.0 \
pillow==12.3.0 \
packaging \
&& rm -rf /root/.cache/pip /usr/src/python/lib*
# shim: arg parsing, leading-dash path handling (paths derive from URL
# paths and start with '-'), temp-file + atomic replace (input must survive
# a failed conversion — CLSI renames the untouched original as fallback),
# and the "Converted <f> to PDF" stdout line Go counts.
COPY server-ce/png2pdf/png2pdf.sh /usr/local/bin/png2pdf
RUN chmod 0755 /usr/local/bin/png2pdf
# The Go runner passes the arg list ["--in-place", "--", ...] as the container
# command, never a binary name — see the pdftocairo image (ENTRYPOINT []) for
# the contrast: that image provides the binary NAME in its command, this one
# relies on the image's own ENTRYPOINT.
ENTRYPOINT ["/usr/local/bin/png2pdf"]
+4 -58
View File
@@ -11,33 +11,20 @@ export OVERLEAF_BASE_TAG ?= sharelatex/sharelatex-base:$(BRANCH_NAME_TAG_SAFE)-$
export OVERLEAF_BRANCH ?= sharelatex/sharelatex:$(BRANCH_NAME_TAG_SAFE)
export OVERLEAF_LATEST ?= sharelatex/sharelatex
export OVERLEAF_TAG ?= sharelatex/sharelatex:$(BRANCH_NAME_TAG_SAFE)-$(MONOREPO_REVISION)
export OVERLEAF_PANDOC_BRANCH ?= sharelatex/sharelatex-pandoc:$(BRANCH_NAME_TAG_SAFE)
export OVERLEAF_PANDOC_LATEST ?= sharelatex/sharelatex-pandoc
export OVERLEAF_PANDOC_TAG ?= sharelatex/sharelatex-pandoc:$(BRANCH_NAME_TAG_SAFE)-$(MONOREPO_REVISION)
export OVERLEAF_PDFTOCAIRO ?= sharelatex/pdftocairo:24.02
export OVERLEAF_PNG2PDF ?= sharelatex/png2pdf:2026-06-24
all: build-base build-community build-pandoc build-pdftocairo build-png2pdf
all: build-base build-community
clean:
-docker rmi --force $(OVERLEAF_BASE_TAG) $(OVERLEAF_TAG) $(OVERLEAF_PANDOC_TAG) $(OVERLEAF_PDFTOCAIRO) $(OVERLEAF_PNG2PDF)
-docker rmi --force $(OVERLEAF_BASE_TAG) $(OVERLEAF_TAG)
refresh-cache: refresh-cache-branch
refresh-cache-branch:
docker inspect $(OVERLEAF_BASE_BRANCH) > /dev/null && docker pull $(OVERLEAF_BASE_BRANCH) || true
docker inspect $(OVERLEAF_BRANCH) > /dev/null && docker pull $(OVERLEAF_BRANCH) || true
docker inspect $(OVERLEAF_PANDOC_BRANCH) > /dev/null && docker pull $(OVERLEAF_PANDOC_BRANCH) || true
docker inspect $(OVERLEAF_PDFTOCAIRO) > /dev/null && docker pull $(OVERLEAF_PDFTOCAIRO) || true
docker inspect $(OVERLEAF_PNG2PDF) > /dev/null && docker pull $(OVERLEAF_PNG2PDF) || true
refresh-cache: refresh-cache-latest
refresh-cache-latest:
docker inspect $(OVERLEAF_BASE_LATEST) > /dev/null && docker pull $(OVERLEAF_BASE_LATEST) || true
docker inspect $(OVERLEAF_LATEST) > /dev/null && docker pull $(OVERLEAF_LATEST) || true
docker inspect $(OVERLEAF_PANDOC_LATEST) > /dev/null && docker pull $(OVERLEAF_PANDOC_LATEST) || true
docker inspect $(OVERLEAF_PDFTOCAIRO) > /dev/null && docker pull $(OVERLEAF_PDFTOCAIRO) || true
docker inspect $(OVERLEAF_PNG2PDF) > /dev/null && docker pull $(OVERLEAF_PNG2PDF) || true
build-base:
cp .dockerignore $(MONOREPO_ROOT)
@@ -52,6 +39,7 @@ build-base:
--tag $(OVERLEAF_BASE_BRANCH) \
$(MONOREPO_ROOT)
build-community:
cp .dockerignore $(MONOREPO_ROOT)
docker build \
@@ -66,53 +54,11 @@ build-community:
--tag $(OVERLEAF_BRANCH) \
$(MONOREPO_ROOT)
build-pandoc:
cp .dockerignore $(MONOREPO_ROOT)
docker build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--progress=plain \
--build-arg OVERLEAF_BASE_TAG \
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
--cache-from $(OVERLEAF_PANDOC_LATEST) \
--cache-from $(OVERLEAF_PANDOC_BRANCH) \
--file Dockerfile-pandoc \
--tag $(OVERLEAF_PANDOC_TAG) \
--tag $(OVERLEAF_PANDOC_BRANCH) \
$(MONOREPO_ROOT)
build-pdftocairo:
cp .dockerignore $(MONOREPO_ROOT)
docker build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--progress=plain \
--build-arg OVERLEAF_BASE_TAG \
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
--cache-from $(OVERLEAF_PDFTOCAIRO) \
--file Dockerfile-pdftocairo \
--tag $(OVERLEAF_PDFTOCAIRO) \
$(MONOREPO_ROOT)
build-png2pdf:
cp .dockerignore $(MONOREPO_ROOT)
docker build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--progress=plain \
--build-arg OVERLEAF_BASE_TAG \
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
--cache-from $(OVERLEAF_PNG2PDF) \
--file Dockerfile-png2pdf \
--tag $(OVERLEAF_PNG2PDF) \
$(MONOREPO_ROOT)
push:
docker push $(OVERLEAF_BASE_TAG)
docker push $(OVERLEAF_BASE_BRANCH)
docker push $(OVERLEAF_TAG)
docker push $(OVERLEAF_BRANCH)
docker push $(OVERLEAF_PANDOC_TAG)
docker push $(OVERLEAF_PANDOC_BRANCH)
docker push $(OVERLEAF_PDFTOCAIRO)
docker push $(OVERLEAF_PNG2PDF)
SHELLCHECK_OPTS = \
--shell=bash \
@@ -134,5 +80,5 @@ shellcheck_fix:
done
.PHONY: all \
build-base build-community build-pandoc build-pdftocairo build-png2pdf\
build-base build-community \
shellcheck shellcheck_fix
+1 -19
View File
@@ -21,14 +21,7 @@ server {
proxy_send_timeout 10m;
}
# 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)?(/|$)" {
location ^~ /git/ {
resolver_timeout 2s;
resolver 127.0.0.11 valid=10s;
set $git_upstream http://${GIT_BRIDGE_HOST}:${GIT_BRIDGE_PORT};
@@ -70,17 +63,6 @@ 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
-83
View File
@@ -1,83 +0,0 @@
#!/bin/sh
# CLSI "png2pdf" container contract shim (engine: img2pdf 0.6.3, josch).
# Replaces the proprietary quay.io/sharelatex/png2pdf:2026-06-24.
#
# Invoked by the CLSI docker runner (go/services/clsitex/dockerrunner.go, go/services/clsitex/png2pdf/png2pdf.go):
# args: ["--in-place", "--", "<relpath1>", ...] CWD=/compile
# the docker runner does NOT supply a binary name in the command, so the
# image ENTRYPOINT is this script.
#
# Paths: each arg is a conversion path CLSI downloaded, RELATIVE to the
# mounted cache dir (CWD /compile). Real CLSI derives it from the file
# URL path (u.Path: '/'->'-') + mtime + ".opt<10-hex cacheKey>",
# e.g. ".-uploads-filestore-a1b2c3d4-0-1-1756600000000.opta1b2c3d4a1bc"
# (leading dash, no directory separator).
#
# "IN-PLACE" means "rewrite each input PATH as a PDF". CLSI then renames
# each path to its own ".opt" cache entry and copies it into the compile
# dir (urlcache.CommitConversion) — PER FILE and UNCONDITIONALLY (sync.go:
# a conversion error only logs a warning). So each converted file lands
# in the cache even in a partially-failed run; an unconverted file is
# committed as the original-PNG fallback.
#
# Contract (go/services/clsitex/png2pdf/png2pdf.go):
# - stdout: one "Converted <f> to PDF" per converted file (Go counts
# lines matching ^Converted .+ to PDF$ -> stats.png2pdf).
# - exit non-zero if anything fails: CLSI logs a warning and commits
# each file's current on-disk state (converted or original).
#
# Why img2pdf: LOSSLESS. PNG IDAT is embedded verbatim into the PDF (no
# re-encode); the alpha channel becomes a PDF /SMask, also lossless.
# That is the mechanism of the feature: pdfTeX copies PDF image streams
# cheaply, whereas PNGs with alpha/gamma/palette/interlace are re-decoded
# from the PNG on every compile — what made them "slow". Without
# --imgsize the image keeps its pHYs DPI, falling back to 96dpi (the same
# default pdfLaTeX itself uses).
#
# Failure handling: convert to a temp + atomic replace, and CONTINUE with
# the remaining files after a failure (their valid conversions are
# committed by CLSI even though the run is reported failed). A failed
# file stays exactly the original PNG CLSI downloaded.
set -u
# argv is exactly [--in-place]... ["--"] <files...>. The loop terminates:
# every iteration either shifts, breaks, or exits.
while [ "$#" -gt 0 ]; do
case "$1" in
--) shift ; break ;;
--in-place) shift ;;
*) echo "png2pdf: unexpected argument: $1" >&2 ; exit 2 ;;
esac
done
if [ "$#" -lt 1 ]; then
echo "png2pdf: no input files (usage: --in-place -- <png>...)" >&2
exit 2
fi
rc=0
for file in "$@"; do
# Resolve against CWD so a leading '-' (or '.') never lands in an argv
# slot of img2pdf/mv, where it would be parsed as an option.
case "$file" in
/ | /*) abs="$file" ;;
*) abs="$(pwd)/$file" ;;
esac
tmp="${abs}.tmp"
if ! img2pdf --output "$tmp" "$abs"; then
echo "png2pdf: conversion failed for $file" >&2
rm -f -- "$tmp"
rc=1
continue
fi
if ! mv -- "$tmp" "$abs"; then
echo "png2pdf: failed to replace $file" >&2
rm -f -- "$tmp"
rc=1
continue
fi
echo "Converted $file to PDF"
done
exit "$rc"
-13
View File
@@ -1,18 +1,5 @@
#!/bin/bash
# Grant the service user access to the Docker socket group, same as the CLSI
# run script. Needed for CONVERTER=pdftocairo-sandbox (filestore launches the
# sibling conversion container through /var/run/docker.sock). Idempotent: a
# fresh container may have no such group yet, and CLSI's run script does the
# same thing, so just append to the membership if it exists.
if [ -e '/var/run/docker.sock' ]; then
DOCKER_GROUP=$(stat -c '%g' /var/run/docker.sock)
if ! getent group dockeronhost >/dev/null; then
groupadd --non-unique --gid "${DOCKER_GROUP}" dockeronhost
fi
usermod -aG dockeronhost www-data
fi
source /etc/overleaf/env.sh
export LISTEN_ADDRESS=127.0.0.1
+25 -41
View File
@@ -48,20 +48,7 @@ const DockerRunner = {
image = `${Settings.texliveImageNameOveride}/${img}`
}
if (
Settings.path.sandboxedCompilesHostDirCache &&
directory.startsWith(Settings.path.clsiCacheDir + Path.sep)
) {
// In: directory = '<clsiCacheDir>/projectId'
// sandboxedCompilesHostDirCache = '/host/cache'
// Out: directory = '/host/cache/projectId'
// (png2pdf & friends operate on CLSI's per-project cache dir, which
// is not under the compiles dir)
directory = Path.join(
Settings.path.sandboxedCompilesHostDirCache,
Path.basename(directory)
)
} else if (compileGroup === 'synctex-output') {
if (compileGroup === 'synctex-output') {
// In: directory = '/overleaf/services/clsi/output/projectId-userId/generated-files/buildId'
// directory.split('/').slice(-3) === 'projectId-userId/generated-files/buildId'
// sandboxedCompilesHostDirOutput = '/host/output'
@@ -106,35 +93,32 @@ const DockerRunner = {
// logOptions = _.clone(options)
// logOptions?.HostConfig?.SecurityOpt = "secomp used, removed in logging"
logger.debug({ projectId }, 'running docker container')
// Bounded retry: a permanent container create/start failure (e.g.
// "unable to find user" on a FROM-scratch image) must not busy-loop
// forever — surface the error to the caller after a couple of attempts.
let retriesLeft = 2
const runAttempt = () => {
DockerRunner._runAndWaitForContainer(
options,
volumes,
timeout,
(error, output) => {
if (error && error.statusCode === 500 && retriesLeft > 0) {
retriesLeft -= 1
logger.debug(
{ err: error, projectId, retriesLeft },
'error running container so destroying and retrying'
DockerRunner._runAndWaitForContainer(
options,
volumes,
timeout,
(error, output) => {
if (error && error.statusCode === 500) {
logger.debug(
{ err: error, projectId },
'error running container so destroying and retrying'
)
DockerRunner.destroyContainer(name, null, true, error => {
if (error != null) {
return callback(error)
}
DockerRunner._runAndWaitForContainer(
options,
volumes,
timeout,
callback
)
DockerRunner.destroyContainer(name, null, true, err => {
if (err != null) {
return callback(err)
}
runAttempt()
})
} else {
callback(error, output)
}
})
} else {
callback(error, output)
}
)
}
runAttempt()
}
)
// pass back the container name to allow it to be killed
return name
+1 -9
View File
@@ -172,15 +172,7 @@ if ((process.env.DOCKER_RUNNER || process.env.SANDBOXED_COMPILES) === 'true') {
wordcount: { 'HostConfig.AutoRemove': true },
synctex: { 'HostConfig.AutoRemove': true },
'synctex-output': { 'HostConfig.AutoRemove': true },
// The pdftocairo image is also built FROM scratch with no passwd file,
// so the default named user (www-data) fails at container create with
// "unable to find user ... no matching entries in passwd file". Run it
// as the numeric uid/gid of this clsi process (which owns the files it
// converts), same as the png2pdf image below.
conversions: {
'HostConfig.AutoRemove': true,
User: `${process.getuid()}:${process.getgid()}`,
},
conversions: { 'HostConfig.AutoRemove': true },
// The png2pdf image is built FROM scratch and has no 'tex' user, so run
// it as the same uid/gid as this clsi process, which owns the cache files
// it converts in place.
@@ -2,7 +2,6 @@ import metrics from '@overleaf/metrics'
import Settings from '@overleaf/settings'
import { callbackify } from 'node:util'
import SafeExec from './SafeExec.js'
import SandboxConverterRunner from './SandboxConverterRunner.js'
import Errors from './Errors.js'
const { ConversionError } = Errors
@@ -23,9 +22,6 @@ export default {
}
async function convert(sourcePath, requestedFormat) {
if (Settings.converter === 'pdftocairo-sandbox') {
return await _convertInSandbox(sourcePath, requestedFormat, 1500)
}
if (Settings.converter === 'pdftocairo') {
const width = 1500
return await _convert(sourcePath, requestedFormat, [
@@ -53,9 +49,6 @@ async function convert(sourcePath, requestedFormat) {
}
async function thumbnail(sourcePath) {
if (Settings.converter === 'pdftocairo-sandbox') {
return await _convertInSandbox(sourcePath, 'png', 700)
}
if (Settings.converter === 'pdftocairo') {
const width = 700
return await _convert(sourcePath, 'png', [
@@ -88,9 +81,6 @@ async function thumbnail(sourcePath) {
async function preview(sourcePath) {
const width = 1000
if (Settings.converter === 'pdftocairo-sandbox') {
return await _convertInSandbox(sourcePath, 'png', width)
}
if (Settings.converter === 'pdftocairo') {
return await _convert(sourcePath, 'png', [
'pdftocairo',
@@ -119,50 +109,6 @@ async function preview(sourcePath) {
}
}
/**
* Run the pdftocairo conversion inside a sandboxed sibling container (no
* network, all caps dropped, memory cap — see SandboxConverterRunner.js).
*
* Keeps the same contract as the local path: the result is written to
* `${sourcePath}.${requestedFormat}`.
*
* @param {string} sourcePath
* @param {string} requestedFormat 'png' or 'jpg'
* @param {number} width output width in px
* @return {Promise<{outputHostPath: string}>}
*/
async function _convertInSandbox(sourcePath, requestedFormat, width) {
if (!APPROVED_FORMATS.includes(requestedFormat)) {
throw new ConversionError('invalid format requested', { format: requestedFormat })
}
const ext = requestedFormat
const workDir = '/work'
// poppler appends the device extension to the output argument, so the
// argument must end WITHOUT an extension; the result lands at
// /work/input.pdf.<png|jpg> next to the input.
const device = requestedFormat === 'png' ? '-png' : '-jpeg'
const result = await SandboxConverterRunner.runConversion({
sourcePath,
containerArgs: [
'pdftocairo',
device,
'-singlefile',
'-scale-to-x',
width.toString(),
'-scale-to-y',
'-1', // maintain aspect ratio
`${workDir}/input.pdf`,
`${workDir}/input.pdf`,
],
workDir,
outputBase: 'input.pdf',
outputExt: ext,
destPath: `${sourcePath}.${requestedFormat}`,
})
// Same contract as the local converters: return the destination path string.
return result.outputHostPath
}
async function _convert(sourcePath, requestedFormat, command) {
if (!APPROVED_FORMATS.includes(requestedFormat)) {
throw new ConversionError('invalid format requested', {
@@ -1,342 +0,0 @@
// SandboxConverterRunner.js
//
// Runs a PDF→image conversion (pdftocairo) in a throwaway sibling container,
// the same isolation profile CLSI's DockerRunner gives its conversion
// containers (see services/clsi/app/js/DockerRunner.mjs and the `conversions`
// / `png2pdf` compile groups in services/clsi/config/settings.defaults.cjs):
//
// - no network access (HostConfig.NetworkMode: 'none')
// - all capabilities dropped (HostConfig.CapDrop: ['ALL'])
// - no new privileges (SecurityOpt: 'no-new-privileges')
// - hard memory cap
// - numeric uid:gid of the overleaf service user (the scratch-based
// converter images have no passwd entries, so named users fail)
// - container is always removed afterwards
//
// Implemented as a minimal Docker Engine API client over the unix socket so
// that no new npm dependency is required (this service is installed with
// Yarn PnP; cross-service packages such as dockerode are not resolvable
// here).
//
// IMPORTANT (bind mounts): the daemon runs on the Docker host, so the
// bind source MUST be a daemon-host-visible path (in this deployment:
// under the overleaf `./data` volume), never a container-internal path
// such as /tmp. The caller passes a `hostDir` that satisfies this.
import http from 'node:http'
import Path from 'node:path'
import Fs from 'node:fs'
import crypto from 'node:crypto'
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import Errors from './Errors.js'
const { ConversionError } = Errors
const API_VERSION = 'v1.43' // conservative; the engine API is backwards compatible
/**
* One request against the Docker Engine API on a unix socket.
*
* @param {string} socketPath
* @param {string} method
* @param {string} apiPath e.g. `/v1.43/containers/create?name=xyz`
* @param {object} [opts]
* @param {object|string} [opts.body] JSON body
* @param {number} [opts.timeoutMs] hard deadline for the whole request
* @param {Buffer|null} [opts.expectRaw] pass a truthy value to keep the body
* as a Buffer (used for the log stream)
* @return {Promise<{status: number, headers: object, body: any, raw: Buffer}>}
*/
function request(socketPath, method, apiPath, { body, timeoutMs = 60000, raw = false } = {}) {
return new Promise((resolve, reject) => {
const data = body === undefined || body === null ? null : JSON.stringify(body)
let settled = false
const req = http.request(
{
socketPath,
method,
path: apiPath,
headers: data
? {
'content-type': 'application/json',
'content-length': Buffer.byteLength(data),
}
: {},
},
(res) => {
const chunks = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
if (settled) return
settled = true
clearTimeout(timer)
const buf = Buffer.concat(chunks)
if (raw) return resolve({ status: res.statusCode, headers: res.headers, body: buf, raw: buf })
let parsed = buf
if (buf.length && (buf[0] === 0x7b || buf[0] === 0x5b)) {
try {
parsed = JSON.parse(buf.toString('utf8'))
} catch (e) {
// non-JSON body (e.g. plain text) — keep buffer
parsed = buf
}
}
resolve({ status: res.statusCode, headers: res.headers, body: parsed, raw: buf })
})
}
)
const timer = setTimeout(() => {
if (settled) return
settled = true
req.destroy()
reject(new Error(`docker api ${method} ${apiPath} timed out after ${timeoutMs}ms`))
}, timeoutMs)
timer.unref?.()
req.on('error', (err) => {
if (settled) return
settled = true
clearTimeout(timer)
reject(err)
})
if (data) req.write(data)
req.end()
})
}
/** Decode the framed (TTY=false) docker log stream. */
function decodeLogStream(buf) {
if (!buf || !buf.length) return ''
// 8-byte header per frame: [stream(1)] [size_be32(4)] [reserved(3)] + payload
// (older daemons use a 4-byte header; try the 8-byte layout first)
let out = ''
let i = 0
while (i + 8 <= buf.length) {
const size = buf.readUInt32BE(i + 4)
if (i + 8 + size > buf.length) break
if (buf[i] === 1 || buf[i] === 2) out += buf.subarray(i + 8, i + 8 + size).toString('utf8')
i += 8 + size
}
return out || buf.toString('utf8')
}
/**
* Best-effort sweep of leftover `filestore-convert-*` containers from crashes
* or service restarts mid-run (a container that was created but never started
* or removed otherwise would leak forever).
*/
async function _sweepLeftovers(socketPath) {
let res
try {
res = await request(
socketPath,
'GET',
`/${API_VERSION}/containers/json?all=1&limit=200&filters=%7B%22name%22%3A%5B%22filestore-convert-%22%5D%7D`,
{ timeoutMs: 10000 }
)
} catch (err) {
return // not fatal
}
if (res.status !== 200 || !Array.isArray(res.body)) return
for (const c of res.body) {
if (typeof c.Names?.[0] === 'string' && c.Names[0].startsWith('/filestore-convert-')) {
const name = c.Names[0].slice(1)
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
await request(socketPath, 'DELETE', `/${API_VERSION}/containers/${name}?force=1`, { timeoutMs: 10000 })
logger.warn({ name }, 'sandbox conversion: removed leftover container')
break
} catch (err) {
if (attempt === 0) continue
logger.warn({ err, name }, 'sandbox conversion: failed to remove leftover container')
}
}
}
}
}
/**
* Run a conversion in a sandboxed container.
*
* Layout contract (mirrors CLSI's conversion runner):
* host: <hostDir>/<runId>/input.pdf <- the source file (copied in)
* host: <hostDir>/<runId>/<outputBase>.<ext> <- produced by the container
*
* @param {object} opts
* @param {string} opts.sourcePath absolute path to the source PDF (must be
* inside a daemon-host-visible directory — the bind source is derived from it)
* @param {string} opts.containerArgs full argv inside the container, e.g.
* ['pdftocairo','-png','-singlefile','-scale-to-x','700','-scale-to-y','-1',
* '/work/input.pdf','/work/output']
* @param {string} opts.workDir basename mapping used in `containerArgs`; the
* source file is placed at <workDir>/input.pdf inside the container
* @param {string} opts.outputBase container-side output basename (without ext)
* @param {string} opts.outputExt 'png' or 'jpg'
* @param {string} opts.destPath absolute host path where the result must end up
* @return {Promise<{container: string, exitCode: number, outputHostPath: string, durationMs: number}>}
*/
export async function runConversion(opts) {
const {
sourcePath,
containerArgs,
workDir = '/work',
outputBase = 'output',
outputExt = 'png',
destPath,
} = opts
const sandbox = Settings.sandboxConversion || {}
const image = sandbox.image || 'sharelatex/pdftocairo:24.02'
const socketPath = sandbox.dockerSocketPath || '/var/run/docker.sock'
const hostDir = sandbox.hostDir // daemon-host side of the scratch dir
const containerDir = sandbox.containerDir // overleaf-container side
const memoryBytes = sandbox.memoryBytes || 512 * 1024 * 1024
const timeoutMs = sandbox.timeoutMs || 40000
if (!hostDir || !containerDir) {
throw new ConversionError(
'sandbox conversion is not configured: set CONVERSION_SANDBOX_HOST_DIR (Docker-daemon side) and CONVERSION_SANDBOX_DIR (overleaf-container side) to the two views of the same directory'
)
}
const runId = crypto.randomBytes(8).toString('hex')
const runName = `filestore-convert-${runId}`
const runDir = Path.join(containerDir, runId) // path used by THIS process (container side)
const hostRunDir = Path.join(hostDir, runId) // same dir from the daemon side (bind source)
const inputHostPath = Path.join(runDir, 'input.pdf')
const outputHostPath = Path.join(runDir, `${outputBase}.${outputExt}`)
const t0 = Date.now()
let containerCreated = false
try {
// Guard against leftover containers from an interrupted previous run.
await _sweepLeftovers(socketPath)
Fs.mkdirSync(runDir, { mode: 0o770 })
Fs.copyFileSync(sourcePath, inputHostPath)
// Container-absolute path of the source file, for argv remapping sanity
const sourceInContainer = Path.join(workDir, 'input.pdf')
if (!containerArgs.some((a) => a === sourceInContainer)) {
throw new ConversionError(
`sandbox argv does not reference ${sourceInContainer} — refusing to run`
)
}
// Create (the converter images are scratch-based: no ENTRYPOINT, so a bare
// Cmd list is what CLSI runs successfully too).
const createBody = {
Image: image,
Cmd: containerArgs,
WorkingDir: workDir,
// scratch-based images have no passwd entries — run as the numeric
// uid:gid of this service (it owns the files on both sides of the bind).
User: `${process.getuid()}:${process.getgid()}`,
HostConfig: {
Binds: [`${hostRunDir}:${workDir}:rw`],
CapDrop: ['ALL'],
SecurityOpt: ['no-new-privileges'],
NetworkMode: 'none',
Memory: memoryBytes,
MemorySwap: 0,
PidsLimit: 64,
},
}
const created = await request(socketPath, 'POST', `/${API_VERSION}/containers/create?name=${runName}`, {
body: createBody,
timeoutMs: 60000,
})
if (created.status >= 300 || !created.body?.Id) {
const msg = created.body?.message || JSON.stringify(created.body) || `status ${created.status}`
throw new ConversionError(`failed to create sandbox container: ${msg}`)
}
containerCreated = true
logger.info({ image, runName, hostDir: hostRunDir, containerDir: runDir }, 'sandbox conversion: container created')
await request(socketPath, 'POST', `/${API_VERSION}/containers/${runName}/start`, { timeoutMs: 60000 })
// Wait with a real deadline; kill on breach (the /wait call is a blocking
// HTTP call that carries no data, so it gets its own generous budget).
let exitCode = -1
let killed = false
try {
// NOTE: this daemon (Docker 29 / API ≥1.44) requires POST for /wait; GET
// returns 404. (dockerode does the same, which is how CLSI waits.)
const waited = await request(socketPath, 'POST', `/${API_VERSION}/containers/${runName}/wait`, {
timeoutMs: timeoutMs + 15000,
})
exitCode = waited.body?.StatusCode ?? -1
if (exitCode === -1 && waited.status !== 200) {
throw new Error(`unexpected /wait status ${waited.status}: ${waited.raw?.toString?.('utf8') || 'no body'}`)
}
} catch (waitErr) {
killed = true
await _killAndRemove(socketPath, runName)
throw new ConversionError(`sandbox conversion timed out (${timeoutMs}ms): ${waitErr.message}`)
}
let logs = ''
try {
const logsRes = await request(
socketPath,
'GET',
`/${API_VERSION}/containers/${runName}/logs?stdout=1&stderr=1&stdin=0&follow=1`,
{ raw: true, timeoutMs: 10000 }
)
logs = decodeLogStream(logsRes.raw)
} catch (err) {
logger.warn({ err, runName }, 'sandbox conversion: could not read container logs')
}
if (exitCode !== 0) {
throw new ConversionError(
`sandbox conversion failed (exit ${exitCode}${killed ? ', killed' : ''})`,
{ stderr: logs.trim().slice(-4000), exitCode }
)
}
let stat
try {
stat = Fs.statSync(outputHostPath)
} catch (err) {
throw new ConversionError(`sandbox conversion produced no output file`, { stderr: logs.trim().slice(-4000) })
}
if (!stat.isFile()) {
throw new ConversionError('sandbox conversion output is not a regular file', {
stderr: logs.trim().slice(-4000),
})
}
Fs.copyFileSync(outputHostPath, destPath)
logger.info(
{ runName, sizeBytes: stat.size, durationMs: Date.now() - t0 },
'sandbox conversion completed'
)
return { container: runName, exitCode, outputHostPath: destPath, durationMs: Date.now() - t0 }
} finally {
// Best-effort cleanup: container + scratch dir (the dir holds the original
// PDF — do not leave it behind).
if (containerCreated) {
await _killAndRemove(socketPath, runName).catch((e) => {
logger.warn({ err: e, runName }, 'sandbox conversion: cleanup failed')
})
}
Fs.rmSync(runDir, { recursive: true, force: true })
}
}
async function _killAndRemove(socketPath, name) {
try {
// POST /kill (GET is not served by this daemon); 409 = not running → fine
await request(socketPath, 'POST', `/${API_VERSION}/containers/${name}/kill`, { timeoutMs: 10000 })
} catch (err) {
// not running → fine
}
try {
await request(socketPath, 'DELETE', `/${API_VERSION}/containers/${name}?force=1`, { timeoutMs: 10000 })
} catch (err) {
// already gone → fine
}
}
export default { runConversion }
@@ -99,29 +99,8 @@ const settings = {
enableConversions: process.env.ENABLE_CONVERSIONS === 'true',
// PDF→image conversion backend (template gallery previews/thumbnails):
// 'imagemagick' – local, via ImageMagick `convert` (default; binary required)
// 'pdftocairo' – local, via the `pdftocairo` binary (poppler)
// 'pdftocairo-sandbox' – runs `pdftocairo` in a throwaway sibling container:
// no network, CapDrop ALL, no-new-privileges, memory
// cap (see app/js/SandboxConverterRunner.js).
// requires a Docker socket + a daemon-host-visible
// scratch dir (CONVERSION_SANDBOX_HOST_DIR).
converter: process.env.CONVERTER || 'imagemagick',
sandboxConversion: {
image: process.env.CONVERSION_SANDBOX_IMAGE || 'sharelatex/pdftocairo:24.02',
dockerSocketPath: process.env.CONVERSION_DOCKER_SOCKET || '/var/run/docker.sock',
// The scratch dir has two views of the same path (the overleaf container
// mounts ./data): `containerDir` for this process, `hostDir` for the Docker
// daemon's bind mount. Both must be set when sandbox conversion is used.
hostDir: process.env.CONVERSION_SANDBOX_HOST_DIR || null,
containerDir: process.env.CONVERSION_SANDBOX_DIR || null,
memoryBytes:
parseInt(process.env.CONVERSION_SANDBOX_MEMORY_BYTES, 10) || 512 * 1024 * 1024,
timeoutMs: parseInt(process.env.CONVERSION_SANDBOX_TIMEOUT_MS, 10) || 40000,
},
gracefulShutdownDelayInMs:
parseInt(process.env.GRACEFUL_SHUTDOWN_DELAY_SECONDS ?? '30', 10) * 1000,
}
-54
View File
@@ -1,54 +0,0 @@
# Build the a8m/envsubst binary, as it supports default values,
# which the gnu envsubst (from gettext-base) does not.
FROM golang:1.25.5-alpine AS envsubst_builder
WORKDIR /build
RUN go install github.com/a8m/envsubst/cmd/envsubst@latest
FROM maven:3-amazoncorretto-21-debian AS base
RUN apt-get update && apt-get install -y make git sqlite3 curl \
&& rm -rf /var/lib/apt/lists
COPY --from=envsubst_builder /go/bin/envsubst /opt/envsubst
RUN chmod +x /opt/envsubst
RUN useradd --create-home node
FROM base AS builder
COPY . /app
WORKDIR /app
RUN make package \
# The name of the created jar contains the current version tag.
# Rename it to a static path that can be used for copying.
&& find /app/target \
-name 'writelatex-git-bridge*jar-with-dependencies.jar' \
-exec mv {} /git-bridge.jar \;
FROM amazoncorretto:21-alpine
RUN apk add --update --no-cache bash git sqlite procps htop net-tools jemalloc util-linux
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
RUN adduser -D node
COPY --from=builder /git-bridge.jar /
COPY --from=envsubst_builder /go/bin/envsubst /opt/envsubst
RUN chmod +x /opt/envsubst
COPY conf/envsubst_template.json envsubst_template.json
COPY start.sh start.sh
COPY server-pro-start.sh server-pro-start.sh
RUN mkdir conf
RUN chown node:node conf
USER node
CMD ["/start.sh"]
-89
View File
@@ -1,89 +0,0 @@
# git-bridge makefile
MVN_OPTS := --no-transfer-progress
MVN_TARGET := target/writelatex-git-bridge-1.0-SNAPSHOT-jar-with-dependencies.jar
export BUILD_NUMBER ?= local
export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g')
export COMMIT_SHA ?= $(shell git rev-parse HEAD)
PROJECT_NAME = git-bridge
IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER)
IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME)
IMAGE_REPO_BRANCH ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)
IMAGE_REPO_MAIN ?= $(IMAGE_REPO):main
IMAGE_REPO_FINAL ?= $(IMAGE_REPO_BRANCH)-$(BUILD_NUMBER)
runtime-conf:
/opt/envsubst < conf/envsubst_template.json > conf/runtime.json
run: $(MVN_TARGET) runtime-conf
@exec java $(GIT_BRIDGE_JVM_ARGS) -jar $(MVN_TARGET) conf/runtime.json
$(MVN_TARGET): $(shell find src -type f) pom.xml
mvn $(MVN_OPTS) package -DskipTests
build: $(MVN_TARGET)
docker_build_base:
docker build --pull \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from $(IMAGE_REPO_BRANCH) \
--cache-from $(IMAGE_REPO_MAIN) \
--tag $(IMAGE_CI) \
--target base \
.
docker_build:
docker build --pull \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from $(IMAGE_REPO_BRANCH) \
--cache-from $(IMAGE_REPO_MAIN) \
--tag $(IMAGE_REPO_BRANCH) \
--tag $(IMAGE_REPO_FINAL) \
.
format:
mvn $(MVN_OPTS) com.spotify.fmt:fmt-maven-plugin:check
docker_format:
docker run --rm -v $(PWD):$(PWD) -w $(PWD) --user node $(IMAGE_CI) make format
format_fix:
mvn $(MVN_OPTS) com.spotify.fmt:fmt-maven-plugin:format
test:
mvn $(MVN_OPTS) test
docker_test:
docker run --rm -v $(PWD):$(PWD) -w $(PWD) --user node $(IMAGE_CI) make test
clean:
mvn $(MVN_OPTS) clean
package: clean
mvn $(MVN_OPTS) package -DskipTests
refresh_cache: refresh_cache_branch
refresh_cache_branch:
docker inspect $(IMAGE_REPO_BRANCH) > /dev/null && docker pull $(IMAGE_REPO_BRANCH) || true
refresh_cache: refresh_cache_latest
refresh_cache_latest:
docker inspect $(IMAGE_REPO_MAIN) > /dev/null && docker pull $(IMAGE_REPO_MAIN) || true
push:
docker push $(IMAGE_REPO_FINAL)
push_branch:
docker push $(IMAGE_REPO_BRANCH)
clean_ci:
-docker rmi -f $(IMAGE_CI) $(IMAGE_REPO_FINAL)
-git clean -xdf .
.PHONY: run package build clean test runtime-conf
@@ -1199,8 +1199,8 @@ function _finaliseRequest(projectId, options, project, docs, files) {
timeout: options.timeout,
imageName: project.imageName,
draft: Boolean(options.draft),
// CE: premium-compile (alpha/priority) gate removed
png2pdf: Boolean(options.png2pdf),
// enable for premium compiles only
png2pdf: Boolean(options.png2pdf) && hasPremiumCompiles,
// enable for premium compiles on an image that has a checkpointing build
enableCheckpoint:
Boolean(options.checkpointing) &&
@@ -453,8 +453,7 @@ const _CompileController = {
enablePng2Pdf,
enableCheckpointCompiles,
} = await _getSplitTestOptions(req, res)
// CE: saas feature gate removed so png2pdf / compile-from-history options are sent
if (true) {
if (Features.hasFeature('saas')) {
options.compileFromClsiCache = true
options.populateClsiCache = true
options.compileFromHistory = compileFromHistory
@@ -123,12 +123,7 @@ const setDocumentSchema = z.object({
version: z.number().int(),
ranges: rangesSchemas.ranges,
lastUpdatedAt: z.coerce.number().int().positive().nullish(),
// document-updater records the acting user of each update it applies and
// flushes it back here as lastUpdatedBy (see its RedisManager's
// updateMeta.user_id). For a link-sharing visitor with no account that
// is the sentinel 'anonymous-user', which real-time sets in Router.js --
// never a user id. It is accepted here and normalised to null below.
lastUpdatedBy: zz.objectId().or(z.literal('anonymous-user')).nullish(),
lastUpdatedBy: zz.objectId().nullish(),
}),
})
@@ -137,11 +132,7 @@ async function setDocument(req, res) {
logOnly: true,
})
const { Project_id: projectId, doc_id: docId } = params
const { lines, version, ranges, lastUpdatedAt } = body
// an anonymous editor has no user id to record on the project, and
// Project.lastUpdatedBy is an ObjectId ref -- see setDocumentSchema
const lastUpdatedBy =
body.lastUpdatedBy === 'anonymous-user' ? null : body.lastUpdatedBy
const { lines, version, ranges, lastUpdatedAt, lastUpdatedBy } = body
const result = await ProjectEntityUpdateHandler.promises.updateDocLines(
projectId,
docId,
@@ -604,58 +604,6 @@ const EditorController = {
)
},
// ─────────────────────────────────────────────────────────────
// LanguageTool settings (project-wide), mirrored to all clients
// in the room (see use-project-wide-settings-socket-listener).
// ─────────────────────────────────────────────────────────────
setLanguageToolEnabled(projectId, enabled, callback) {
ProjectOptionsHandler.setLanguageToolEnabled(projectId, enabled, function (
err
) {
if (err) {
return callback(err)
}
EditorRealTimeController.emitToRoom(
projectId,
'languageToolEnabledUpdated',
enabled
)
callback()
})
},
setLanguageToolPicky(projectId, picky, callback) {
ProjectOptionsHandler.setLanguageToolPicky(projectId, picky, function (err) {
if (err) {
return callback(err)
}
EditorRealTimeController.emitToRoom(
projectId,
'languageToolPickyUpdated',
picky
)
callback()
})
},
setLanguageToolLanguage(projectId, language, callback) {
ProjectOptionsHandler.setLanguageToolLanguage(
projectId,
language,
function (err) {
if (err) {
return callback(err)
}
EditorRealTimeController.emitToRoom(
projectId,
'languageToolLanguageUpdated',
language
)
callback()
}
)
},
setPublicAccessLevel(projectId, newAccessLevel, callback) {
async.series(
[
@@ -799,13 +747,6 @@ EditorController.promises = {
setImageName: promisify(EditorController.setImageName),
setPng2pdf: promisify(EditorController.setPng2pdf),
setSpellCheckLanguage: promisify(EditorController.setSpellCheckLanguage),
setLanguageToolEnabled: promisify(
EditorController.setLanguageToolEnabled
),
setLanguageToolPicky: promisify(EditorController.setLanguageToolPicky),
setLanguageToolLanguage: promisify(
EditorController.setLanguageToolLanguage
),
setPublicAccessLevel: promisify(EditorController.setPublicAccessLevel),
setRootDoc: promisify(EditorController.setRootDoc),
setMainBibliographyDoc: promisify(EditorController.setMainBibliographyDoc),
@@ -109,9 +109,6 @@ const updateProjectSettingsSchema = z.object({
rootDocId: zz.objectId().optional(),
spellCheckLanguage: z.string().optional(),
referenceFormat: z.enum(['bibtex', 'biblatex']).optional(),
languageToolEnabled: z.boolean().optional(),
languageToolPicky: z.boolean().optional(),
languageToolLanguage: z.string().optional(),
}),
})
@@ -130,9 +127,6 @@ const updateProjectSettingsFallbackSchema = z.object({
rootDocId: zz.objectId().optional(),
spellCheckLanguage: z.string().optional(),
referenceFormat: z.enum(['bibtex', 'biblatex']).optional(),
languageToolEnabled: z.boolean().optional(),
languageToolPicky: z.boolean().optional(),
languageToolLanguage: z.string().optional(),
}),
})
@@ -234,27 +228,6 @@ const _ProjectController = {
)
}
if (body.languageToolEnabled != null) {
await EditorController.promises.setLanguageToolEnabled(
projectId,
body.languageToolEnabled
)
}
if (body.languageToolPicky != null) {
await EditorController.promises.setLanguageToolPicky(
projectId,
body.languageToolPicky
)
}
if (body.languageToolLanguage != null) {
await EditorController.promises.setLanguageToolLanguage(
projectId,
body.languageToolLanguage
)
}
if (body.rootDocId != null) {
await EditorController.promises.setRootDoc(projectId, body.rootDocId)
}
@@ -127,29 +127,6 @@ const ProjectOptionsHandler = {
const update = { referenceFormat: newReferenceFormat }
return Project.updateOne(conditions, update, {})
},
// ─────────────────────────────────────────────────────────────────
// LanguageTool settings (project-wide; shared across collaborators).
// Unset values mean the client defaults: enabled=true, picky=true,
// language='auto' — i.e. picky level is the default selection.
// ─────────────────────────────────────────────────────────────────
async setLanguageToolEnabled(projectId, enabled) {
const conditions = { _id: projectId }
const update = { languageToolEnabled: Boolean(enabled) }
return Project.updateOne(conditions, update, {})
},
async setLanguageToolPicky(projectId, picky) {
const conditions = { _id: projectId }
const update = { languageToolPicky: Boolean(picky) }
return Project.updateOne(conditions, update, {})
},
async setLanguageToolLanguage(projectId, language) {
const conditions = { _id: projectId }
const update = { languageToolLanguage: language || 'auto' }
return Project.updateOne(conditions, update, {})
},
}
export default {
@@ -167,14 +144,5 @@ export default {
ProjectOptionsHandler.setHistoryRangesSupport
),
setReferenceFormat: callbackify(ProjectOptionsHandler.setReferenceFormat),
setLanguageToolEnabled: callbackify(
ProjectOptionsHandler.setLanguageToolEnabled
),
setLanguageToolPicky: callbackify(
ProjectOptionsHandler.setLanguageToolPicky
),
setLanguageToolLanguage: callbackify(
ProjectOptionsHandler.setLanguageToolLanguage
),
promises: ProjectOptionsHandler,
}
@@ -29,7 +29,6 @@ function _canHaveNoInitiatorId(operation, info) {
if (operation === 'must-reset-password-set') return true
if (operation === 'must-reset-password-unset') return true
if (operation === 'account-suspension' && info.script) return true
if (operation === 'account-suspension-lifted' && info.script) return true
if (operation === 'release-managed-user' && info.script) return true
}
@@ -306,7 +306,6 @@ const UserPagesController = {
title: Settings.nav?.login_support_title || 'login',
login_support_title: Settings.nav?.login_support_title,
login_support_text: Settings.nav?.login_support_text,
local_login_mask_enable: Settings.enableLocalLoginMask,
metadata,
})
},
@@ -633,23 +633,6 @@ async function suspendUser(userId, auditLog = {}) {
)
}
async function unsuspendUser(userId, auditLog = {}) {
const res = await updateUser(
{ _id: userId, suspended: true },
{ $set: { suspended: false } }
)
if (res.matchedCount !== 1) {
throw new Errors.NotFoundError('user id not found or not suspended')
}
await UserAuditLogHandler.promises.addEntry(
userId,
'account-suspension-lifted',
auditLog.initiatorId,
auditLog.ip,
auditLog.info || {}
)
}
function _securityAlertPrimaryEmailChangedExtraRecipients(
emailsData,
oldEmail,
@@ -706,7 +689,6 @@ export default {
migrateDefaultEmailAddress: callbackify(migrateDefaultEmailAddress),
updateUser: callbackify(updateUser),
suspendUser: callbackify(suspendUser),
unsuspendUser: callbackify(unsuspendUser),
promises: {
addAffiliationForNewUser,
addEmailAddress,
@@ -720,6 +702,5 @@ export default {
migrateDefaultEmailAddress,
updateUser,
suspendUser,
unsuspendUser,
},
}
@@ -1,706 +0,0 @@
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
import { db } from '../../infrastructure/mongodb.mjs'
import Mongo from '../Helpers/Mongo.mjs'
import { callbackify } from 'node:util'
import UserGetter from './UserGetter.mjs'
import InstitutionsAPI from '../Institutions/InstitutionsAPI.mjs'
import Features from '../../infrastructure/Features.mjs'
import FeaturesUpdater from '../Subscription/FeaturesUpdater.mjs'
import EmailHandler from '../Email/EmailHandler.mjs'
import EmailHelper from '../Helpers/EmailHelper.mjs'
import Errors from '../Errors/Errors.js'
import UserAuditLogHandler from './UserAuditLogHandler.mjs'
import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
import EmailChangeHelper from '../Analytics/EmailChangeHelper.mjs'
import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs'
import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
import _ from 'lodash'
import Modules from '../../infrastructure/Modules.mjs'
import UserSessionsManager from './UserSessionsManager.mjs'
import ThirdPartyIdentityManager from './ThirdPartyIdentityManager.mjs'
import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs'
const { normalizeQuery } = Mongo
async function _sendSecurityAlertPrimaryEmailChanged(
userId,
oldEmail,
email,
deleteOldEmail
) {
// here
// Send email to the following:
// - the old primary
// - the new primary
// - for all other current (confirmed or recently-enough reconfirmed) email addresses, group by institution if we
// have it, or domain if we don’t, and for each group send to the most recently reconfirmed (or confirmed if never
// reconfirmed) address in that group.
// See #6101.
const emailOptions = {
actionDescribed: `the primary email address on your account was changed to ${email}`,
action: 'change of primary email address',
message: deleteOldEmail
? [
`We also removed the previous primary email ${oldEmail} from the account.`,
]
: [],
}
async function sendToRecipients(recipients) {
// On failure, log the error and carry on so that one email failing does not prevent other emails sending
for await (const recipient of recipients) {
try {
const opts = Object.assign({}, emailOptions, { to: recipient })
await EmailHandler.promises.sendEmail('securityAlert', opts)
} catch (error) {
logger.error(
{ error, userId },
'could not send security alert email when primary email changed'
)
}
}
}
// First, send notification to the old and new primary emails before getting other emails from v1 to ensure that these
// are still sent in the event of not being able to reach v1
const oldAndNewPrimaryEmails = [oldEmail, email]
await sendToRecipients(oldAndNewPrimaryEmails)
// Next, get extra recipients with affiliation data
const emailsData = await UserGetter.promises.getUserFullEmails(userId)
const extraRecipients = _securityAlertPrimaryEmailChangedExtraRecipients(
emailsData,
oldEmail,
email
)
await sendToRecipients(extraRecipients)
}
/**
* Add a new email address for the user. Email cannot be already used by this
* or any other user
*/
async function addEmailAddress(userId, newEmail, affiliationOptions, auditLog) {
AsyncLocalStorage.removeItem('userFullEmails')
newEmail = EmailHelper.parseEmail(newEmail)
if (!newEmail) {
throw new Error('invalid email')
}
await UserGetter.promises.ensureUniqueEmailAddress(newEmail)
AnalyticsManager.recordEventForUserInBackground(
userId,
'secondary-email-added'
)
await UserAuditLogHandler.promises.addEntry(
userId,
'add-email',
auditLog.initiatorId,
auditLog.ipAddress,
{
...auditLog.info,
newSecondaryEmail: newEmail,
}
)
try {
await InstitutionsAPI.promises.addAffiliation(
userId,
newEmail,
affiliationOptions
)
} catch (error) {
throw OError.tag(error, 'problem adding affiliation while adding email')
}
const createdAt = new Date()
let res
try {
const reversedHostname = newEmail.split('@')[1].split('').reverse().join('')
const update = {
$push: {
emails: { email: newEmail, createdAt, reversedHostname },
},
}
res = await updateUser(
{ _id: userId, 'emails.email': { $ne: newEmail } },
update
)
} catch (error) {
throw OError.tag(error, 'problem updating users emails')
}
if (res.matchedCount !== 1) {
return
}
try {
await EmailChangeHelper.registerEmailCreation(userId, newEmail, {
createdAt: new Date(),
emailCreatedAt: createdAt,
})
} catch (error) {
logger.warn(
{ error, userId, newEmail },
'Error registering email creation with analytics'
)
}
}
async function clearSAMLData(userId, auditLog, sendEmail) {
const user = await UserGetter.promises.getUser(userId, {
email: 1,
emails: 1,
})
await UserAuditLogHandler.promises.addEntry(
userId,
'clear-institution-sso-data',
auditLog.initiatorId,
auditLog.ipAddress,
{}
)
const update = {
$unset: {
samlIdentifiers: 1,
'emails.$[].samlProviderId': 1,
'enrollment.sso': 1,
},
}
await updateUser(userId, update)
for (const emailData of user.emails) {
await InstitutionsAPI.promises.removeEntitlement(userId, emailData.email)
}
await FeaturesUpdater.promises.refreshFeatures(
userId,
'clear-institution-sso-data'
)
if (sendEmail) {
await EmailHandler.promises.sendEmail('SAMLDataCleared', { to: user.email })
}
}
async function clearThirdPartyIdentifiers(userId, auditLog) {
const user = await UserGetter.promises.getUser(userId, {
thirdPartyIdentifiers: 1,
})
await UserAuditLogHandler.promises.addEntry(
userId,
'clear-third-party-identifiers',
auditLog.initiatorId,
auditLog.ipAddress,
{}
)
for (const thirdPartyIdentifier of user.thirdPartyIdentifiers || []) {
await ThirdPartyIdentityManager.promises.unlink(
userId,
thirdPartyIdentifier.providerId,
auditLog
)
}
}
/**
* set the default email address by setting the `email` attribute. The email
* must be one of the user's multiple emails (`emails` attribute)
*/
async function setDefaultEmailAddress(
userId,
email,
allowUnconfirmed,
auditLog,
sendSecurityAlert,
deleteOldEmail = false
) {
AsyncLocalStorage.removeItem('userFullEmails')
email = EmailHelper.parseEmail(email)
if (email == null) {
throw new Error('invalid email')
}
const user = await UserGetter.promises.getUser(userId, {
email: 1,
emails: 1,
})
if (!user) {
throw new Error('invalid userId')
}
const oldEmail = user.email
const userEmail = user.emails.find(e => e.email === email)
if (!userEmail) {
throw new Error('Default email does not belong to user')
}
if (!userEmail.confirmedAt && !allowUnconfirmed) {
throw new Errors.UnconfirmedEmailError()
}
await UserAuditLogHandler.promises.addEntry(
userId,
'change-primary-email',
auditLog.initiatorId,
auditLog.ipAddress,
{
newPrimaryEmail: email,
oldPrimaryEmail: oldEmail,
}
)
const query = { _id: userId, 'emails.email': email }
const update = { $set: { email, lastPrimaryEmailCheck: new Date() } }
const res = await updateUser(query, update)
// this should not happen
if (res.matchedCount !== 1) {
throw new Error('email update error')
}
AnalyticsManager.recordEventForUserInBackground(
userId,
'primary-email-address-updated'
)
try {
await EmailChangeHelper.registerEmailUpdate(userId, email, {
isPrimary: true,
action: 'updated',
createdAt: new Date(),
})
} catch (err) {
logger.warn(
{ err, userId, email },
'Error registering email change with analytics'
)
}
if (sendSecurityAlert) {
// no need to wait, errors are logged and not passed back
_sendSecurityAlertPrimaryEmailChanged(
userId,
oldEmail,
email,
deleteOldEmail
).catch(err => {
logger.error({ err }, 'failed to send security alert email')
})
}
try {
await Modules.promises.hooks.fire('userEmailChanged', user, email)
} catch (err) {
logger.error(
{ err, oldEmail, newEmail: email },
'Failed to fire "userEmailChanged" hook'
)
}
try {
await Modules.promises.hooks.fire(
'updateAccountEmailAddress',
user._id,
email
)
} catch (error) {
// errors are ignored
}
}
/**
* Overwrites the primary email address of a user in the database in-place.
* This function is only intended for use in scripts to migrate email addresses
* where we do not want to trigger all the actions that happen when a user
* changes their own email. It should not be used in any other circumstances.
*/
async function migrateDefaultEmailAddress(
userId,
oldEmail,
newEmail,
auditLog
) {
oldEmail = EmailHelper.parseEmail(oldEmail)
if (oldEmail == null) {
throw new Error('invalid old email')
}
newEmail = EmailHelper.parseEmail(newEmail)
if (newEmail == null) {
throw new Error('invalid new email')
}
const reversedHostname = newEmail.split('@')[1].split('').reverse().join('')
const query = {
_id: userId,
email: oldEmail,
'emails.email': oldEmail,
}
const update = {
$set: {
email: newEmail,
'emails.$.email': newEmail,
'emails.$.reversedHostname': reversedHostname,
},
}
const result = await updateUser(query, update)
if (result.modifiedCount !== 1) {
throw new Error('email update error')
}
// add a user audit log entry for the email change
await UserAuditLogHandler.promises.addEntry(
userId,
'migrate-default-email',
auditLog.initiatorId,
auditLog.ipAddress,
{
oldEmail,
newEmail,
// Add optional extra info
...(auditLog.extraInfo || {}),
}
)
}
async function confirmEmail(userId, email, affiliationOptions) {
AsyncLocalStorage.removeItem('userFullEmails')
// used for initial email confirmation (non-SSO and SSO)
// also used for reconfirmation of non-SSO emails
const confirmedAt = new Date()
email = EmailHelper.parseEmail(email)
if (email == null) {
throw new Error('invalid email')
}
logger.debug({ userId, email }, 'confirming user email')
try {
affiliationOptions = affiliationOptions || {}
affiliationOptions.confirmedAt = confirmedAt
await InstitutionsAPI.promises.addAffiliation(
userId,
email,
affiliationOptions
)
} catch (error) {
throw OError.tag(error, 'problem adding affiliation while confirming email')
}
const query = {
_id: userId,
'emails.email': email,
}
// only update confirmedAt if it was not previously set
const update = {
$set: {
'emails.$.reconfirmedAt': confirmedAt,
},
$min: {
'emails.$.confirmedAt': confirmedAt,
},
}
if (Features.hasFeature('affiliations')) {
update.$unset = {
'emails.$.affiliationUnchecked': 1,
}
}
const res = await updateUser(query, update)
if (res.matchedCount !== 1) {
throw new Errors.NotFoundError('user id and email do no match')
}
await FeaturesUpdater.promises.refreshFeatures(userId, 'confirm-email')
try {
await EmailChangeHelper.registerEmailUpdate(userId, email, {
emailConfirmedAt: confirmedAt,
action: 'updated',
isPrimary: false,
})
} catch (error) {
logger.warn(
{ error, userId, email },
'Error registering email confirmation with analytics'
)
}
try {
await maybeCreateRedundantSubscriptionNotification(userId, email)
} catch (error) {
logger.err(
{ err: error },
'error checking redundant subscription on email confirmation'
)
}
}
async function maybeCreateRedundantSubscriptionNotification(userId, email) {
const subscription =
await SubscriptionLocator.promises.getUserIndividualSubscription(userId)
if (!subscription || subscription.groupPlan) {
return
}
const affiliations =
await InstitutionsAPI.promises.getUserAffiliations(userId)
const confirmedAffiliation = affiliations.find(a => a.email === email)
if (!confirmedAffiliation || confirmedAffiliation.licence === 'free') {
return
}
await NotificationsBuilder.promises
.redundantPersonalSubscription(
{
institutionId: confirmedAffiliation.institution.id,
institutionName: confirmedAffiliation.institution.name,
},
{ _id: userId }
)
.create()
}
async function removeEmailAddress(
userId,
email,
auditLog,
skipParseEmail = false
) {
AsyncLocalStorage.removeItem('userFullEmails')
// remove one of the user's email addresses. The email cannot be the user's
// default email address
if (!skipParseEmail) {
email = EmailHelper.parseEmail(email)
} else if (skipParseEmail && typeof email !== 'string') {
throw new Error('email must be a string')
}
if (!email) {
throw new Error('invalid email')
}
const isMainEmail = await UserGetter.promises.getUserByMainEmail(email, {
_id: 1,
})
if (isMainEmail) {
throw new Error('cannot remove primary email')
}
await UserAuditLogHandler.promises.addEntry(
userId,
'remove-email',
auditLog.initiatorId,
auditLog.ipAddress,
{
removedEmail: email,
// Add optional extra info
...(auditLog.extraInfo || {}),
}
)
try {
await InstitutionsAPI.promises.removeAffiliation(userId, email)
} catch (error) {
OError.tag(error, 'problem removing affiliation')
throw error
}
const query = { _id: userId, email: { $ne: email } }
const update = { $pull: { emails: { email } } }
let res
try {
res = await updateUser(query, update)
} catch (error) {
OError.tag(error, 'problem removing users email')
throw error
}
if (res.matchedCount !== 1) {
throw new Error('Cannot remove email')
}
try {
await EmailChangeHelper.registerEmailDeletion(userId, email, {
isPrimary: false,
emailDeletedAt: new Date(),
})
} catch (error) {
logger.warn(
{ error, userId, email },
'Error registering email deletion with analytics'
)
}
await FeaturesUpdater.promises.refreshFeatures(userId, 'remove-email')
}
async function addAffiliationForNewUser(
userId,
email,
affiliationOptions = {}
) {
AsyncLocalStorage.removeItem('userFullEmails')
await InstitutionsAPI.promises.addAffiliation(
userId,
email,
affiliationOptions
)
try {
await updateUser(
{ _id: userId, 'emails.email': email },
{ $unset: { 'emails.$.affiliationUnchecked': 1 } }
)
} catch (error) {
logger.error(
OError.tag(
error,
'could not remove affiliationUnchecked flag for user on create',
{
userId,
email,
}
)
)
}
}
async function updateUser(query, update) {
query = normalizeQuery(query)
const result = await db.users.updateOne(query, update)
return result
}
/**
* DEPRECATED
*
* Change the user's main email address by adding a new email, switching the
* default email and removing the old email. Prefer manipulating multiple
* emails and the default rather than calling this method directly
*/
async function changeEmailAddress(userId, newEmail, auditLog) {
newEmail = EmailHelper.parseEmail(newEmail)
if (newEmail == null) {
throw new Error('invalid email')
}
const oldEmail = await UserGetter.promises.getUserEmail(userId)
await addEmailAddress(userId, newEmail, {}, auditLog)
await setDefaultEmailAddress(userId, newEmail, true, auditLog, true)
await removeEmailAddress(userId, oldEmail, auditLog)
}
/**
* @param {string} userId
* @param {{initiatorId: string, ip: string}} auditLog
* @returns {Promise<void>}
*/
async function removeReconfirmFlag(userId, auditLog) {
await UserAuditLogHandler.promises.addEntry(
userId.toString(),
'must-reset-password-unset',
auditLog.initiatorId,
auditLog.ip
)
await updateUser(userId.toString(), { $set: { must_reconfirm: false } })
}
async function suspendUser(userId, auditLog = {}) {
const res = await updateUser(
{ _id: userId, suspended: { $ne: true } },
{ $set: { suspended: true } }
)
if (res.matchedCount !== 1) {
throw new Errors.NotFoundError('user id not found or already suspended')
}
await UserAuditLogHandler.promises.addEntry(
userId,
'account-suspension',
auditLog.initiatorId,
auditLog.ip,
auditLog.info || {}
)
await UserSessionsManager.promises.removeSessionsFromRedis({ _id: userId })
await Modules.promises.hooks.fire(
'removeDropbox',
userId,
'account-suspension'
)
}
function _securityAlertPrimaryEmailChangedExtraRecipients(
emailsData,
oldEmail,
email
) {
// Group by institution if we have it, or domain if we don’t, and for each group send to the most recently
// reconfirmed (or confirmed if never reconfirmed) address in that group. We also remove the original and new
// primary email addresses because they are emailed separately
// See #6101.
function sortEmailsByConfirmation(emails) {
return emails.sort((e1, e2) => e2.lastConfirmedAt - e1.lastConfirmedAt)
}
const recipients = new Set()
const emailsToIgnore = new Set([oldEmail, email])
// Remove non-confirmed emails
const confirmedEmails = emailsData.filter(email => !!email.lastConfirmedAt)
// Group other emails by institution, separating out those with no institution and grouping them instead by domain.
// The keys for each group are not used for anything other than the grouping, so can have a slightly paranoid format
// to avoid any potential clash
const groupedEmails = _.groupBy(confirmedEmails, emailData => {
if (!emailData.affiliation || !emailData.affiliation.institution) {
return `domain:${EmailHelper.getDomain(emailData.email)}`
}
return `institution_id:${emailData.affiliation.institution.id}`
})
// For each group of emails, order the emails by (re-)confirmation date and pick the first
for (const emails of Object.values(groupedEmails)) {
// Sort by confirmation and pick the first
sortEmailsByConfirmation(emails)
// Ignore original and new primary email addresses
const recipient = emails[0].email
if (!emailsToIgnore.has(recipient)) {
recipients.add(emails[0].email)
}
}
return Array.from(recipients)
}
export default {
addAffiliationForNewUser: callbackify(addAffiliationForNewUser),
addEmailAddress: callbackify(addEmailAddress),
changeEmailAddress: callbackify(changeEmailAddress),
clearSAMLData: callbackify(clearSAMLData),
confirmEmail: callbackify(confirmEmail),
removeEmailAddress: callbackify(removeEmailAddress),
removeReconfirmFlag: callbackify(removeReconfirmFlag),
setDefaultEmailAddress: callbackify(setDefaultEmailAddress),
migrateDefaultEmailAddress: callbackify(migrateDefaultEmailAddress),
updateUser: callbackify(updateUser),
suspendUser: callbackify(suspendUser),
promises: {
addAffiliationForNewUser,
addEmailAddress,
changeEmailAddress,
clearSAMLData,
clearThirdPartyIdentifiers,
confirmEmail,
removeEmailAddress,
removeReconfirmFlag,
setDefaultEmailAddress,
migrateDefaultEmailAddress,
updateUser,
suspendUser,
},
}
@@ -425,7 +425,6 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
linkedInInsightsPartnerId: Settings.analytics?.linkedIn?.partnerId,
githubSyncEnabled: !!Settings.githubSync?.clientID && !!Settings.githubSync?.clientSecret,
zoteroEnabled: !!Settings.zotero?.clientKey && !!Settings.zotero?.clientSecret,
languageToolAvailable: !!Settings.languageToolURL,
enablePandocConversions: Settings.enablePandocConversions,
mixpanelLabsToken:
Settings.labs?.enable && Settings.analytics?.mixpanel?.labsToken,
@@ -36,7 +36,7 @@ footer.site-footer
a(href=item.url class=item.class aria-label=item.label) !{item.text}
else
| !{item.text}
if settings.env === 'server-ce' && settings.show_unsupport_warning
if settings.env === 'server-ce'
.row
.col-12
p.copyright-notice
+35 -37
View File
@@ -2,7 +2,6 @@ extends ../layout-website-redesign
block vars
- isWebsiteRedesign = true
- suppressNavbarRight = true
block content
main#main-content.content
@@ -14,42 +13,41 @@ block content
h1 !{login_support_title}
else
h1 #{translate("log_in")}
if local_login_mask_enable
form(name='loginForm' data-ol-async-form action='/login' method='POST')
input(name='_csrf' type='hidden' value=csrfToken)
+formMessagesNewStyle
+customFormMessageNewStyle('invalid-password-retry-or-reset', 'danger')
| !{translate('email_or_password_wrong_try_again_or_reset', {}, [{ name: 'a', attrs: { href: '/user/password/reset', 'aria-describedby': 'resetPasswordDescription' } }])}
span.visually-hidden(id='resetPasswordDescription')
| #{translate('reset_password_link')}
+customFormMessageNewStyle('password-compromised')
| !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}.
.form-group
label(for='email') #{translate("email")}
input#email.form-control(
name='email'
type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email'
required
placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com'
autofocus='true'
autocomplete='username'
)
.form-group
label(for='password') #{translate("password")}
input#password.form-control(
name='password'
type='password'
autocomplete='current-password'
required
)
.actions
button.btn-primary.btn(type='submit' data-ol-disabled-inflight)
span(data-ol-inflight='idle') #{translate("login")}
span(hidden data-ol-inflight='pending') #{translate("logging_in")}…
a.float-end(href='/user/password/reset') #{translate("forgot_your_password")}?
if login_support_text
hr
p.text-center !{login_support_text}
form(name='loginForm' data-ol-async-form action='/login' method='POST')
input(name='_csrf' type='hidden' value=csrfToken)
+formMessagesNewStyle
+customFormMessageNewStyle('invalid-password-retry-or-reset', 'danger')
| !{translate('email_or_password_wrong_try_again_or_reset', {}, [{ name: 'a', attrs: { href: '/user/password/reset', 'aria-describedby': 'resetPasswordDescription' } }])}
span.visually-hidden(id='resetPasswordDescription')
| #{translate('reset_password_link')}
+customFormMessageNewStyle('password-compromised')
| !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}.
.form-group
label(for='email') #{translate("email")}
input#email.form-control(
name='email'
type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email'
required
placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com'
autofocus='true'
autocomplete='username'
)
.form-group
label(for='password') #{translate("password")}
input#password.form-control(
name='password'
type='password'
autocomplete='current-password'
required
)
.actions
button.btn-primary.btn(type='submit' data-ol-disabled-inflight)
span(data-ol-inflight='idle') #{translate("login")}
span(hidden data-ol-inflight='pending') #{translate("logging_in")}…
a.float-end(href='/user/password/reset') #{translate("forgot_your_password")}?
if login_support_text
hr
p.text-center !{login_support_text}
if settings.saml && settings.saml.enable
.actions(style='margin-top: 30px;')
a.button.btn-secondary.btn(
+2 -17
View File
@@ -79,8 +79,6 @@ const safeCompilers = ['xelatex', 'pdflatex', 'latex', 'lualatex']
module.exports = {
env: 'server-ce',
// show the CE "free unsupported software" disclaimer in the footer; set SHOW_UNSUPPORT_WARNING=false to hide it
show_unsupport_warning: process.env.SHOW_UNSUPPORT_WARNING !== 'false',
limits: {
httpGlobalAgentMaxSockets: 300,
@@ -1034,12 +1032,7 @@ module.exports = {
),
],
contactUsModal: [],
sourceEditorExtensions: [
Path.resolve(
__dirname,
'../modules/languagetool/frontend/js/languagetool-extension'
),
],
sourceEditorExtensions: [],
sourceEditorVisualExtensions: [],
sourceEditorComponents: [],
pdfLogEntryHeaderActionComponents: [],
@@ -1180,12 +1173,7 @@ module.exports = {
],
referenceSearchSetting: [],
settingsModalEditorTabSections: [],
settingsModalSpellcheckSections: [
Path.resolve(
__dirname,
'../modules/languagetool/frontend/js/languagetool-section'
),
],
settingsModalSpellcheckSections: [],
editorFloatingMenuActions: [],
errorLogsComponents: [],
referenceIndices: [
@@ -1208,7 +1196,6 @@ module.exports = {
'symbol-palette',
'reference-picker',
'track-changes',
'languagetool',
'authentication/ldap',
'authentication/saml',
'authentication/oidc',
@@ -1245,8 +1232,6 @@ module.exports = {
},
enablePandocConversions: process.env.ENABLE_PANDOC_CONVERSIONS === 'true',
// local login form is shown by default; set ENABLE_LOCAL_LOGIN_MASK=false to hide it (e.g. SSO-only login)
enableLocalLoginMask: process.env.ENABLE_LOCAL_LOGIN_MASK !== 'false',
oauthProviders: {
...(process.env.EXTERNAL_AUTH && process.env.EXTERNAL_AUTH.includes('oidc') && {
@@ -13,15 +13,6 @@ type ProjectSettingsSetterContextValue = {
setSpellCheckLanguage: (
spellCheckLanguage: ProjectSettings['spellCheckLanguage']
) => void
setLanguageToolEnabled: (
languageToolEnabled: ProjectSettings['languageToolEnabled']
) => void
setLanguageToolPicky: (
languageToolPicky: ProjectSettings['languageToolPicky']
) => void
setLanguageToolLanguage: (
languageToolLanguage: ProjectSettings['languageToolLanguage']
) => void
setReferenceFormat: (
referenceFormat: ProjectSettings['referenceFormat']
) => Promise<void>
@@ -81,12 +72,6 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setSpellCheckLanguage,
referenceFormat,
setReferenceFormat,
languageToolEnabled,
setLanguageToolEnabled,
languageToolPicky,
setLanguageToolPicky,
languageToolLanguage,
setLanguageToolLanguage,
} = useProjectWideSettings()
const {
@@ -150,12 +135,6 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setRootDocId,
spellCheckLanguage,
setSpellCheckLanguage,
languageToolEnabled,
setLanguageToolEnabled,
languageToolPicky,
setLanguageToolPicky,
languageToolLanguage,
setLanguageToolLanguage,
autoComplete,
setAutoComplete,
autoPairDelimiters,
@@ -214,12 +193,6 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setRootDocId,
spellCheckLanguage,
setSpellCheckLanguage,
languageToolEnabled,
setLanguageToolEnabled,
languageToolPicky,
setLanguageToolPicky,
languageToolLanguage,
setLanguageToolLanguage,
autoComplete,
setAutoComplete,
autoPairDelimiters,
@@ -53,33 +53,6 @@ export default function useProjectWideSettingsSocketListener() {
[project, updateProject]
)
const setLanguageToolEnabled = useCallback(
(languageToolEnabled: ProjectSettings['languageToolEnabled']) => {
if (project) {
updateProject({ languageToolEnabled })
}
},
[project, updateProject]
)
const setLanguageToolPicky = useCallback(
(languageToolPicky: ProjectSettings['languageToolPicky']) => {
if (project) {
updateProject({ languageToolPicky })
}
},
[project, updateProject]
)
const setLanguageToolLanguage = useCallback(
(languageToolLanguage: ProjectSettings['languageToolLanguage']) => {
if (project) {
updateProject({ languageToolLanguage })
}
},
[project, updateProject]
)
useEffect(() => {
// data is not available on initial mounting
const dataAvailable = !!project
@@ -90,9 +63,6 @@ export default function useProjectWideSettingsSocketListener() {
socket.on('png2pdfUpdated', setPng2pdf)
socket.on('spellCheckLanguageUpdated', setSpellCheckLanguage)
socket.on('referenceFormatUpdated', setReferenceFormat)
socket.on('languageToolEnabledUpdated', setLanguageToolEnabled)
socket.on('languageToolPickyUpdated', setLanguageToolPicky)
socket.on('languageToolLanguageUpdated', setLanguageToolLanguage)
return () => {
socket.removeListener('compilerUpdated', setCompiler)
socket.removeListener('imageNameUpdated', setImageName)
@@ -102,15 +72,6 @@ export default function useProjectWideSettingsSocketListener() {
setSpellCheckLanguage
)
socket.removeListener('referenceFormatUpdated', setReferenceFormat)
socket.removeListener(
'languageToolEnabledUpdated',
setLanguageToolEnabled
)
socket.removeListener('languageToolPickyUpdated', setLanguageToolPicky)
socket.removeListener(
'languageToolLanguageUpdated',
setLanguageToolLanguage
)
}
}
}, [
@@ -121,8 +82,5 @@ export default function useProjectWideSettingsSocketListener() {
setReferenceFormat,
setPng2pdf,
setSpellCheckLanguage,
setLanguageToolEnabled,
setLanguageToolPicky,
setLanguageToolLanguage,
])
}
@@ -3,7 +3,6 @@ import type { ProjectSettings } from '../utils/api'
import useRootDocId from './use-root-doc-id'
import useSaveProjectSettings from './use-save-project-settings'
import useSetSpellCheckLanguage from './use-set-spell-check-language'
import useSetLanguageToolSetting from './use-set-language-tool'
import { debugConsole } from '@/utils/debugging'
import { useProjectContext } from '@/shared/context/project-context'
import getMeta from '@/utils/meta'
@@ -50,9 +49,6 @@ export default function useProjectWideSettings() {
const { setRootDocId, rootDocId } = useRootDocId()
const setSpellCheckLanguage = useSetSpellCheckLanguage()
const setLanguageToolEnabled = useSetLanguageToolSetting('languageToolEnabled')
const setLanguageToolPicky = useSetLanguageToolSetting('languageToolPicky')
const setLanguageToolLanguage = useSetLanguageToolSetting('languageToolLanguage')
return {
compiler: project?.compiler,
@@ -67,11 +63,5 @@ export default function useProjectWideSettings() {
setRootDocId,
spellCheckLanguage: project?.spellCheckLanguage,
setSpellCheckLanguage,
languageToolEnabled: project?.languageToolEnabled,
setLanguageToolEnabled,
languageToolPicky: project?.languageToolPicky,
setLanguageToolPicky,
languageToolLanguage: project?.languageToolLanguage,
setLanguageToolLanguage,
}
}
@@ -1,25 +0,0 @@
import { useCallback } from 'react'
import { type ProjectSettings } from '../utils/api'
import useSaveProjectSettings from './use-save-project-settings'
type LanguageToolSettingKey =
| 'languageToolEnabled'
| 'languageToolPicky'
| 'languageToolLanguage'
/**
* Setter for the LanguageTool project settings (enabled / picky / language),
* following the `useSet*` hook pattern of this directory.
*/
export default function useSetLanguageToolSetting(
key: LanguageToolSettingKey
) {
const saveProjectSettings = useSaveProjectSettings()
return useCallback(
(newValue: ProjectSettings[LanguageToolSettingKey]) => {
saveProjectSettings(key, newValue).catch(() => {})
},
[key, saveProjectSettings]
)
}
@@ -15,11 +15,6 @@ export interface ProjectSettings {
name: string
mainBibliographyDocId?: string
referenceFormat?: 'bibtex' | 'biblatex'
// LanguageTool grammar check (project-wide; defaults when unset:
// enabled=true, picky=true → level=picky, language='auto')
languageToolEnabled?: boolean
languageToolPicky?: boolean
languageToolLanguage?: string
}
type SaveUserSettings = Partial<
@@ -55,7 +55,6 @@ import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import { useProjectContext } from '@/shared/context/project-context'
import getMeta from '@/utils/meta'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
import { SearchQuery } from '@codemirror/search'
@@ -110,33 +109,6 @@ function useCodeMirrorScope(view: EditorView) {
const hunspellManager = useHunspell(spellCheckLanguage)
// ── LanguageTool grammar check (project-wide settings) ─────────────────────
// Stored on the project (shared by all collaborators). Defaults when unset:
// enabled + picky ON → level=picky. Values are handed to the CodeMirror
// extension through `createExtensions` below, and every change is
// announced with a window event the extension listens for.
const languageTool = {
available:
getMeta('ol-ExposedSettings')?.languageToolAvailable === true,
// off for read-only views (like spell-checking)
enabled:
(permissions.write || permissions.trackedWrite) &&
project?.languageToolEnabled !== false,
picky: project?.languageToolPicky !== false,
language: project?.languageToolLanguage || 'auto',
}
useEffect(() => {
window.dispatchEvent(
new CustomEvent('lt:settings-changed', { detail: languageTool })
)
}, [
languageTool.available,
languageTool.enabled,
languageTool.picky,
languageTool.language,
])
const { showVisual: visual, trackChanges } = useEditorPropertiesContext()
const { referenceKeys, searchLocalReferences } = useReferencesContext()
@@ -390,7 +362,6 @@ function useCodeMirrorScope(view: EditorView) {
settings: settingsRef.current,
phrases: phrasesRef.current,
spelling: spellingRef.current,
languageTool,
showVisual: showVisualRef.current,
previewByPath: previewByPathRef.current,
currentDocFolder: dirnameRef.current(currentDocument.doc_id),
@@ -50,13 +50,7 @@ body {
.navbar-default {
position: relative;
// Nudge the logo down and to the right so that it lines up with the
// sidebar items below it. Instances that set both a custom header image
// and a nav title (OVERLEAF_HEADER_IMAGE_URL and OVERLEAF_NAV_TITLE on
// Server Pro) show the title next to the logo, and the nudge would move
// only the logo and leave the two misaligned, so skip it in that case and
// keep the pair centred in the navbar as it is on every other page.
.navbar-header .navbar-brand:not(:has(.navbar-title)) .navbar-logo {
.navbar-header .navbar-logo {
@include media-breakpoint-up(md) {
position: relative;
top: calc(var(--spacing-05) * 2);
@@ -101,14 +101,14 @@ const GitBridgePATManager = {
if (!objToken?.user_id) return null
// does the user still exists and not deleted and not suspended?
// does the user still exists and not deleted?
// 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, suspended: 1 } }
{ projection: { _id: 1 } }
)
if (!user || user.suspended) return null
if (!user) return null
// non-blocking
db.oauthAccessTokens.updateOne(
@@ -1,90 +0,0 @@
# LanguageTool module
Grammar and style checking in the Overleaf source editor, powered by an
external [LanguageTool](https://languagetool.org) server (API v2).
Based on the upstream PR:
<https://github.com/yu-i-i/overleaf-cep/pull/179> (commit 8337e00), adapted
for ext-ce-v6.3.1 with:
- **Picky level as the default** and an on/off "Picky mode" toggle in the
project settings (the deployed LT fork, `erikvl87/languagetool`, supports
`default` + `picky`; other levels return HTTP 400).
- **Project-wide settings** (stored on the project document, shared by all
collaborators, synced over the editor socket):
- `languageToolEnabled` — grammar check on/off (default: on)
- `languageToolPicky` — picky level on/off (default: **on** → `level=picky`)
- `languageToolLanguage` — LT language code, e.g. `en-US`, `de` (default: `auto`)
- **Dark/light theme support**: tooltip + underlines use the Overleaf themed
CSS variables (`--bg-primary-themed`, `--content-primary-themed`, ...)
with light-mode fallbacks (fixes the dark-theme tooltip bug from the PR).
## Server (Overleaf) environment
| Variable | Default | Notes |
| ----------------------- | ----------------- | --------------------------------------- |
| `LANGUAGE_TOOL_ENABLED` | _unset_ | must be exactly `true` to activate |
| `LANGUAGE_TOOL_HOST` | `languagetool` | host of the LT service |
| `LANGUAGE_TOOL_PORT` | `8010` | LT API port |
| `LANGUAGE_TOOL_URL` | derived | overrides host+port when set |
The overleaf container needs network access to the LT service. In this
deployment the overleaf service joins the external
`languagetools_server_compose_default` network (see
`/data_1/docker/compose/overleaf/compose.yaml`) and talks to
`http://languagetool:8010`.
## Endpoints (authenticated)
- `GET /languagetool/languages` — LT language list (used by the settings UI)
- `POST /languagetool/check` — JSON body `{ language, level?, text? | data? }`;
proxied to LT `/v2/check` with a 100 KB cap and LaTeX-noise `disabledRules`.
**Exempt from CSRF** (`webRouter.csrf.disableDefaultCsrfProtection` in the
router): it is a stateless, login-gated query — and long-lived editor tabs
outlive session rotations, where the page's meta token would stale out and
403 every check. The client still sends `X-CSRF-Token`; it passes when the
session is fresh.
## Frontend
- `frontend/js/languagetool-extension.ts` — CodeMirror 6 extension
(wired via the `sourceEditorExtensions` slot in
`services/web/config/settings.defaults.js`). Wavy underlines + hover
tooltip with replacement buttons; LaTeX-aware annotation via the Lezer
syntax tree (regex fallback); debounced 2 s; aborts stale requests;
filters TYPOS while the Hunspell spellchecker is active.
- `frontend/js/languagetool-section.tsx` +
`frontend/js/components/languagetool-settings.tsx` — Settings modal,
Spelling and Language → **LanguageTool** (wired via the
`settingsModalSpellcheckSections` slot).
Settings reach the editor through `options.languageTool`
(`use-codemirror-scope.ts`) and the `lt:settings-changed` window event.
## Files
```
index.mjs module entry (env-gated router)
app/src/LanguageToolRouter.mjs
app/src/LanguageToolController.mjs
frontend/js/languagetool-extension.ts
frontend/js/languagetool-section.tsx
frontend/js/components/languagetool-settings.tsx
frontend/js/utils/latex-to-annotations.ts
```
Core Overleaf changes (see git diff):
- `services/web/config/settings.defaults.js`
(`sourceEditorExtensions`, `settingsModalSpellcheckSections`,
`moduleImportSequence`)
- `services/web/app/src/infrastructure/ExpressLocals.mjs`
(exposed setting `languageToolAvailable`)
- `services/web/types/exposed-settings.ts`
- `services/web/app/src/Features/Project/ProjectController.mjs`
(project-settings schema + update)
- `services/web/app/src/Features/Project/ProjectOptionsHandler.mjs` (persist)
- `services/web/app/src/Features/Editor/EditorController.mjs` (socket emit)
- `services/web/frontend/js/features/ide-settings/...` (settings UI context,
hooks, socket listener, `api.ts`)
- `services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts`
@@ -1,138 +0,0 @@
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import fetch from 'node-fetch'
const MAX_TEXT_SIZE = 100_000 // 100 KB limit for safety
// Levels understood by the LanguageTool fork we deploy
// (erikvl87/languagetool supports: default and picky).
const ALLOWED_LEVELS = new Set([
'default',
'normal',
'careful',
'meticulous',
'picky',
])
const LanguageToolController = {
/**
* GET /languagetool/languages
* Returns the list of supported languages from the LT service
*/
async getLanguages(req, res, next) {
try {
const response = await fetch(`${Settings.languageToolURL}/v2/languages`, {
headers: { Accept: 'application/json' },
timeout: 5000,
})
if (!response.ok) {
logger.error(
{ status: response.status },
'LanguageTool languages request failed'
)
return res.status(502).json({ error: 'LanguageTool request failed' })
}
const data = await response.json()
res.json(data)
} catch (err) {
logger.error({ err }, 'Error fetching LanguageTool languages')
next(err)
}
},
/**
* POST /languagetool/check
* Proxies text/annotation data to the LanguageTool service and returns matches.
* Accepts JSON body: { language, level?, text?, data? }
*
* `level` is optional; when omitted the LanguageTool default level is used.
* The client sends `picky` when the project's "picky mode" is enabled —
* picky is the recommended default in this deployment.
*/
async check(req, res, next) {
const { language = 'auto', level, text, data } = req.body
if (!text && !data) {
return res.status(400).json({ error: 'text or data is required' })
}
if (level && !ALLOWED_LEVELS.has(level)) {
return res.status(400).json({ error: `unknown level: ${level}` })
}
// Sanitize: reject oversized requests
const contentStr =
typeof data === 'string'
? data
: typeof data === 'object'
? JSON.stringify(data)
: text || ''
if (contentStr.length > MAX_TEXT_SIZE) {
logger.warn(
{ size: contentStr.length },
'LanguageTool request exceeds size limit, truncating'
)
}
try {
const params = new URLSearchParams()
params.set('language', language)
if (level) {
params.set('level', level)
}
if (data) {
const dataStr =
typeof data === 'string' ? data : JSON.stringify(data)
params.set('data', dataStr.slice(0, MAX_TEXT_SIZE))
} else {
params.set('text', (text || '').slice(0, MAX_TEXT_SIZE))
}
// Disable rules that produce excessive false positives in LaTeX documents
params.set(
'disabledRules',
'WHITESPACE_RULE,COMMA_PARENTHESIS_WHITESPACE,CONSECUTIVE_SPACES,DASH_RULE,UPPERCASE_SENTENCE_START'
)
const response = await fetch(`${Settings.languageToolURL}/v2/check`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: params.toString(),
timeout: 60_000,
})
if (!response.ok) {
const body = await response.text()
logger.error(
{ status: response.status, level, body: body.slice(0, 500) },
'LanguageTool check failed'
)
return res
.status(502)
.json({ error: 'LanguageTool check failed', serverStatus: response.status })
}
const result = await response.json()
res.json(result)
} catch (err) {
// On timeout or network error return empty results so the linter stays silent
if (err.type === 'request-timeout' || err.name === 'FetchError') {
logger.warn({ err }, 'LanguageTool check timed out or failed, returning empty results')
return res.json({ matches: [] })
}
logger.error({ err }, 'Error checking text with LanguageTool')
next(err)
}
},
}
export default LanguageToolController
@@ -1,31 +0,0 @@
import logger from '@overleaf/logger'
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
import LanguageToolController from './LanguageToolController.mjs'
export default {
apply(webRouter) {
logger.debug({}, 'Init languagetool router')
// POST /languagetool/check is a stateless grammar query (login required,
// no server state changes). Exempt it from CSRF: long-lived editor tabs
// hold the page's meta token, and after an in-flight (re-)login the
// browser's session has rotated, so the stale token would 403 every
// check even though the request is fully authenticated.
if (webRouter.csrf && webRouter.csrf.disableDefaultCsrfProtection) {
webRouter.csrf.disableDefaultCsrfProtection('/languagetool/check', 'POST')
}
// Authenticated endpoints
webRouter.get(
'/languagetool/languages',
AuthenticationController.requireLogin(),
LanguageToolController.getLanguages
)
webRouter.post(
'/languagetool/check',
AuthenticationController.requireLogin(),
LanguageToolController.check
)
}
}
@@ -1,152 +0,0 @@
/**
* LanguageTool settings component — rendered inside the Settings modal
* (Spelling and Language → LanguageTool section) via the
* `settingsModalSpellcheckSections` slot.
*
* Persistence: all three settings are PROJECT settings — stored on the
* project document so every collaborator sees the same values:
* - languageToolEnabled (default: true)
* - languageToolPicky (default: true → level=picky is the default)
* - languageToolLanguage (default: 'auto')
*
* Changes are saved server-side through the project settings context and
* synced to other clients over the project socket. The CodeMirror
* extension is informed via the `lt:settings-changed` window event so this
* component does not need to be inside the CodeMirrorViewContext.
*/
import { useState, useEffect, useCallback } from 'react'
import { useProjectSettingsContext } from '@/features/ide-settings/context/project-settings-context'
import { getJSON } from '@/infrastructure/fetch-json'
import DropdownSetting from '@/features/ide-settings/components/dropdown-setting'
import ToggleSetting from '@/features/ide-settings/components/toggle-setting'
import getMeta from '@/utils/meta'
interface LTLanguage {
name: string
code: string
longCode: string
}
export default function LanguageToolSettings() {
const {
languageToolEnabled,
setLanguageToolEnabled,
languageToolPicky,
setLanguageToolPicky,
languageToolLanguage,
setLanguageToolLanguage,
} = useProjectSettingsContext()
// Defaults: enabled + picky (picky level is the recommended default).
const enabled = languageToolEnabled !== false
const picky = languageToolPicky !== false
const language = languageToolLanguage || 'auto'
const gtAvailable =
getMeta('ol-ExposedSettings')?.languageToolAvailable === true
const [languages, setLanguages] = useState<LTLanguage[]>([])
const [languagesLoading, setLanguagesLoading] = useState(false)
// ── Load language list when LT is available and grammar check is on ──
useEffect(() => {
if (!gtAvailable || !enabled || languages.length > 0) return
setLanguagesLoading(true)
getJSON<LTLanguage[]>('/languagetool/languages')
.then(langs => {
// Sort alphabetically by display name for usability
const sorted = [...langs].sort((a, b) => a.name.localeCompare(b.name))
setLanguages(sorted)
})
.catch(() => {})
.finally(() => setLanguagesLoading(false))
}, [gtAvailable, enabled, languages.length])
// ── Notify the CodeMirror extension of the effective settings ─────────
const notify = useCallback(
(detail: { enabled: boolean; picky: boolean; language: string }) => {
window.dispatchEvent(
new CustomEvent('lt:settings-changed', {
detail: { ...detail, available: gtAvailable },
})
)
},
[gtAvailable]
)
const handleToggleEnabled = useCallback(
(newEnabled: boolean) => {
setLanguageToolEnabled(newEnabled)
notify({ enabled: newEnabled, picky, language })
},
[setLanguageToolEnabled, picky, language, notify]
)
const handleTogglePicky = useCallback(
(newPicky: boolean) => {
setLanguageToolPicky(newPicky)
notify({ enabled, picky: newPicky, language })
},
[setLanguageToolPicky, enabled, language, notify]
)
const handleLanguageChange = useCallback(
(newLanguage: string) => {
setLanguageToolLanguage(newLanguage || 'auto')
notify({ enabled, picky, language: newLanguage || 'auto' })
},
[setLanguageToolLanguage, enabled, picky, notify]
)
// Don't render anything if LT is not configured on the server
if (!gtAvailable) return null
return (
<>
<ToggleSetting
id="languagetool-enabled"
label="LanguageTool grammar check"
description="Grammar and style checking powered by LanguageTool"
checked={enabled}
onChange={handleToggleEnabled}
/>
{enabled && (
<>
<ToggleSetting
id="languagetool-picky"
label="Picky mode"
description="Picky level catches more (subtle) grammar and style issues; off uses LanguageTool's default level"
checked={picky}
onChange={handleTogglePicky}
/>
<DropdownSetting
id="languagetool-language"
label="Language"
options={[{ value: 'auto', label: 'Auto-detect' }]}
optgroups={
languages.length > 0
? [
{
label: 'Languages',
options: languages.map(l => ({
value: l.longCode,
label: l.name,
})),
},
]
: []
}
onChange={handleLanguageChange}
value={language}
width="wide"
loading={languagesLoading}
/>
</>
)}
</>
)
}
@@ -1,597 +0,0 @@
/**
* LanguageTool CodeMirror 6 extension.
*
* Exported as a named `extension` function matching the `sourceEditorExtensions`
* module-import slot signature: (options: Record<string, any>) => Extension
*
* Features:
* - Grammar and style linting via the /languagetool/check backend proxy
* - Uses a CUSTOM decoration + tooltip system instead of @codemirror/lint.
* The Overleaf compile-log linter globally combines markerFilter (only
* severity=error) and tooltipFilter (returns []) into the lint facet,
* which hides LT grammar warnings. By managing our own StateField,
* Decoration layer, and hoverTooltip we bypass those filters entirely.
* - Debounced: checks 2 s after the last document change
* - LaTeX-aware: uses the Lezer syntax tree to identify plain-text regions;
* math, commands, comments, citation keys, labels, and package arguments
* are sent as `markup` in the LT annotation format
* - Falls back to regex-based parsing when the syntax tree is unavailable
* - Filters TYPOS category when the Overleaf Hunspell spellchecker is active
* to avoid duplicate underlines; tracks Hunspell state dynamically
* - Cancels stale HTTP requests with AbortController
* - Project-wide settings: `enabled` / `picky` / `language` arrive through
* `options.languageTool` (from the project settings, default: enabled,
* picky ON → level=picky) and live `lt:settings-changed` window events
* dispatched by the settings UI / the source-editor scope.
* - Theme aware: tooltip and underlines use the Overleaf themed CSS
* variables (--*-themed) with light-mode fallbacks, so they render
* correctly in both the dark and the light theme.
*
* All logic is self-contained — no imports from Overleaf core internals.
*/
import {
StateEffect,
StateField,
type EditorState,
type Extension,
} from '@codemirror/state'
import {
Decoration,
type DecorationSet,
EditorView,
ViewPlugin,
hoverTooltip,
type ViewUpdate,
} from '@codemirror/view'
import { syntaxTree, ensureSyntaxTree } from '@codemirror/language'
import type { SyntaxNodeRef } from '@lezer/common'
import getMeta from '@/utils/meta'
import {
latexToAnnotations,
type LTAnnotation,
} from './utils/latex-to-annotations'
// ─── LT types ─────────────────────────────────────────────────────────────────
interface LTMatch {
offset: number
length: number
message: string
rule?: { id?: string; category?: { id?: string } }
replacements?: Array<{ value: string }>
}
/** Our own diagnostic type — independent of @codemirror/lint's Diagnostic. */
interface LTDiagnostic {
from: number
to: number
severity: 'error' | 'warning'
message: string
source: string
replacements: string[]
}
/** Effective per-project settings (see languagetool-settings.tsx). */
interface LTSettings {
available: boolean
enabled: boolean
picky: boolean
language: string
}
const DEFAULT_SETTINGS: LTSettings = {
available: false,
enabled: true,
picky: true,
language: 'auto',
}
// ─── LaTeX node skip-list ─────────────────────────────────────────────────────
// Mirrors the `noSpellCheckProp` definitions in the Overleaf LaTeX grammar
// without importing the core module. These argument node types contain
// non-prose content (citation keys, labels, package names, etc.) and must be
// skipped so that LanguageTool does not report false positives on them.
/** Node types whose entire subtree is always non-prose. */
const ALWAYS_SKIP = new Set([
'BibKeyArgument',
'BibliographyArgument',
'BibliographyStyleArgument',
'DocumentClassArgument',
'LabelArgument',
'PackageArgument',
'RefArgument',
])
/** Node types to skip only when they appear inside certain parent contexts. */
const CONTEXTUAL_SKIP: Record<string, string[][]> = {
OptionalArgument: [
['DocumentClass'],
['IncludeGraphics'],
['LineBreak'],
['UsePackage'],
['FigureEnvironment', 'BeginEnv'],
['ListEnvironment', 'BeginEnv'],
],
ShortTextArgument: [['Date'], ['SetLengthCommand']],
TextArgument: [['TabularEnvironment', 'BeginEnv']],
}
function shouldSkipNode(node: SyntaxNodeRef): boolean {
if (ALWAYS_SKIP.has(node.type.name)) return true
const contexts = CONTEXTUAL_SKIP[node.type.name]
if (contexts) {
return contexts.some(ctx => node.matchContext(ctx))
}
return false
}
// ─── Syntax-tree walker ───────────────────────────────────────────────────────
interface TextSpan {
from: number
to: number
text: string
}
/**
* Collect all "Normal" text spans from a single document line using the Lezer
* syntax tree. Nodes matching the skip-list above are pruned so their
* children (including any nested Normal nodes) are never visited.
*/
function getNormalSpansFromLine(
state: EditorState,
lineFrom: number,
lineTo: number
): TextSpan[] {
const tree = syntaxTree(state)
const spans: TextSpan[] = []
tree.iterate({
from: lineFrom,
to: lineTo,
enter(node: SyntaxNodeRef) {
if (shouldSkipNode(node)) return false
if (node.type.name === 'Normal') {
spans.push({
from: node.from,
to: node.to,
text: state.doc.sliceString(node.from, node.to),
})
return false
}
return true
},
})
return spans
}
// ─── Annotation builder (syntax tree) ─────────────────────────────────────────
/**
* Emit a gap (everything between two Normal text spans) as whitespace → text
* (so LanguageTool sees sentence boundaries) and non-whitespace → markup.
*/
function emitGap(annotations: LTAnnotation[], gap: string): void {
const segments = gap.match(/\s+|\S+/g)
if (!segments) return
for (const segment of segments) {
if (/^\s+$/.test(segment)) {
annotations.push({ text: segment })
} else {
annotations.push({ markup: segment })
}
}
}
/**
* Build LT annotations from the Lezer syntax tree. Only "Normal" text nodes
* are emitted as checkable `text`; everything else becomes `markup`.
*
* Invariant: annotations.map(a => a.text ?? a.markup).join('') === source
*/
function buildAnnotationsFromTree(view: EditorView): LTAnnotation[] {
const { state } = view
const doc = state.doc
const annotations: LTAnnotation[] = []
let lastEnd = 0
for (let i = 1; i <= doc.lines; i++) {
const line = doc.line(i)
const spans = getNormalSpansFromLine(state, line.from, line.to)
for (const span of spans) {
if (span.from > lastEnd) {
emitGap(annotations, doc.sliceString(lastEnd, span.from))
}
annotations.push({ text: span.text })
lastEnd = span.to
}
}
if (lastEnd < doc.length) {
emitGap(annotations, doc.sliceString(lastEnd, doc.length))
}
return annotations
}
// ─── State management ─────────────────────────────────────────────────────────
//
// We deliberately do NOT use @codemirror/lint's `linter()` because its
// `lintConfig` facet is a global combine: the compile-log linter's
// `markerFilter` (only severity=error) and `tooltipFilter` (returns [])
// suppress our grammar-warning underlines and tooltips. Instead we manage
// our own StateField → DecorationSet pipeline and a standalone hoverTooltip.
const setLTDiagnosticsEffect = StateEffect.define<readonly LTDiagnostic[]>()
const ltDiagnosticsField = StateField.define<{
diagnostics: readonly LTDiagnostic[]
decorations: DecorationSet
}>({
create() {
return { diagnostics: [], decorations: Decoration.none }
},
update({ diagnostics, decorations }, tr) {
for (const effect of tr.effects) {
if (effect.is(setLTDiagnosticsEffect)) {
const diags = effect.value
if (diags.length === 0) {
return { diagnostics: [], decorations: Decoration.none }
}
const marks = diags
.filter(d => d.from < d.to && d.to <= tr.state.doc.length)
.map(d =>
Decoration.mark({
class: `cm-lt-underline cm-lt-underline-${d.severity}`,
}).range(d.from, d.to)
)
return {
diagnostics: diags,
decorations: Decoration.set(marks, true),
}
}
}
if (tr.docChanged) {
const mapped = diagnostics
.map(d => ({
...d,
from: tr.changes.mapPos(d.from, 1),
to: tr.changes.mapPos(d.to, -1),
}))
.filter(d => d.from < d.to)
return {
diagnostics: mapped,
decorations: decorations.map(tr.changes),
}
}
return { diagnostics, decorations }
},
provide: f => EditorView.decorations.from(f, val => val.decorations),
})
// ─── Hover tooltip ────────────────────────────────────────────────────────────
const ltTooltip = hoverTooltip(
(view, pos) => {
const { diagnostics } = view.state.field(ltDiagnosticsField)
const matches = diagnostics.filter(d => d.from <= pos && pos <= d.to)
if (!matches.length) return null
const primary = matches[0]
return {
pos: primary.from,
end: primary.to,
above: true,
// NB: this closure uses the guard's `view` (EditorView) — do NOT
// shadow it, hoverTooltip passes the editor *state* to create().
create() {
const dom = document.createElement('div')
dom.className = 'cm-lt-tooltip'
for (const match of matches) {
const entry = document.createElement('div')
entry.className = 'cm-lt-tooltip-entry'
const msg = document.createElement('div')
msg.className = 'cm-lt-tooltip-message'
msg.textContent = match.message
entry.appendChild(msg)
if (match.source) {
const src = document.createElement('div')
src.className = 'cm-lt-tooltip-source'
src.textContent = match.source
entry.appendChild(src)
}
if (match.replacements.length) {
const actions = document.createElement('div')
actions.className = 'cm-lt-tooltip-actions'
for (const rep of match.replacements) {
const btn = document.createElement('button')
btn.className = 'cm-lt-tooltip-action'
btn.textContent = rep
btn.addEventListener('click', e => {
e.preventDefault()
view.dispatch({
changes: { from: match.from, to: match.to, insert: rep },
})
})
actions.appendChild(btn)
}
entry.appendChild(actions)
}
dom.appendChild(entry)
}
return { dom }
},
}
},
{ hoverTime: 300 }
)
// ─── Theme ────────────────────────────────────────────────────────────────────
// Overleaf 6.x themes via body[data-theme] → semantic CSS variables
// (--bg-primary-themed & friends, see
// stylesheets/abstracts/themes-common-variables.scss). The light fallbacks
// in var(...) keep the extension usable where the variables are undefined.
const ltTheme = EditorView.baseTheme({
'.cm-lt-underline': {
backgroundImage: 'none !important',
textDecoration: 'underline wavy',
textUnderlineOffset: '3px',
},
'.cm-lt-underline-warning': {
textDecorationColor: 'var(--content-warning-themed, #E8A200)',
},
'.cm-lt-underline-error': {
textDecorationColor: 'var(--content-danger-themed, #ff5252)',
},
'.cm-lt-tooltip': {
color: 'var(--content-primary-themed, #333)',
backgroundColor: 'var(--bg-primary-themed, #fff)',
border: '1px solid var(--border-divider-themed, #ddd)',
borderRadius: '4px',
padding: '8px',
fontSize: '13px',
maxWidth: '400px',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
},
'.cm-lt-tooltip-entry + .cm-lt-tooltip-entry': {
marginTop: '8px',
paddingTop: '8px',
borderTop: '1px solid var(--border-divider-themed, #eee)',
},
'.cm-lt-tooltip-message': {
marginBottom: '4px',
},
'.cm-lt-tooltip-source': {
fontSize: '11px',
color: 'var(--content-secondary-themed, #888)',
marginBottom: '4px',
},
'.cm-lt-tooltip-actions': {
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
},
'.cm-lt-tooltip-action': {
padding: '2px 8px',
border: '1px solid var(--border-divider-themed, #ccc)',
borderRadius: '3px',
backgroundColor: 'var(--bg-secondary-themed, #f5f5f5)',
color: 'var(--content-primary-themed, #333)',
cursor: 'pointer',
fontSize: '12px',
'&:hover': {
backgroundColor: 'var(--bg-tertiary-themed, #e0e0e0)',
},
},
})
// ─── Module export ────────────────────────────────────────────────────────────
export const extension = (options: Record<string, any>): Extension => {
/**
* ViewPlugin that manages the full lifecycle:
* 1. On mount — starts disabled until the first `lt:settings-changed`
* event (dispatched by the source-editor scope with the project
* settings) or the options snapshot confirms LT is available and
* enabled for this project.
* 2. On document changes — debounces a new check (2 s after last edit).
* 3. Listens to `lt:settings-changed` window events carrying the
* per-project `{ enabled, picky, language }` settings.
* 4. Tracks Hunspell state dynamically via duck-typing on
* setSpellCheckLanguageEffect payloads.
* 5. Dispatches setLTDiagnosticsEffect to update the StateField, which
* in turn drives the decoration layer and hover tooltip.
*/
const syncPlugin = ViewPlugin.define(view => {
const initial: LTSettings = {
...DEFAULT_SETTINGS,
...(options?.languageTool ?? {}),
}
let ltServerAvailable = !!initial.available
let hunspellActive = !!options?.spelling?.spellCheckLanguage
let currentLanguage = initial.language || 'auto'
let picky = initial.picky !== false
let enabled = !!initial.available && (initial.enabled !== false)
let timer: ReturnType<typeof setTimeout> | null = null
let controller: AbortController | null = null
// ── Scheduling ──────────────────────────────────────────────────────
function scheduleCheck(delay = 2000) {
if (timer) clearTimeout(timer)
if (!enabled) return
timer = setTimeout(() => runCheck(), delay)
}
function clearAll() {
if (timer) clearTimeout(timer)
timer = null
if (controller) controller.abort()
controller = null
view.dispatch({ effects: setLTDiagnosticsEffect.of([]) })
}
// ── Core check logic ────────────────────────────────────────────────
async function runCheck() {
if (!enabled) return
// Abort any in-flight request.
if (controller) controller.abort()
const ac = new AbortController()
controller = ac
const source = view.state.doc.toString()
if (source.trim().length < 20) {
if (!ac.signal.aborted) {
view.dispatch({ effects: setLTDiagnosticsEffect.of([]) })
}
return
}
// Prefer the Lezer syntax tree; fall back to regex-based parser.
let annotations: LTAnnotation[]
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 500)
if (tree) {
annotations = buildAnnotationsFromTree(view)
} else {
annotations = latexToAnnotations(source)
}
try {
const response = await fetch('/languagetool/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': getMeta('ol-csrfToken') },
body: JSON.stringify({
language: currentLanguage,
// picky mode ON (the default) → level=picky; OFF → level=default
level: picky ? 'picky' : 'default',
data: { annotation: annotations },
}),
signal: ac.signal,
})
if (!response.ok) return
const result: { matches: LTMatch[] } = await response.json()
// Filter TYPOS when Hunspell is active (it handles typos better).
let matches = result.matches
if (hunspellActive) {
matches = matches.filter(m => m.rule?.category?.id !== 'TYPOS')
}
const docLen = view.state.doc.length
const diagnostics: LTDiagnostic[] = matches
.filter(m => m.offset >= 0 && m.offset + m.length <= docLen)
.map(m => ({
from: m.offset,
to: m.offset + m.length,
severity:
m.rule?.category?.id === 'TYPOS'
? ('error' as const)
: ('warning' as const),
message: m.message,
source: `LanguageTool (${m.rule?.id ?? ''})${
picky ? ' (picky)' : ''
}`,
replacements: (m.replacements ?? []).slice(0, 5).map(r => r.value),
}))
if (!ac.signal.aborted) {
view.dispatch({ effects: setLTDiagnosticsEffect.of(diagnostics) })
}
} catch {
// AbortError or network failure — silently ignore.
}
}
// ── Settings-changed listener ───────────────────────────────────────
//
// Detail shape (from languagetool-settings.tsx / use-codemirror-scope):
// { available?: boolean, enabled?: boolean, picky?: boolean, language?: string }
const applySettings = (detail: Partial<LTSettings>) => {
if (detail.available != null) ltServerAvailable = !!detail.available
const newLanguage = detail.language || currentLanguage || 'auto'
const newPicky = detail.picky !== false
const newEnabled = ltServerAvailable && (detail.enabled !== false)
const changed =
newEnabled !== enabled || newPicky !== picky || newLanguage !== currentLanguage
enabled = newEnabled
picky = newPicky
currentLanguage = newLanguage
if (!enabled) {
clearAll()
} else if (changed) {
scheduleCheck(100)
}
}
const handler = (event: Event) => {
const detail = (event as CustomEvent<Partial<LTSettings>>).detail
if (!detail) return
applySettings(detail)
}
window.addEventListener('lt:settings-changed', handler)
// ── Initialisation ──────────────────────────────────────────────────
if (enabled) {
scheduleCheck(500)
}
// ── ViewPlugin callbacks ────────────────────────────────────────────
return {
update(update: ViewUpdate) {
if (update.docChanged && enabled) {
// Abort stale in-flight request and schedule a fresh check.
if (controller) controller.abort()
scheduleCheck()
}
// Track Hunspell state changes (duck-typed from effects).
for (const tr of update.transactions) {
for (const effect of tr.effects) {
const val = effect.value as Record<string, unknown> | null
if (
val !== null &&
typeof val === 'object' &&
'spellCheckLanguage' in val
) {
const newActive = !!(val.spellCheckLanguage as string)
if (newActive !== hunspellActive) {
hunspellActive = newActive
if (enabled) scheduleCheck(100)
}
}
}
}
},
destroy() {
window.removeEventListener('lt:settings-changed', handler)
if (timer) clearTimeout(timer)
if (controller) {
controller.abort()
controller = null
}
},
}
})
return [ltDiagnosticsField, ltTooltip, ltTheme, syncPlugin]
}
@@ -1,24 +0,0 @@
/**
* LanguageTool settings section hook — registers the LanguageTool settings in
* the Settings modal (Spelling and Language tab) through the
* `settingsModalSpellcheckSections` slot.
*
* Signature required by the slot consumer: a default-exported function that
* returns a SettingsSection (or null).
*/
import type { SettingsSection } from '@/features/ide-settings/context/types'
import LanguageToolSettings from './components/languagetool-settings'
export default function languageToolSection(): SettingsSection {
return {
key: 'languagetool',
title: 'LanguageTool',
settings: [
{
key: 'languagetool-settings',
component: <LanguageToolSettings />,
},
],
}
}
@@ -1,210 +0,0 @@
/**
* Converts LaTeX source to a LanguageTool annotation array.
*
* The invariant is:
* annotations.map(a => a.text ?? a.markup).join('') === source
*
* This means that the character offsets returned by LanguageTool (which count
* both text and markup characters) map directly to positions in the original
* CodeMirror document — no offset remapping is needed.
*
* LaTeX commands, math environments, and comments are emitted as `markup`.
* Regular text content is emitted as `text` and will be grammar-checked.
*/
export interface LTAnnotation {
text?: string
markup?: string
}
/** Math environments whose entire content should be treated as markup. */
const MATH_ENVS = new Set([
'equation',
'equation*',
'align',
'align*',
'alignat',
'alignat*',
'gather',
'gather*',
'multline',
'multline*',
'displaymath',
'eqnarray',
'eqnarray*',
'flalign',
'flalign*',
'subequations',
'math',
'dmath',
'dgroup',
'dgroup*',
])
export function latexToAnnotations(source: string): LTAnnotation[] {
const annotations: LTAnnotation[] = []
let textStart = 0 // start of the current un-emitted text segment
let i = 0
/** Emit accumulated plain text up to `end`. */
function flushText(end: number): void {
if (end > textStart) {
annotations.push({ text: source.slice(textStart, end) })
}
}
/**
* Emit `source[start..end)` as markup, flushing any buffered text first.
* Also advances `textStart` to `end`.
*/
function addMarkup(start: number, end: number): void {
flushText(start)
if (end > start) {
annotations.push({ markup: source.slice(start, end) })
}
textStart = end
}
while (i < source.length) {
const ch = source[i]
// ── LaTeX comments: % … (end of line) ──────────────────────────────────
if (ch === '%') {
let end = i + 1
while (end < source.length && source[end] !== '\n') end++
addMarkup(i, end)
i = end
continue
}
// ── Display math: $$…$$ ────────────────────────────────────────────────
if (ch === '$' && i + 1 < source.length && source[i + 1] === '$') {
const start = i
let j = i + 2
while (j + 1 < source.length) {
if (source[j] === '$' && source[j + 1] === '$') {
j += 2
break
}
j++
}
addMarkup(start, j)
i = j
continue
}
// ── Inline math: $…$ ───────────────────────────────────────────────────
if (ch === '$') {
const start = i
let j = i + 1
while (j < source.length && source[j] !== '$') {
if (source[j] === '\\') j++ // skip one escaped character
j++
}
const end = j < source.length ? j + 1 : j
addMarkup(start, end)
i = end
continue
}
// ── LaTeX commands ─────────────────────────────────────────────────────
if (ch === '\\' && i + 1 < source.length) {
const next = source[i + 1]
// \[…\] (display math)
if (next === '[') {
const start = i
let j = i + 2
while (j + 1 < source.length) {
if (source[j] === '\\' && source[j + 1] === ']') {
j += 2
break
}
j++
}
addMarkup(start, j)
i = j
continue
}
// \(…\) (inline math)
if (next === '(') {
const start = i
let j = i + 2
while (j + 1 < source.length) {
if (source[j] === '\\' && source[j + 1] === ')') {
j += 2
break
}
j++
}
addMarkup(start, j)
i = j
continue
}
// \begin{envname}
if (source.startsWith('begin{', i + 1)) {
const closeBrace = source.indexOf('}', i + 7)
if (closeBrace !== -1) {
const envName = source.slice(i + 7, closeBrace)
if (MATH_ENVS.has(envName)) {
// Entire math environment is markup
const endTag = `\\end{${envName}}`
const endIdx = source.indexOf(endTag, closeBrace + 1)
const envEnd =
endIdx !== -1 ? endIdx + endTag.length : source.length
addMarkup(i, envEnd)
i = envEnd
continue
}
// Non-math environment: only mark up the \begin{...} tag itself
addMarkup(i, closeBrace + 1)
i = closeBrace + 1
continue
}
}
// \end{…}
if (source.startsWith('end{', i + 1)) {
const closeBrace = source.indexOf('}', i + 5)
const end = closeBrace !== -1 ? closeBrace + 1 : i + 5
addMarkup(i, end)
i = end
continue
}
// General alpha command: \commandname (optional trailing whitespace)
if (/[a-zA-Z]/.test(next)) {
let j = i + 1
while (j < source.length && /[a-zA-Z]/.test(source[j])) j++
// Absorb trailing horizontal whitespace into the markup token
while (j < source.length && (source[j] === ' ' || source[j] === '\t'))
j++
addMarkup(i, j)
i = j
continue
}
// Single non-letter command: \\, \%, \$, \{, \}, \&, \_, \#, etc.
addMarkup(i, i + 2)
i += 2
continue
}
// ── Curly braces (LaTeX grouping — always markup) ──────────────────────
if (ch === '{' || ch === '}') {
addMarkup(i, i + 1)
i++
continue
}
// ── Regular text character ─────────────────────────────────────────────
i++
}
flushText(source.length)
return annotations
}
@@ -1,18 +0,0 @@
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
let LanguageToolModule = {}
if (process.env.LANGUAGE_TOOL_ENABLED?.toLowerCase() === 'true') {
logger.debug({}, 'Enable LanguageTool module')
Settings.languageToolURL = process.env.LANGUAGE_TOOL_URL ||
`http://${process.env.LANGUAGE_TOOL_HOST || 'languagetool'}:${process.env.LANGUAGE_TOOL_PORT || '8010'}`
const { default: LanguageToolRouter } = await import('./app/src/LanguageToolRouter.mjs')
LanguageToolModule = {
router: LanguageToolRouter,
}
}
export default LanguageToolModule
@@ -12,7 +12,7 @@ export default async function registerNewUserAndSendActivationEmail(userData) {
let user
try {
const password = crypto.randomBytes(32).toString('hex')
user = await UserRegistrationHandler.promises.registerNewUser({ ...userData, password })
user = await UserRegistrationHandler.promises.registerNewUser({ ...userData, password, analyticsId: crypto.randomUUID() })
} catch (error) {
if (error.message === 'EmailAlreadyRegistered') {
logger.debug({ email: userData.email }, 'user already registered')
@@ -96,9 +96,7 @@ async function exportUserProjectsToZip(userId, output) {
`Exporting ${allIds.length} projects for user ${userId} to ${output}`
)
const zipStream = await createZipStreamForMultipleProjectsAsync(allIds, false)
// NOTE: createZipStreamForMultipleProjects(projectIds, zipFromHistory, callback) has no
// historyId slot (unlike createZipStreamForProject), so only one argument besides the ids.
const zipStream = await createZipStreamForMultipleProjectsAsync(allIds)
zipStream.on('progress', progress => {
updateProgress(progress.entries.total, allIds.length)
@@ -134,7 +132,7 @@ async function exportSingleProject(projectId, output) {
console.log('Flushing project to MongoDB...')
await DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete(projectId)
console.log(`Exporting project ${projectId} to ${output}`)
const zipStream = await createZipStreamForProjectAsync(projectId, false, null)
const zipStream = await createZipStreamForProjectAsync(projectId)
await writeStreamToFileAtomically(zipStream, output)
console.log('Exported project to', output)
}
@@ -37,11 +37,8 @@ const TrackChangesController = {
async getAllRanges(req, res, next) {
try {
const { project_id } = req.params
// 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 ?? {} })))
const ranges = await DocumentUpdaterHandler.promises.getProjectRanges(project_id)
res.json(ranges)
} catch (err) {
next(err)
}
@@ -53,9 +50,6 @@ 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,7 +68,6 @@ async function refreshLinkedFile(
parentFolderId,
userId
) {
linkedFileData = _normalizeLegacyData(linkedFileData)
logger.debug(
{ projectId, userId, linkedFileData },
'refreshing Zotero linked file'
@@ -109,7 +108,7 @@ async function _getBibtex(linkedFileData) {
return await ZoteroApiClient.getLibraryBibtex(
userId,
linkedFileData.group_id, // == null for main library
linkedFileData.format
linkedFileData.format || 'bibtex'
)
} catch (err) {
@@ -130,24 +129,6 @@ 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',
-112
View File
@@ -1,112 +0,0 @@
/*
* Read a list of users from a file and suspend their accounts.
*
* Each line is either a user ID or an email address; the two can be mixed in
* one file. Emails are matched against any of a user's addresses, not just the
* primary one. An email that matches no account is reported as a failure and
* the rest of the file is still processed.
*
* Use the --reenable option to roll back account suspension.
*
* Usage: node scripts/suspend_users.mjs [--reenable] <filename>
*/
import fs from 'node:fs'
import { ObjectId } from '../app/src/infrastructure/mongodb.mjs'
import UserGetter from '../app/src/Features/User/UserGetter.mjs'
import UserUpdater from '../app/src/Features/User/UserUpdater.mjs'
import { promiseMapWithLimit } from '@overleaf/promise-utils'
const ASYNC_LIMIT = 10
const reenable = process.argv.includes('--reenable')
const action = reenable ? 're-enable' : 'suspend'
const updateUser = reenable
? UserUpdater.promises.unsuspendUser
: UserUpdater.promises.suspendUser
const processLogger = {
failed: [],
success: [],
printSummary: () => {
console.log(
{
success: processLogger.success,
failed: processLogger.failed,
},
`\nDONE. ${processLogger.success.length} successful. ${processLogger.failed.length} failed to ${action}.`
)
},
}
function _isEmail(identifier) {
return identifier.includes('@')
}
function _validateIdentifierList(identifiers) {
if (!Array.isArray(identifiers)) throw new Error('users is not an array')
identifiers.forEach(identifier => {
if (!ObjectId.isValid(identifier) && !_isEmail(identifier)) {
throw new Error(`not a valid user ID or email: ${identifier}`)
}
})
}
async function _resolveUserId(identifier) {
if (!_isEmail(identifier)) return identifier
const user = await UserGetter.promises.getUserByAnyEmail(identifier, {
_id: 1,
})
if (user == null) {
throw new Error(`no user found with email ${identifier}`)
}
return user._id
}
async function _handleUser(identifier) {
let userId
try {
userId = await _resolveUserId(identifier)
} catch (error) {
console.log(`Failed to resolve ${identifier}`, error)
processLogger.failed.push(identifier)
return
}
try {
await updateUser(userId, {
ip: '0.0.0.0',
info: { script: true },
})
} catch (error) {
console.log(`Failed to ${action} ${identifier}`, error)
processLogger.failed.push(identifier)
return
}
processLogger.success.push(identifier)
}
async function _loopUsers(identifiers) {
return promiseMapWithLimit(ASYNC_LIMIT, identifiers, _handleUser)
}
const fileName = process.argv.slice(2).find(arg => arg !== '--reenable')
if (!fileName) throw new Error('missing filename')
const usersFile = fs.readFileSync(fileName, 'utf8')
const identifiers = usersFile
.trim()
.split('\n')
.map(identifier => identifier.trim())
.filter(Boolean)
async function processUsers(identifiers) {
console.log('---Starting suspend_users script---')
_validateIdentifierList(identifiers)
console.log(`---Starting to ${action} ${identifiers.length} users---`)
await _loopUsers(identifiers)
processLogger.printSummary()
process.exit()
}
processUsers(identifiers)
-1
View File
@@ -56,6 +56,5 @@ export type ExposedSettings = {
linkedInInsightsPartnerId?: string
githubSyncEnabled: boolean
zoteroEnabled: boolean
languageToolAvailable: boolean
enablePandocConversions: boolean
}
-22
View File
@@ -1,22 +0,0 @@
. ./env.sh
docker history --no-trunc --format '{{.CreatedBy}}' ${OLD_VERSION} > history-old.txt
docker history --no-trunc --format '{{.CreatedBy}}' ${NEW_VERSION} > history-new.txt
diff history-old.txt history-new.txt > history-diff.txt
rm -rf old
rm -rf new
id=$(docker create ${OLD_VERSION})
mkdir -p old
docker export "$id" | tar -x -C old
docker rm "$id"
id=$(docker create ${NEW_VERSION})
mkdir -p new
docker export "$id" | tar -x -C new
docker rm "$id"
rm -rf delta
mkdir -p delta
rsync -a -c -m --compare-dest="$PWD/old" new/ delta/
find delta -mindepth 1 -type d -empty -delete
-2
View File
@@ -1,2 +0,0 @@
export OLD_VERSION="sharelatex/sharelatex:6.3.0"
export NEW_VERSION="sharelatex/sharelatex:6.3.1"
-101
View File
@@ -1,101 +0,0 @@
# 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
@@ -1,72 +0,0 @@
# 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
@@ -1,133 +0,0 @@
# 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
@@ -1,373 +0,0 @@
# 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
@@ -1,25 +0,0 @@
// 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)');
@@ -1,34 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,76 +0,0 @@
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
@@ -1,87 +0,0 @@
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;
}
@@ -1,76 +0,0 @@
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
@@ -1,222 +0,0 @@
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;
}
@@ -1,56 +0,0 @@
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('');
}
@@ -1,125 +0,0 @@
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
@@ -1,34 +0,0 @@
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>;
}
@@ -1,52 +0,0 @@
# 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
@@ -1,25 +0,0 @@
{
"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"
}
}
@@ -1,58 +0,0 @@
#!/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
@@ -1,211 +0,0 @@
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);
}
@@ -1,77 +0,0 @@
// 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
@@ -1,264 +0,0 @@
// 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>;
}
@@ -1,439 +0,0 @@
// 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
@@ -1,165 +0,0 @@
// 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
@@ -1,29 +0,0 @@
/* 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
@@ -1,204 +0,0 @@
// 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
@@ -1,647 +0,0 @@
// 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
@@ -1,59 +0,0 @@
#!/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"
@@ -1,303 +0,0 @@
// 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]]);
});
@@ -1,737 +0,0 @@
// 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
@@ -1,90 +0,0 @@
#!/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 ]
@@ -1 +0,0 @@
.env
@@ -1,155 +0,0 @@
# LanguageTool server (Docker Compose)
Standalone [LanguageTool](https://languagetool.org) grammar-check server used by
the Overleaf integration in `services/web/modules/languagetool`
(Overleaf CE fork repo, see the module's `README.md`).
- **Image:** `erikvl87/languagetool:latest` (community server fork of LanguageTool;
supports `level=default` and `level=picky` — other levels return HTTP 400)
- **Container name:** `languagetool` (overleaf reaches it by this hostname on the shared network)
- **Host port:** `127.0.0.1:32768` → container `8010` (pinned, stable across restarts)
- **Compose project name:** `languagetools_server_compose` (directory name)
## Layout
```
tools/languagetools_server_compose/
├── compose.yaml # top-level: `extends` the service in languagetool/compose.yaml
├── up.sh # docker compose up -d
├── down.sh # docker compose down
├── up_languagetool.sh # docker compose up -d languagetool
├── down_languagetool.sh # docker compose down languagetool
├── logs_languagetool.sh # docker compose logs -f languagetool
├── autoupdate_languagetool.sh # pull latest image, recreate container only if changed
├── lt-test.tex # test document: sentences with verified expected detections
└── languagetool/
├── compose.yaml # actual service definition
├── download_ngrams.sh # main n-gram set (de en es fr nl) → ngrams/
├── download_ngrams_untested.sh # untested set (he it ru zh) → ngrams/untested/
└── ngrams/ # n-gram data, mounted read-only into the container (gitignored)
```
## Quick start
```bash
cd tools/languagetools_server_compose
# 1. Populate n-gram data (one-time, ~45 GB disk; resumable, idempotent)
./languagetool/download_ngrams.sh
./languagetool/download_ngrams_untested.sh # optional: he/it/ru/zh
# 2. Start
./up.sh
# 3. Verify (container must be healthy)
docker ps --filter name=languagetool
curl -s http://127.0.0.1:32768/v2/languages | head -c 200
```
## N-gram data
LanguageTool loads per-language n-gram models from the directory given in
`langtool_languageModel` (here: `/ngrams` ← `languagetool/ngrams/`).
Every subdirectory named after a language code (e.g. `en/`, `de/`) is picked
up — currently present: `de en es fr nl` (main) and `he it ru zh` (untested).
| Set | Languages | Size (extracted) | Zip sources |
|---|---|---|---|
| main | de en es fr nl | ~24 GB | `ngrams-<lang>-YYYYMMDD.zip` (~1.7–9 GB each) |
| untested | he it ru zh | ~14 GB | `ngram-<lang>-YYYYMMDD.zip` (~0.2–2.3 GB each) |
- Downloads come from `https://languagetool.org/download/ngram-data/`
(untested set: `.../ngram-data/untested/`).
- Both scripts are idempotent: existing zips are skip-validated (`unzip -t`),
downloads are resumable (`curl -C -`). Re-run safely after an interrupted transfer.
- The downloaded zip archives stay in `ngrams/` (~21 GB total).
Safe to `rm ngrams/*.zip` once extraction succeeded, to reclaim that space.
- `an` (Aragonese) is **not** available on the server; it is kept commented
out in `download_ngrams_untested.sh` in case it appears later.
> **Pitfall (cost us a debugging session):** if `ngrams/` does not exist on
> the host, Docker creates an *empty* directory for the bind mount and the
> server runs **without any n-gram models** — spelling detection
> (`recieve`, `realy`, …) silently degrades while the API still answers.
> Verify with `ls languagetool/ngrams/en` and a check request that
> `MORFOLOGIK_RULE_EN_US` still fires.
## Service configuration (`languagetool/compose.yaml`)
| Setting | Value | Notes |
|---|---|---|
| `image` | `erikvl87/languagetool:latest` | community fork, HTTP REST API on 8010 |
| `Java_Xms` / `Java_Xmx` | `512m` / `2g` | JVM heap |
| `langtool_languageModel` | `/ngrams` | n-gram model directory (read-only bind mount) |
| `ports` | `127.0.0.1:32768:8010` | host port pinned — do not reintroduce the ephemeral `127.0.0.1::8010` form |
| `healthcheck` | `wget --spider http://localhost:8010/v2/languages` | 30s interval, 60s start period |
| `restart` | `unless-stopped` | |
## Connecting Overleaf
The matching Overleaf side (CE fork v6.3.1) exposes the feature through
`services/web/modules/languagetool/` — enabled via overleaf service env vars.
In this deployment they live in `/data_1/docker/compose/overleaf/compose.yaml`:
```yaml
env:
LANGUAGE_TOOL_ENABLED: "true"
LANGUAGE_TOOL_HOST: languagetool # resolves on the shared docker network
LANGUAGE_TOOL_PORT: "8010"
```
- The overleaf container reaches LT through the shared network
(`languagetools_server_compose_default`); from inside overleaf the host is
`languagetool:8010`, from the host it is `127.0.0.1:32768`.
- Overleaf checks `/v2/check` (POST, form-encoded `text`, `language`, `level`,
plus `disabledRules`); client-side defaults: **picky level on**,
spellcheck language = Overleaf default (`en-US`), and the checks are
stored as **project settings** (shared with collaborators).
- If `LANGUAGE_TOOL_ENABLED` is false or the proxy can't reach LT, Overleaf
simply does not offer the feature (no client errors).
## Verification
```bash
# basic
curl -s -X POST http://127.0.0.1:32768/v2/check \
-d 'language=en-US' -d 'text=You can recieve a bonus.' -d 'level=picky'
# picky vs default (passive voice is picky-only on this build)
curl -s -X POST http://127.0.0.1:32768/v2/check \
--data-urlencode 'language=en-US' --data-urlencode 'text=The error was detected by the test suite.' \
-d 'level=default' # → no passive-voice match
curl -s -X POST http://127.0.0.1:32768/v2/check \
--data-urlencode 'language=en-US' --data-urlencode 'text=The error was detected by the test suite.' \
-d 'level=picky' # → PASSIVE_VOICE_SIMPLE
```
Editor test document: **`lt-test.tex`** in this directory.
Verified expectations (2026-10-26, with the exact `disabledRules` the proxy sends):
| Picky mode | Underlines |
|---|---|
| **on** (Overleaf default) | **27** — section A (2 passive-voice) + section B (23) |
| **off** | **25** — section A disappears, section B stays |
See the file header for the per-sentence rule annotations.
## Update / rollback
```bash
./autoupdate_languagetool.sh # pull erikvl87/languagetool:latest, recreate only if image changed
docker compose down && docker compose up -d # manual restart
docker compose logs -f languagetool # tail logs
```
Troubleshooting:
- **Overleaf can't reach LT** — from the overleaf container:
`wget -O- http://languagetool:8010/v2/languages`. If DNS fails, the overleaf
service is not attached to the `languagetools_server_compose_default` network
(see the two `networks` blocks above; both must carry the same name).
- **Port changed after a recreate** — always use the pinned form
`"127.0.0.1:32768:8010"`; the old `"127.0.0.1::8010"` form picks a random
host port per container creation.
- **No spelling detections** — n-gram mount empty, see the pitfall above.
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SERVICE_NAME="languagetool"
IMAGE_NAME="erikvl87/languagetool: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
@@ -1,7 +0,0 @@
services:
languagetool:
extends:
file: ./languagetool/compose.yaml
service: languagetool
@@ -1,2 +0,0 @@
docker compose down
@@ -1,3 +0,0 @@
docker compose down languagetool
@@ -1 +0,0 @@
ngrams
@@ -1,21 +0,0 @@
services:
languagetool:
image: erikvl87/languagetool:latest
container_name: languagetool
hostname: languagetool
restart: unless-stopped
environment:
Java_Xms: 512m
Java_Xmx: 2g
langtool_languageModel: /ngrams
ports:
- "127.0.0.1:32768:8010"
volumes:
- ./ngrams:/ngrams:ro
healthcheck:
test: ["CMD-SHELL", "wget --quiet --spider --timeout=5 http://localhost:8010/v2/languages 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
@@ -1,71 +0,0 @@
#!/usr/bin/env bash
# Download & extract LanguageTool n-gram data (main set: de en es fr nl)
# into ngrams/ next to this script.
#
# Sources: https://languagetool.org/download/ngram-data/
# ngrams-de-20150819.zip (~1.7 GB)
# ngrams-en-20150817.zip (~9.0 GB)
# ngrams-es-20150915.zip (~1.7 GB)
# ngrams-fr-20150913.zip (~1.8 GB)
# ngrams-nl-20181229.zip (~1.2 GB)
#
# Usage: ./download_ngrams.sh
# Re-running is safe: already-downloaded & valid zips are skipped (download is
# resumable via curl -C).
set -euo pipefail
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NGRAMS_DIR="$BASE/ngrams"
BASE_URL="https://languagetool.org/download/ngram-data"
# lang -> zip filename (verified against server directory listing)
declare -a FILES=(
"ngrams-de-20150819.zip"
"ngrams-en-20150817.zip"
"ngrams-es-20150915.zip"
"ngrams-fr-20150913.zip"
"ngrams-nl-20181229.zip"
)
command -v curl >/dev/null || { echo "ERROR: curl not found" >&2; exit 1; }
command -v unzip >/dev/null || { echo "ERROR: unzip not found" >&2; exit 1; }
mkdir -p "$NGRAMS_DIR"
echo "Target dir: $NGRAMS_DIR"
failed=0
for zip in "${FILES[@]}"; do
url="$BASE_URL/$zip"
dest="$NGRAMS_DIR/$zip"
echo "=================================================================="
echo "$zip"
if [ -f "$dest" ] && unzip -t "$dest" >/dev/null 2>&1; then
echo " [skip] already downloaded and valid: $zip"
else
echo " [dl] $url"
if ! curl --fail --location --retry 3 --retry-delay 5 -C - \
--connect-timeout 30 --progress-bar -o "$dest" "$url"; then
echo " [FAIL] download failed for $url (partial file kept for resume: $dest)" >&2
rm -f "$dest" # 404/error body would corrupt the name for future runs
failed=1
continue
fi
fi
echo " [extract] -> $NGRAMS_DIR"
if ! unzip -o -q "$dest" -d "$NGRAMS_DIR"; then
echo " [FAIL] extraction failed for $zip" >&2
failed=1
fi
done
echo "=================================================================="
if [ "$failed" -ne 0 ]; then
echo "Finished with ERRORS — re-run to resume failed downloads." >&2
exit 1
fi
echo "All done. Contents:"
ls -lh "$NGRAMS_DIR"
@@ -1,75 +0,0 @@
#!/usr/bin/env bash
# Download & extract LanguageTool n-gram data (UNTESTED set) into ngrams/untested/
# next to this script.
#
# Sources: https://languagetool.org/download/ngram-data/untested/
# ngram-he-20150916.zip (~225 MB)
# ngram-it-20150915.zip (~1.1 GB)
# ngram-ru-20150914.zip (~2.3 GB)
# ngram-zh-20150916.zip (~1.7 GB)
#
# NOTE: "an" (Aragonese) was requested but does NOT exist on the server
# (checked ngram-data/, untested/, temp/, archive/ — only he/it/ru/zh are
# offered). Kept commented out so it can be enabled if it appears later.
#
# Usage: ./download_ngrams_untested.sh
# Re-running is safe: already-downloaded & valid zips are skipped (download is
# resumable via curl -C).
set -euo pipefail
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NGRAMS_DIR="$BASE/ngrams"
UNTESTED_DIR="$NGRAMS_DIR"
BASE_URL="https://languagetool.org/download/ngram-data/untested"
# zip filenames (verified against server directory listing)
declare -a FILES=(
"ngram-he-20150916.zip"
"ngram-it-20150915.zip"
"ngram-ru-20150914.zip"
"ngram-zh-20150916.zip"
# "ngram-an-*.zip (404 — not available on the server as of 2025/2026)"
)
command -v curl >/dev/null || { echo "ERROR: curl not found" >&2; exit 1; }
command -v unzip >/dev/null || { echo "ERROR: unzip not found" >&2; exit 1; }
mkdir -p "$UNTESTED_DIR"
echo "Target dir: $UNTESTED_DIR"
failed=0
for zip in "${FILES[@]}"; do
url="$BASE_URL/$zip"
dest="$UNTESTED_DIR/$zip"
echo "=================================================================="
echo "$zip"
if [ -f "$dest" ] && unzip -t "$dest" >/dev/null 2>&1; then
echo " [skip] already downloaded and valid: $zip"
else
echo " [dl] $url"
if ! curl --fail --location --retry 3 --retry-delay 5 -C - \
--connect-timeout 30 --progress-bar -o "$dest" "$url"; then
echo " [FAIL] download failed for $url (partial file kept for resume: $dest)" >&2
rm -f "$dest" # 404/error body would corrupt the name for future runs
failed=1
continue
fi
fi
echo " [extract] -> $UNTESTED_DIR"
if ! unzip -o -q "$dest" -d "$UNTESTED_DIR"; then
echo " [FAIL] extraction failed for $zip" >&2
failed=1
fi
done
echo "=================================================================="
if [ "$failed" -ne 0 ]; then
echo "Finished with ERRORS — re-run to resume failed downloads." >&2
exit 1
fi
echo "All done. Contents:"
ls -lh "$UNTESTED_DIR"
@@ -1,2 +0,0 @@
docker compose logs -f languagetool
@@ -1,102 +0,0 @@
% LanguageTool level test — ext-ce-v6.3.1 + erikvl87/languagetool
%
% Section A: expected to be flagged ONLY in Picky mode (level=picky).
% Section B: expected to be flagged in BOTH modes (default and picky).
%
% All sentences below were verified live against the running server
% (2026-10-26), with the exact disabledRules the Overleaf proxy sends.
%
% Usage: open in Overleaf, wait ~2 s after the last edit,
% hover a wavy underline for the message + one-click replacements.
% Toggle Settings → Spelling and Language → LanguageTool → "Picky mode"
% off/on and compare: section A marks should appear/disappear.
\documentclass{article}
\begin{document}
\section*{A: Picky-only (missed at default level)}
% PASSIVE_VOICE_SIMPLE — passive voice
The experiment was conducted by our group.
% PASSIVE_VOICE_SIMPLE — passive voice
The error was detected by the test suite.
\section*{B: Both modes (default and picky)}
% EN_A_VS_AN — wrong article
An sequence off independent variables.
% MORFOLOGIK_RULE_EN_US — spelling mistake
You can recieve a bonus for this.
% MORFOLOGIK_RULE_EN_US — spelling mistake
This is a realy interesting result.
% MORFOLOGIK_RULE_EN_US — spelling mistake
He shoulda told us about the crash.
% AGREEMENT_SENT_START — agreement error
The results shows a significant difference.
% BEEN_PART_AGREEMENT — possible agreement error
A new paper was publish last week.
% THERE_S_MANY — possible agreement error
There is three reasons for this choice.
% IT_IS — possible typo (missing apostrophe)
Its time to finish the manuscript.
% AFFECT_EFFECT — commonly confused word
This should have an affect on the outcome.
% LOOSE_LOSE — possible typo
You will loose the key if you leave it here.
% MODAL_OF — possible typo
You should of checked the configuration.
% MODAL_OF — possible typo
It might of happened last night.
% MODAL_OF — possible typo
We could of fixed the bug yesterday.
% INFORMATIONS — nonstandard word
We need more informations about this topic.
% EN_A_VS_AN — wrong article (uncountable)
He gave me a advice yesterday morning.
% ENGLISH_WORD_REPEAT_RULE — word repetition
The the method was tested twice.
% ADMIT_ENJOY_VB — use 'ing'
I look forward to hear from you again.
% TOO_TO — possible typo
I want to too participate in this.
% AGREEMENT_SENT_START — agreement error
Each of the students have passed the exam.
% AGREEMENT_SENT_START — agreement error
The dog which barks is friendly.
% RB_RB_COMMA — intensifier comma
We have a very very good result here.
% FREE_GIFT — redundant phrase
This is a free gift for all of you.
% ADMIT_ENJOY_VB — use 'ing'
The manual recommends to restart the server.
% ALLOW_TO — use a gerund
The tool allows to configure the output.
% ALLOW_TO — use a gerund
The server requires to restart after the change.
\end{document}
-2
View File
@@ -1,2 +0,0 @@
docker compose up -d
@@ -1,2 +0,0 @@
docker compose up -d languagetool
@@ -1,77 +0,0 @@
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
@@ -1,2 +0,0 @@
.env
backup_seaweedfs_online

Some files were not shown because too many files have changed in this diff Show More