Compare commits

..
251 changed files with 7156 additions and 9468 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
-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 -36
View File
@@ -14,30 +14,23 @@ export OVERLEAF_TAG ?= sharelatex/sharelatex:$(BRANCH_NAME_TAG_SAFE)-$(MONOREPO_
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 build-pandoc
clean:
-docker rmi --force $(OVERLEAF_BASE_TAG) $(OVERLEAF_TAG) $(OVERLEAF_PANDOC_TAG) $(OVERLEAF_PDFTOCAIRO) $(OVERLEAF_PNG2PDF)
-docker rmi --force $(OVERLEAF_BASE_TAG) $(OVERLEAF_TAG) $(OVERLEAF_PANDOC_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 +45,7 @@ build-base:
--tag $(OVERLEAF_BASE_BRANCH) \
$(MONOREPO_ROOT)
build-community:
cp .dockerignore $(MONOREPO_ROOT)
docker build \
@@ -80,30 +74,6 @@ build-pandoc:
--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)
@@ -111,8 +81,6 @@ push:
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 +102,5 @@ shellcheck_fix:
done
.PHONY: all \
build-base build-community build-pandoc build-pdftocairo build-png2pdf\
build-base build-community build-pandoc\
shellcheck shellcheck_fix
-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,
}
@@ -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
@@ -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,
}
@@ -425,9 +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,
wakaTimeEnabled: Settings.wakatime?.enabled !== false,
wakaTimeDebugLogging: !!Settings.wakatime?.debugLogging,
languageToolAvailable: !!Settings.languageToolURL,
enablePandocConversions: Settings.enablePandocConversions,
mixpanelLabsToken:
Settings.labs?.enable && Settings.analytics?.mixpanel?.labsToken,
+2 -42
View File
@@ -1034,16 +1034,7 @@ module.exports = {
),
],
contactUsModal: [],
sourceEditorExtensions: [
Path.resolve(
__dirname,
'../modules/languagetool/frontend/js/languagetool-extension'
),
Path.resolve(
__dirname,
'../modules/wakatime/frontend/js/editor/wakatime-source-editor-extension.ts'
),
],
sourceEditorExtensions: [],
sourceEditorVisualExtensions: [],
sourceEditorComponents: [],
pdfLogEntryHeaderActionComponents: [],
@@ -1087,10 +1078,6 @@ module.exports = {
__dirname,
'../modules/github-sync/frontend/js/components/github-sync-widget.tsx'
),
Path.resolve(
__dirname,
'../modules/wakatime/frontend/js/components/wakatime-widget.tsx'
),
],
referenceLinkingWidgets: [
Path.resolve(
@@ -1185,19 +1172,10 @@ module.exports = {
__dirname,
'../modules/zotero/frontend/js/components/zotero-integration-card.tsx'
),
Path.resolve(
__dirname,
'../modules/wakatime/frontend/js/components/wakatime-integration-card.tsx'
),
],
referenceSearchSetting: [],
settingsModalEditorTabSections: [],
settingsModalSpellcheckSections: [
Path.resolve(
__dirname,
'../modules/languagetool/frontend/js/languagetool-section'
),
],
settingsModalSpellcheckSections: [],
editorFloatingMenuActions: [],
errorLogsComponents: [],
referenceIndices: [
@@ -1220,7 +1198,6 @@ module.exports = {
'symbol-palette',
'reference-picker',
'track-changes',
'languagetool',
'authentication/ldap',
'authentication/saml',
'authentication/oidc',
@@ -1230,24 +1207,7 @@ module.exports = {
'git-bridge',
'github-sync',
'zotero',
'wakatime',
],
wakatime: {
// global on/off (widget + routes); default: on (matches PR 249 behavior)
enabled: process.env.WAKATIME_ENABLED !== 'false',
debugLogging: process.env.WAKATIME_DEBUG_LOGGING === 'true',
// Deployment-managed identity (bundled Wakapi, see
// tools/wakapi_server_compose): NO per-user registration, NO API keys —
// the logged-in user's email is asserted to Wakapi via a trusted
// header (WAKAPI_TRUSTED_HEADER_AUTH on the Wakapi side) and Wakapi
// creates the user automatically on first use. A manually linked key
// (wakatime.com or any other WakaTime server) always takes priority.
sharedIdentity: process.env.WAKATIME_SHARED_IDENTITY === 'true',
sharedIdentityApiUrl:
process.env.WAKATIME_SHARED_IDENTITY_API_URL ||
'http://wakapi:3000/api/compat/wakatime/v1',
sharedIdentityHeader: process.env.WAKATIME_SHARED_IDENTITY_HEADER || 'Remote-User',
},
viewIncludes: {},
csp: {
@@ -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<
@@ -12,8 +12,7 @@ const integrationPanelComponents = allIntegrationPanelComponents.filter(
({ path }) =>
(getMeta('ol-gitBridgeEnabled') || !path.includes('git-bridge')) &&
(getMeta('ol-ExposedSettings').githubSyncEnabled || !path.includes('github-sync')) &&
(getMeta('ol-ExposedSettings').zoteroEnabled || !path.includes('zotero')) &&
(getMeta('ol-ExposedSettings').wakaTimeEnabled || !path.includes('wakatime'))
(getMeta('ol-ExposedSettings').zoteroEnabled || !path.includes('zotero'))
)
export default function IntegrationsPanel() {
@@ -20,12 +20,10 @@ const availableLangFeedbackLinkingWidgets = importOverleafModules(
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
const githubSyncEnabled = getMeta('ol-ExposedSettings').githubSyncEnabled
const zoteroEnabled = getMeta('ol-ExposedSettings').zoteroEnabled
const wakaTimeEnabled = getMeta('ol-ExposedSettings').wakaTimeEnabled
const availableIntegrationLinkingWidgets = allAvailableIntegrationLinkingWidgets.filter(
({ path }) =>
(githubSyncEnabled || !path.includes('github-sync')) &&
(wakaTimeEnabled || !path.includes('wakatime'))
(githubSyncEnabled || !path.includes('github-sync'))
)
function LinkingSection() {
@@ -57,7 +55,7 @@ function LinkingSection() {
}[]
const renderSyncSection =
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled || wakaTimeEnabled
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled
const allIntegrationLinkingWidgets = integrationLinkingWidgets.concat(
oauth2ServerComponents
@@ -513,7 +513,7 @@ class HistoryOTAdapter {
export const trackChangesAnnotation = Annotation.define()
export const chooseOrigin = (transaction: Transaction) => {
const chooseOrigin = (transaction: Transaction) => {
if (transaction.annotation(Transaction.remote)) {
return 'remote'
}
@@ -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),
@@ -1,27 +0,0 @@
function WakaTimeLogo({ size = 40 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 340 340"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M170 20C87.156 20 20 87.156 20 170C20 252.844 87.156 320 170 320C252.844 320 320 252.844 320 170C320 87.156 252.844 20 170 20V20V20Z"
stroke="black"
strokeWidth="40"
/>
<path
d="M190.183 213.541C188.74 215.443 186.576 216.667 184.151 216.667C183.913 216.667 183.677 216.651 183.443 216.627C183.042 216.579 182.823 216.545 182.606 216.497C182.337 216.434 182.137 216.375 181.94 216.308C181.561 216.176 181.392 216.109 181.228 216.035C180.843 215.849 180.707 215.778 180.572 215.701C180.205 215.478 180.109 215.412 180.014 215.345C179.856 215.233 179.698 215.117 179.547 214.992C179.251 214.746 179.147 214.65 179.044 214.552C178.731 214.241 178.531 214.018 178.341 213.785C177.982 213.331 177.69 212.888 177.438 212.415L168.6 198.214L159.766 212.415C158.38 214.939 155.874 216.667 152.995 216.667C150.106 216.667 147.588 214.926 146.243 212.346L107.607 156.061C106.337 154.529 105.556 152.499 105.556 150.258C105.556 145.514 109.043 141.665 113.344 141.665C116.127 141.665 118.564 143.282 119.942 145.708L152.555 193.9L161.735 178.952C163.058 176.288 165.626 174.478 168.575 174.478C171.273 174.478 173.652 175.996 175.049 178.298L184.517 193.839L235.684 120.583C237.075 118.226 239.475 116.667 242.213 116.667C246.514 116.667 250 120.514 250 125.258C250 127.332 249.337 129.232 248.23 130.715L190.183 213.541Z"
fill="black"
stroke="black"
strokeWidth="10"
/>
</svg>
)
}
export default WakaTimeLogo
@@ -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
@@ -1,18 +0,0 @@
import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs"
const { Schema } = mongoose
const { ObjectId } = Schema
export const WakaTimeUserCredentialsSchema = new Schema(
{
userId: { type: ObjectId, ref: 'User', required: true, unique: true },
apiUrl: { type: String, required: true },
apiKeyEncrypted: { type: Schema.Types.Mixed, required: true },
},
{ collection: 'wakaTimeUserCredentials', minimize: false }
)
export const WakaTimeUserCredentials = mongoose.model(
'WakaTimeUserCredentials',
WakaTimeUserCredentialsSchema,
)
@@ -1,81 +0,0 @@
import logger from '@overleaf/logger'
import fs from 'node:fs'
import crypto from 'node:crypto'
import Path from 'node:path'
import AccessTokenEncryptorClass from '@overleaf/access-token-encryptor'
const TOKEN_CIPHER_FILE = '/var/lib/overleaf/data/.wakatime-token-cipher.json'
const TOKEN_CIPHER_LABEL = 'OL_CEP_wakatime-v3'
let encryptorInstance = null
/**
* Get or create a stable encryptor data that persists across container
* recreations. Priority:
* 1. WAKATIME_TOKEN_CIPHER_PASSWORD / TOKEN_CIPHER_PASSWORD env var
* 2. JSON file with encryptor data in the persistent volume (/var/lib/overleaf/data/)
* — auto-generated on first use, survives container rebuilds
*/
function _getEncryptorData() {
const cipherPassword = process.env.WAKATIME_TOKEN_CIPHER_PASSWORD || process.env.TOKEN_CIPHER_PASSWORD
const cipherLabel = process.env.WAKATIME_TOKEN_CIPHER_LABEL ||
process.env.TOKEN_CIPHER_LABEL ||
TOKEN_CIPHER_LABEL
if (cipherPassword) {
return {
cipherLabel,
cipherPasswords: {
[cipherLabel]: cipherPassword
}
}
}
// cipherPassword is not set, use file
const cipherFile = process.env.WAKATIME_TOKEN_CIPHER_FILE ||
process.env.TOKEN_CIPHER_FILE ||
TOKEN_CIPHER_FILE
try {
return JSON.parse(fs.readFileSync(cipherFile, 'utf8'))
} catch (err) {
if (err.code === 'ENOENT') {
logger.info({ cipherFile }, 'Creating new cipherFile')
} else {
logger.error({ err, cipherFile }, 'Bad cipherFile')
throw err
}
}
// File doesn't exist yet — generate one
try {
const encryptorData = {
cipherLabel,
cipherPasswords: {
[cipherLabel]: crypto.randomBytes(32).toString('base64')
},
}
const dir = Path.dirname(cipherFile)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(cipherFile, JSON.stringify(encryptorData, null, 2), { mode: 0o600 })
return encryptorData
} catch (err) {
logger.error({ err, cipherFile }, 'Cannot create cipherFile')
throw err
}
}
function _getEncryptor() {
if (!encryptorInstance) {
const encryptorData = _getEncryptorData()
encryptorInstance = new AccessTokenEncryptorClass(encryptorData)
}
return encryptorInstance
}
export const AccessTokenEncryptor = {
async encryptJson(json) {
return await _getEncryptor().promises.encryptJson(json)
},
async decryptToJson(encryptedJson) {
return await _getEncryptor().promises.decryptToJson(encryptedJson)
},
}
@@ -1,53 +0,0 @@
import logger from '@overleaf/logger'
import Mongo from '../../../../app/src/Features/Helpers/Mongo.mjs'
import { WakaTimeUserCredentials } from '../models/wakaTimeUserCredentials.mjs'
import { AccessTokenEncryptor } from './AccessTokenEncryptorHelper.mjs'
const { normalizeQuery } = Mongo
/**
* Decrypt stored WakaTime credentials for a user.
* Returns { apiUrl, apiKey } or null if not linked.
*/
async function getCredentials(userId) {
const record = await WakaTimeUserCredentials.findOne(
normalizeQuery({ userId })
).exec()
if (!record?.apiKeyEncrypted) return null
try {
const apiKey = await AccessTokenEncryptor.decryptToJson(
record.apiKeyEncrypted
)
return { apiUrl: record.apiUrl, apiKey }
} catch (err) {
logger.error(
{ userId, err },
'failed to decrypt WakaTime credentials, treating as not connected'
)
return null
}
}
/**
* Link a WakaTime (or Wakapi-compatible) account.
*/
async function storeCredentials(userId, apiUrl, apiKey) {
const apiKeyEncrypted = await AccessTokenEncryptor.encryptJson(apiKey)
await WakaTimeUserCredentials.findOneAndUpdate(
normalizeQuery({ userId }),
{ $set: { apiUrl, apiKeyEncrypted } },
{ upsert: true }
).exec()
}
async function removeCredentials(userId) {
await WakaTimeUserCredentials.deleteOne(normalizeQuery({ userId })).exec()
}
export default {
getCredentials,
storeCredentials,
removeCredentials,
}
@@ -1,287 +0,0 @@
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
import Settings from '@overleaf/settings'
import {
fetchNothing,
fetchJson,
RequestFailedError,
} from '@overleaf/fetch-utils'
import {
NotFoundError,
TooManyRequestsError,
ServiceNotConfiguredError,
ForbiddenError,
} from '../../../../app/src/Features/Errors/Errors.js'
import TokenManager from './TokenManager.mjs'
const DEFAULT_API_URL = 'https://wakatime.com/api/v1'
const REQUEST_TIMEOUT_MS = 15 * 1000
// ---------------------------------------------------------------------------
// Deployment-managed identity (tools/wakapi_server_compose, trusted-header)
// ---------------------------------------------------------------------------
// When this Overleaf deployment runs its own Wakapi server, users do NOT
// register or paste keys: the logged-in Overleaf user's identity is asserted
// to Wakapi via a trusted HTTP header (WAKAPI_TRUSTED_HEADER_AUTH on the
// Wakapi side), and Wakapi auto-creates the matching user on first use.
// A per-user stored key (manual wakatime.com flow) always takes priority,
// so users who prefer their own wakatime.com account can still link one.
// Configure via services/web/config/settings.defaults.js (wakatime block).
const W = Settings.wakatime || {}
function sharedIdentityActive() {
return W.sharedIdentity === true
}
function sharedAuthHeaders(email) {
return {
'Content-Type': 'application/json',
// Same UA rules as buildHeaders (wakapi user-agent parser).
'User-Agent': 'wakatime/1.0.0 (linux) overleaf/1.0 overleaf-wakatime/1.0',
[W.sharedIdentityHeader || 'Remote-User']: email,
}
}
/**
* Resolve how to authenticate on behalf of a user, WITHOUT storing anything:
* 1. stored credentials (manual wakatime.com / custom key) — always wins
* 2. shared deployment identity (logged-in user's email via trusted header)
* 3. nothing (caller decides the error)
*/
async function resolveAuth(userId, email) {
const credentials = await TokenManager.getCredentials(userId)
if (credentials) {
return {
kind: 'key',
url: normalizeApiUrl(credentials.apiUrl),
headers: buildHeaders(credentials.apiKey),
}
}
if (sharedIdentityActive() && email) {
return {
kind: 'shared',
url: normalizeApiUrl(W.sharedIdentityApiUrl),
headers: sharedAuthHeaders(email),
}
}
return { kind: 'none', url: null, headers: null }
}
function normalizeApiUrl(apiUrl) {
return (apiUrl || DEFAULT_API_URL).replace(/\/+$/, '')
}
function buildHeaders(apiKey, opts = {}) {
return {
Authorization: `Basic ${Buffer.from(apiKey).toString('base64')}`,
'Content-Type': 'application/json',
// Must start with "wakatime/" and have a recognized OS token in
// parens next, or WakaTime/Wakapi's user-agent parser bails out and
// reports the editor as "unknown" — see wakapi/utils/http.go
// ParseUserAgent, which requires this exact shape.
'User-Agent': 'wakatime/1.0.0 (linux) overleaf/1.0 overleaf-wakatime/1.0',
...opts,
}
}
/**
* Verify a (apiUrl, apiKey) pair works, without persisting anything.
* Uses GET /users/current, which every WakaTime-compatible server
* (wakatime.com and Wakapi) implements for auth verification.
*/
async function verifyCredentials(apiUrl, apiKey) {
const url = normalizeApiUrl(apiUrl)
try {
await fetchJson(`${url}/users/current`, {
headers: buildHeaders(apiKey),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
return true
} catch (err) {
normalizeApiError(err, 'verifyCredentials')
}
}
/**
* Checks whether the user has linked (and can still authenticate with)
* a WakaTime-compatible account — or is covered by the deployment-managed
* shared identity (this box's own Wakapi server, trusted-header auth).
*/
async function getConnectionStatus(userId, email) {
const auth = await resolveAuth(userId, email)
if (auth.kind === 'none') return { connected: false }
try {
await fetchJson(`${auth.url}/users/current`, {
headers: auth.headers,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
return {
connected: true,
apiUrl: auth.url,
shared: auth.kind === 'shared' || undefined,
}
} catch (err) {
logger.warn({ userId, kind: auth.kind, err }, 'WakaTime connection check failed')
return { connected: false, apiUrl: auth.url, error: true }
}
}
/**
* Send a single heartbeat on behalf of a user.
* https://wakatime.com/developers#heartbeats
*/
async function sendHeartbeat(userId, email, heartbeat) {
const auth = await resolveAuth(userId, email)
if (auth.kind === 'none') {
throw new ServiceNotConfiguredError({
message: sharedIdentityActive()
? 'WakaTime not configured for this user'
: 'WakaTime credentials missing',
info: { userId, status: 400 },
})
}
try {
await fetchNothing(`${auth.url}/users/current/heartbeats`, {
method: 'POST',
headers: auth.headers,
body: JSON.stringify(heartbeat),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
} catch (err) {
normalizeApiError(err, 'sendHeartbeat')
}
}
/**
* Send a batch of heartbeats on behalf of a user.
* https://wakatime.com/developers#heartbeats (bulk)
*/
async function sendHeartbeatsBulk(userId, email, heartbeats) {
const auth = await resolveAuth(userId, email)
if (auth.kind === 'none') {
throw new ServiceNotConfiguredError({
message: sharedIdentityActive()
? 'WakaTime not configured for this user'
: 'WakaTime credentials missing',
info: { userId, status: 400 },
})
}
try {
// WakaTime's /heartbeats.bulk endpoint accepts ONE heartbeat object per
// request (same contract on wakapi: the .bulk route uses the single
// heartbeat handler — batch semantics are throughput, not array body).
// So a "bulk" send is a loop over the .bulk endpoint: sequential, at
// most MAX_BULK_HEARTBEATS (50) per call, each 2xx-counting.
for (const heartbeat of heartbeats) {
await fetchNothing(`${auth.url}/users/current/heartbeats.bulk`, {
method: 'POST',
headers: auth.headers,
body: JSON.stringify(heartbeat),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
}
} catch (err) {
normalizeApiError(err, 'sendHeartbeatsBulk')
}
}
/**
* Get total tracked time for a single project, over the last `range` days
* (default 7), by requesting a summaries range and summing the per-project
* breakdown. Works against both wakatime.com and Wakapi.
*/
async function getProjectSummary(userId, email, projectName, range = 7) {
const auth = await resolveAuth(userId, email)
if (auth.kind === 'none') return null
const end = new Date()
const start = new Date(end.getTime() - range * 24 * 60 * 60 * 1000)
const qs = new URLSearchParams({
start: start.toISOString().slice(0, 10),
end: end.toISOString().slice(0, 10),
project: projectName,
})
try {
const summary = await fetchJson(
`${auth.url}/users/current/summaries?${qs.toString()}`,
{
headers: auth.headers,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}
)
const totalSeconds = (summary.data || []).reduce((sum, day) => {
const projectEntry = (day.projects || []).find(
p => p.name === projectName
)
return sum + (projectEntry?.total_seconds || 0)
}, 0)
return { totalSeconds, rangeDays: range }
} catch (err) {
normalizeApiError(err, 'getProjectSummary')
}
}
async function unlinkAccount(userId) {
await TokenManager.removeCredentials(userId)
}
function normalizeApiError(err, operation) {
logger.error({ operation }, 'WakaTime API request failed')
if (err.name === 'AbortError') {
throw new OError('WakaTime request timed out', {
operation,
status: 504,
}).withCause(err)
}
if (!(err instanceof RequestFailedError)) {
throw new OError('Something wrong with WakaTime request', {
operation,
status: 500,
}).withCause(err)
}
const status = err.response?.status || 500
if (status === 401 || status === 403) {
throw new ForbiddenError({
message: 'Access denied',
info: { operation, status },
}).withCause(err)
}
if (status === 404) {
throw new NotFoundError({
message: 'Not found',
info: { operation, status },
}).withCause(err)
}
if (status === 429) {
throw new TooManyRequestsError({
message: 'Rate limit exceeded',
info: { operation, status },
}).withCause(err)
}
throw new OError('WakaTime request error', { operation, status }).withCause(
err
)
}
export default {
verifyCredentials,
getConnectionStatus,
sendHeartbeat,
sendHeartbeatsBulk,
getProjectSummary,
unlinkAccount,
}
@@ -1,166 +0,0 @@
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
import { Project } from '../../../../app/src/models/Project.mjs'
import WakaTimeApiClient from './WakaTimeApiClient.mjs'
import TokenManager from './TokenManager.mjs'
const MAX_BULK_HEARTBEATS = 50
// Deployment-managed identity: assert the LOGGED-IN user's email to the
// bundled Wakapi via trusted-header auth (never a client-supplied value).
function sessionEmail(req) {
const user = SessionManager.getSessionUser(req.session)
return user?.email || null
}
function sendError(res, err, operation) {
const info = OError.getFullInfo(err)
const status = info?.status || 500
logger.error(OError.getFullStack(err))
logger.error({ info, operation }, 'WakaTime request failed')
res.status(status).json({ message: err.message })
}
/**
* GET /user/wakatime/status
*/
async function getConnectionStatus(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = sessionEmail(req)
try {
const status = await WakaTimeApiClient.getConnectionStatus(userId, email)
res.json(status)
} catch (err) {
sendError(res, err, 'getConnectionStatus')
}
}
/**
* PUT /user/wakatime
* body: { apiUrl, apiKey }
* Verifies the credentials against the target server before storing them.
*/
async function link(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const { apiUrl, apiKey } = req.body || {}
if (!apiKey || typeof apiKey !== 'string') {
return res.status(400).json({ message: 'apiKey is required' })
}
try {
await WakaTimeApiClient.verifyCredentials(apiUrl, apiKey)
await TokenManager.storeCredentials(userId, apiUrl, apiKey)
res.sendStatus(200)
} catch (err) {
sendError(res, err, 'link')
}
}
/**
* DELETE /user/wakatime
*/
async function unlink(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
try {
await WakaTimeApiClient.unlinkAccount(userId)
res.sendStatus(200)
} catch (err) {
sendError(res, err, 'unlink')
}
}
/**
* POST /project/:project_id/wakatime/heartbeat
* body: a single WakaTime heartbeat payload (entity, type, time, ...)
* Forwards the heartbeat to the user's configured WakaTime/Wakapi server,
* filling in the project name server-side so the client never needs to
* know (or spoof) it.
*/
async function heartbeat(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = sessionEmail(req)
const { project_id: projectId } = req.params
try {
const project = await Project.findById(projectId, 'name').exec()
if (!project) return res.sendStatus(404)
await WakaTimeApiClient.sendHeartbeat(userId, email, {
...req.body,
project: project.name,
})
res.sendStatus(202)
} catch (err) {
sendError(res, err, 'heartbeat')
}
}
/**
* POST /project/:project_id/wakatime/heartbeats/bulk
* body: an array of WakaTime heartbeat payloads
*/
async function heartbeatsBulk(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = sessionEmail(req)
const { project_id: projectId } = req.params
const heartbeats = Array.isArray(req.body) ? req.body : []
if (heartbeats.length === 0) return res.sendStatus(204)
if (heartbeats.length > MAX_BULK_HEARTBEATS) {
return res.status(400).json({ message: 'too many heartbeats in one batch' })
}
try {
const project = await Project.findById(projectId, 'name').exec()
if (!project) return res.sendStatus(404)
await WakaTimeApiClient.sendHeartbeatsBulk(
userId,
email,
heartbeats.map(h => ({ ...h, project: project.name }))
)
res.sendStatus(202)
} catch (err) {
sendError(res, err, 'heartbeatsBulk')
}
}
/**
* GET /project/:project_id/wakatime/summary
* Returns total tracked time (in seconds) for this project over the last
* `range` days (query param, default 7).
*/
async function summary(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = sessionEmail(req)
const { project_id: projectId } = req.params
const range = Math.min(parseInt(req.query.range, 10) || 7, 90)
try {
const project = await Project.findById(projectId, 'name').exec()
if (!project) return res.sendStatus(404)
const result = await WakaTimeApiClient.getProjectSummary(
userId,
email,
project.name,
range
)
if (!result) return res.json({ connected: false })
res.json({ connected: true, ...result })
} catch (err) {
sendError(res, err, 'summary')
}
}
export default {
getConnectionStatus,
link,
unlink,
heartbeat,
heartbeatsBulk,
summary,
}
@@ -1,52 +0,0 @@
import logger from '@overleaf/logger'
import WakaTimeController from './WakaTimeController.mjs'
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
export default {
apply(webRouter) {
logger.debug({}, 'Init wakatime router')
// account-level linking (Account Settings)
webRouter.get(
'/user/wakatime/status',
AuthenticationController.requireLogin(),
WakaTimeController.getConnectionStatus
)
webRouter.put(
'/user/wakatime',
AuthenticationController.requireLogin(),
WakaTimeController.link
)
webRouter.delete(
'/user/wakatime',
AuthenticationController.requireLogin(),
WakaTimeController.unlink
)
// per-project heartbeats, sent by the editor's activity tracker
webRouter.post(
'/project/:project_id/wakatime/heartbeat',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanReadProject,
WakaTimeController.heartbeat
)
webRouter.post(
'/project/:project_id/wakatime/heartbeats/bulk',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanReadProject,
WakaTimeController.heartbeatsBulk
)
// total time tracked for a project, shown in the Integrations panel
webRouter.get(
'/project/:project_id/wakatime/summary',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanReadProject,
WakaTimeController.summary
)
},
}
@@ -1,111 +0,0 @@
import { FormEvent, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { putJSON } from '@/infrastructure/fetch-json'
import OLButton from '@/shared/components/ol/ol-button'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import Notification from '@/shared/components/notification'
const DEFAULT_API_URL = 'https://wakatime.com/api/v1'
export default function WakaTimeConnectModal({
show,
handleHide,
onLinked,
}: {
show: boolean
handleHide: () => void
onLinked: () => void
}) {
const { t } = useTranslation()
const [apiUrl, setApiUrl] = useState(DEFAULT_API_URL)
const [apiKey, setApiKey] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
setIsSaving(true)
setError(null)
try {
await putJSON('/user/wakatime', {
body: { apiUrl: apiUrl.trim() || DEFAULT_API_URL, apiKey: apiKey.trim() },
})
setApiKey('')
onLinked()
handleHide()
} catch (err: any) {
setError(
err?.data?.message ||
err?.message ||
t('generic_something_went_wrong')
)
} finally {
setIsSaving(false)
}
}
return (
<OLModal show={show} onHide={handleHide} backdrop="static">
<form onSubmit={handleSubmit}>
<OLModalHeader closeButton>
<OLModalTitle>{t('connect_wakatime', { defaultValue: 'Connect WakaTime' })}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p className="small">
{t('wakatime_connect_description', {
defaultValue:
'Works with wakatime.com or a self-hosted Wakapi server. Create an API key on your account’s settings page and paste it below.',
})}
</p>
<OLFormGroup controlId="wakatime-api-url">
<OLFormLabel>{t('api_url', { defaultValue: 'API URL' })}</OLFormLabel>
<OLFormControl
type="text"
value={apiUrl}
placeholder={DEFAULT_API_URL}
onChange={e => setApiUrl(e.target.value)}
/>
</OLFormGroup>
<OLFormGroup controlId="wakatime-api-key">
<OLFormLabel>{t('api_key', { defaultValue: 'API Key' })}</OLFormLabel>
<OLFormControl
type="password"
value={apiKey}
required
autoComplete="off"
onChange={e => setApiKey(e.target.value)}
/>
</OLFormGroup>
{error && <Notification type="error" content={error} />}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={handleHide} disabled={isSaving}>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
type="submit"
disabled={isSaving || !apiKey.trim()}
>
{isSaving ? t('saving') : t('connect', { defaultValue: 'Connect' })}
</OLButton>
</OLModalFooter>
</form>
</OLModal>
)
}
@@ -1,81 +0,0 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
import { getJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import WakaTimeLogo from '@/shared/svgs/wakatime-logo'
import IntegrationCard from '@/features/integrations-panel/integration-card'
import WakaTimeConnectModal from './wakatime-connect-modal'
type SummaryResponse =
| { connected: false }
| { connected: true; totalSeconds: number; rangeDays: number }
function formatDuration(totalSeconds: number) {
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
if (hours === 0 && minutes === 0) return '0m'
if (hours === 0) return `${minutes}m`
return `${hours}h ${minutes}m`
}
function WakaTimeCard() {
const { t } = useTranslation()
const { project } = useProjectContext()
const projectId = project?._id
const [summary, setSummary] = useState<SummaryResponse | null>(null)
const [showConnectModal, setShowConnectModal] = useState(false)
const loadSummary = () => {
if (!projectId) return
getJSON<SummaryResponse>(`/project/${projectId}/wakatime/summary`)
.then(setSummary)
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}
useEffect(loadSummary, [projectId])
if (!projectId) return null
const isConnected = summary?.connected === true
const description = summary?.connected
? t('wakatime_time_this_week', {
defaultValue: '__duration__ tracked (last __days__ days)',
duration: formatDuration(summary.totalSeconds),
days: summary.rangeDays,
})
: t('wakatime_connect_to_track_time', {
defaultValue: 'Connect WakaTime to track time on this project',
})
return (
<>
<IntegrationCard
title="WakaTime"
description={description}
icon={<WakaTimeLogo size={32} />}
showPaywallBadge={false}
onClick={
isConnected
? undefined
: () => setShowConnectModal(true)
}
href={
isConnected
? '/user/settings#project-sync'
: undefined
}
/>
<WakaTimeConnectModal
show={showConnectModal}
handleHide={() => setShowConnectModal(false)}
onLinked={loadSummary}
/>
</>
)
}
export default WakaTimeCard
@@ -1,136 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getJSON, deleteJSON } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import OLButton from '@/shared/components/ol/ol-button'
import Notification from '@/shared/components/notification'
import WakaTimeLogo from '@/shared/svgs/wakatime-logo'
import WakaTimeConnectModal from './wakatime-connect-modal'
type WakaTimeStatus = { connected: boolean; apiUrl?: string; error?: boolean }
/**
* WakaTime account linking widget for the Account Settings page.
* Unlike Zotero/GitHub, there's no OAuth handshake: the user pastes an
* API key (from wakatime.com or a self-hosted Wakapi instance) directly.
*
* Registered via overleafModuleImports.integrationLinkingWidgets.
*/
export const WakaTimeWidget = function WakaTimeWidget() {
const { t } = useTranslation()
const {
isLoading: isCheckingConn,
isError: isErrorConnCheck,
runAsync: runAsyncConnCheck,
data: status,
setData: setStatus,
} = useAsync<WakaTimeStatus>()
const {
isLoading: isUnlinking,
isError: isErrorUnlink,
runAsync: runAsyncUnlink,
} = useAsync<void>()
const [showConnectModal, setShowConnectModal] = useState(false)
const handleConnCheck = useCallback(() => {
runAsyncConnCheck(getJSON('/user/wakatime/status')).catch(err =>
debugConsole.error(err?.data?.message || err?.message || err)
)
}, [runAsyncConnCheck])
useEffect(() => {
handleConnCheck()
}, [handleConnCheck])
const handleUnlink = useCallback(() => {
runAsyncUnlink(deleteJSON('/user/wakatime'))
.then(() => setStatus({ connected: false }))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}, [runAsyncUnlink, setStatus])
if (isCheckingConn) {
return (
<div className="settings-widget-container">
<div>
<WakaTimeLogo size={40} />
</div>
<div className="description-container">
<div className="title-row">
<h4>WakaTime</h4>
</div>
<p className="small">
<span>{t('loading')}…</span>
</p>
</div>
</div>
)
}
const isConnected = !!status?.connected
return (
<>
<div className="settings-widget-container">
<div>
<WakaTimeLogo size={40} />
</div>
<div className="description-container">
<div className="title-row">
<h4 id="wakatime-link">WakaTime</h4>
</div>
<p className="small">
{t('wakatime_widget_description', {
defaultValue:
'Track coding time in your projects with WakaTime or a self-hosted Wakapi server.',
})}
</p>
{isConnected && status?.apiUrl && (
<p className="small text-muted">{status.apiUrl}</p>
)}
{isErrorConnCheck && (
<Notification
type="error"
content={t('problem_checking_connection_with_provider', {
provider: 'WakaTime',
})}
/>
)}
{isErrorUnlink && (
<Notification type="error" content={t('generic_something_went_wrong')} />
)}
</div>
<div>
{isConnected ? (
<OLButton
variant="danger-ghost"
onClick={handleUnlink}
disabled={isUnlinking}
>
{isUnlinking ? t('unlinking') : t('unlink')}
</OLButton>
) : (
<OLButton variant="secondary" onClick={() => setShowConnectModal(true)}>
{t('link')}
</OLButton>
)}
</div>
</div>
<WakaTimeConnectModal
show={showConnectModal}
handleHide={() => setShowConnectModal(false)}
onLinked={handleConnCheck}
/>
</>
)
}
@@ -1,93 +0,0 @@
import { ViewPlugin } from '@codemirror/view'
import type { DocumentContainer } from '@/features/ide-react/editor/document-container'
import type { EditorFacade, ChangeDescription } from '@/features/source-editor/extensions/realtime'
import getMeta from '@/utils/meta'
import {
notifyFileOpened,
notifyLocalEdit,
notifyFileSaved,
wakaTimeLog,
} from './wakatime-tracker'
/**
* Registered via overleafModuleImports.sourceEditorExtensions.
*
* Sends WakaTime heartbeats for genuinely local editor activity, without
* touching the CodeMirror/ShareJS realtime plumbing directly. It relies on
* the `origin` already computed by the realtime extension's `chooseOrigin`:
* `origin === undefined` on a docChanged transaction means a real local
* keystroke, as opposed to `'remote'` (a collaborator's edit applied via
* OT), `'undo'` or `'reject'`.
*/
export const extension = (options: Record<string, any>) => {
if (!getMeta('ol-ExposedSettings')?.wakaTimeEnabled) {
wakaTimeLog('extension disabled: ol-ExposedSettings.wakaTimeEnabled is falsy')
return []
}
const currentDoc = options.currentDoc?.currentDoc as
| DocumentContainer
| undefined
const docName = options.docName as string
const projectId = getMeta('ol-project_id') as string
if (!currentDoc || !docName || !projectId) {
wakaTimeLog('extension bailing out, missing data:', {
hasCurrentDoc: !!currentDoc,
docName,
projectId,
})
return []
}
wakaTimeLog('extension attached for', docName, 'in project', projectId)
notifyFileOpened(projectId, docName)
const handleChange = (_editor: EditorFacade, change: ChangeDescription) => {
if (change.origin === undefined) {
notifyLocalEdit(projectId, docName)
}
}
let recentlyEditedLocally = false
const markLocalEdit = (_editor: EditorFacade, change: ChangeDescription) => {
if (change.origin === undefined) {
recentlyEditedLocally = true
}
}
// document-container.ts dispatches this window event (not a `trigger` on
// `currentDoc` itself) whenever the ShareJS doc for *any* open file has
// drained its pending ops — filter to our own doc_id.
const handleSaved = (event: Event) => {
const { id } = (event as CustomEvent<{ id: string }>).detail || {}
if (id === currentDoc.doc_id && recentlyEditedLocally) {
notifyFileSaved(projectId, docName)
recentlyEditedLocally = false
}
}
const attach = () => {
wakaTimeLog('cm6 change listeners attached for', docName)
currentDoc.cm6?.on('change', handleChange)
currentDoc.cm6?.on('change', markLocalEdit)
}
if (currentDoc.cm6) {
attach()
} else {
wakaTimeLog('cm6 not ready yet, waiting for cm6:attach for', docName)
currentDoc.on('cm6:attach', attach)
}
window.addEventListener('doc:saved', handleSaved)
return ViewPlugin.define(() => ({
destroy() {
currentDoc.off('cm6:attach', attach)
window.removeEventListener('doc:saved', handleSaved)
currentDoc.cm6?.off('change', handleChange)
currentDoc.cm6?.off('change', markLocalEdit)
},
}))
}
@@ -1,109 +0,0 @@
import { postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import getMeta from '@/utils/meta'
const wakaTimeDebugLogging = !!getMeta('ol-ExposedSettings')?.wakaTimeDebugLogging
export function wakaTimeLog(...args: unknown[]) {
if (wakaTimeDebugLogging) {
// eslint-disable-next-line no-console
console.log('[wakatime]', ...args)
}
}
// WakaTime clients only send a heartbeat if more than this long has passed
// since the last one for the same file, or the file was switched/saved.
const HEARTBEAT_THROTTLE_MS = 2 * 60 * 1000
// WakaTime/Wakapi identify the sending editor by parsing this string as
// `wakatime/version (os) editorName/editorVersion editorName-wakatime/pluginVersion`
// (the same convention every official wakatime-cli-based plugin uses).
// Must start with "wakatime/" and have a recognized OS token in parens
// next, or the parser bails out and reports the editor as "unknown".
const USER_AGENT = 'wakatime/1.0.0 (linux) overleaf/1.0 overleaf-wakatime/1.0'
const EXTENSION_TO_LANGUAGE: Record<string, string> = {
tex: 'LaTeX',
ltx: 'LaTeX',
sty: 'TeX',
cls: 'TeX',
bib: 'BibTeX',
bbx: 'TeX',
cbx: 'TeX',
md: 'Markdown',
rnw: 'R',
py: 'Python',
r: 'R',
json: 'JSON',
yml: 'YAML',
yaml: 'YAML',
}
function languageForFile(fileName: string): string | undefined {
const match = /\.([^./]+)$/.exec(fileName)
const ext = match?.[1]?.toLowerCase()
return ext ? EXTENSION_TO_LANGUAGE[ext] : undefined
}
const lastHeartbeatAt = new Map<string, number>()
function buildHeartbeat(docName: string, isWrite: boolean) {
const heartbeat: Record<string, unknown> = {
entity: docName,
type: 'file',
time: Date.now() / 1000,
is_write: isWrite,
user_agent: USER_AGENT,
branch: 'main',
}
const language = languageForFile(docName)
if (language) heartbeat.language = language
return heartbeat
}
function send(projectId: string, docName: string, isWrite: boolean) {
const heartbeat = buildHeartbeat(docName, isWrite)
wakaTimeLog('sending heartbeat', { projectId, docName, isWrite, heartbeat })
postJSON(`/project/${projectId}/wakatime/heartbeat`, {
body: heartbeat,
}).then(
res => wakaTimeLog('heartbeat sent OK', { docName, res }),
err => {
// WakaTime not linked, unreachable, etc — never surface this to the user
wakaTimeLog('heartbeat FAILED', {
docName,
status: err?.response?.status,
message: err?.message || err,
data: err?.data,
})
debugConsole.log('wakatime heartbeat not sent', err?.message || err)
}
)
lastHeartbeatAt.set(docName, Date.now())
}
/** Called whenever the user opens/switches to a file in the editor. */
export function notifyFileOpened(projectId: string, docName: string) {
wakaTimeLog('notifyFileOpened', docName)
send(projectId, docName, false)
}
/**
* Called on every local (non-collaborator, non-undo/reject) CodeMirror edit.
* Throttled to at most one heartbeat per file per HEARTBEAT_THROTTLE_MS.
*/
export function notifyLocalEdit(projectId: string, docName: string) {
const last = lastHeartbeatAt.get(docName) || 0
if (Date.now() - last < HEARTBEAT_THROTTLE_MS) {
wakaTimeLog('notifyLocalEdit throttled', docName)
return
}
wakaTimeLog('notifyLocalEdit', docName)
send(projectId, docName, false)
}
/** Called when the document has just been saved (acked by ShareJS). */
export function notifyFileSaved(projectId: string, docName: string) {
wakaTimeLog('notifyFileSaved', docName)
send(projectId, docName, true)
}
-13
View File
@@ -1,13 +0,0 @@
import Settings from '@overleaf/settings'
import WakaTimeRouter from './app/src/WakaTimeRouter.mjs'
Settings.wakatime = {
enabled: process.env.WAKATIME_INTEGRATION_ENABLED !== 'false',
debugLogging: process.env.WAKATIME_DEBUG_LOGGING === 'true',
}
const WakaTimeModule = Settings.wakatime.enabled
? { router: WakaTimeRouter }
: {}
export default WakaTimeModule
-2
View File
@@ -56,7 +56,5 @@ export type ExposedSettings = {
linkedInInsightsPartnerId?: string
githubSyncEnabled: boolean
zoteroEnabled: boolean
wakaTimeEnabled: boolean
languageToolAvailable: boolean
enablePandocConversions: boolean
}
+101
View File
@@ -0,0 +1,101 @@
# Credits & Attribution
This project wraps two upstream bodies of work. Neither is redistributed in
unattributed form; the specifics of what comes from where are listed below.
## 1. grammar-llm — functional core
**Repo:** <https://github.com/whiteh4cker-tr/grammar-llm>
**License:** MIT — Copyright (c) 2025 whiteh4cker-tr
**What this project takes:**
- **The whole correction pipeline** — the pure engine under
`grammar-llm/src/electron/core/`, vendored byte-for-byte into this repo's
`core/` (7 files: `apply.ts`, `clean.ts`, `correction.ts`, `diff.ts`,
`reconstruct.ts`, `sentences.ts`, `types.ts`): sentence splitting, model
output cleaning, word-diff/word-fix extraction, suggestion reconstruction.
Provenance (source commit `ceff65a` + per-file SHA-256) is recorded in
`core/.sync-info`; refresh with `scripts/sync-core.sh`.
- **LLM call semantics** — system prompt, stop sequences, sampling defaults
(temperature 0.7 / topK 40 / topP 0.95 / minP 0.01), and
`node-llama-cpp` session usage mirror
`grammar-llm/src/electron/llamaService.ts` so container output stays
byte-identical to the desktop app for a given model.
- **Model** — GRMR-V3-G4B (fine-tuned by the grammar-llm project; GGUF
weights are fetched from Hugging Face at
`icecubetr/GRMR-V3-G4B-GGUF`, Q4_K_M by default).
- **Model downloader usage** — the download flow in `src/download-model.ts`
follows `grammar-llm`'s use of `createModelDownloader` from
`modelManager.ts`.
> grammar-llm is itself an Electron/React desktop migration of the original
> GrammarLLM Python app (`whiteh4cker-tr/grammar-llm`, `python` branch); the
> engine core it ships is what this container is built on.
Full MIT license text (retained with the vendored `core/` files):
```
MIT License
Copyright (c) 2025 whiteh4cker-tr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## 2. LanguageTool — API compatibility reference
**Project:** <https://languagetool.org> (source:
<https://github.com/languagetool-org/languagetool>)
**License:** GNU Lesser General Public License (LGPL) — see `COPYING.txt` in
the LanguageTool source tree
**What this project takes:** *the wire protocol, not the code.*
- **The v2 REST API contract** this container emulates: `GET /v2/languages`,
`POST /v2/check`, and the response envelope (`software`, `warnings`,
`language`, `matches[]`, `sentenceRanges`) — schema taken from
`languagetool-core/src/main/java/org/languagetool/tools/RuleMatchesAsJsonSerializer.java`,
`languagetool-server/.../ApiV2.java` and `TextChecker.java`, so that
existing LanguageTool clients (Overleaf, editors, CI linters) work
unmodified.
- **Error/status semantics** (`400` missing text, `413` too long, `503`
degraded, `warnings.incompleteResults`) follow LanguageTool's behavior so
clients that special-case it keep working.
- **Drop-in target packaging** — port `8010`, `--allow-origin '*'` behavior
and the "run-a-side" admin story follow the widely deployed
`erikvl87/languagetool` / `Erikvl87/docker-languagetool` images.
No LanguageTool source code is included in this repository; compatibility is
at the HTTP/JSON level only, so the LGPL obligations of the Java source do
not attach to this container. (If you embed this as a *library* rather than a
service, review the LGPL implications yourself.)
## 3. Runtime dependencies
| Dependency | License | Role |
|---|---|---|
| [node-llama-cpp](https://github.com/withcatai/node-llama-cpp) (Gilad S. / withcatai), v3.19.1 | MIT | GGUF inference engine (`llama.cpp` bindings) + `createModelDownloader` used for model fetches |
| [esbuild](https://github.com/evanw/esbuild) | MIT | bundling `src/` → `dist/` |
| Node.js LTS (≥ 20) | MIT (nodejs org) | runtime |
## 4. Integration context
Serves the Overleaf LanguageTool module in this repo
(`services/web/modules/languagetool/`, based on upstream
[yu-i-i/overleaf-cep#179](https://github.com/yu-i-i/overleaf-cep/pull/179))
as a one-for-one replacement for a `languagetool` service container.
+72
View File
@@ -0,0 +1,72 @@
# syntax=docker/dockerfile:1
#
# grammar-llm container — LanguageTool-compatible (v2 API) grammar checker.
# Drop-in replacement for erikvl87/languagetool (docker-languagetool):
# same port (8010), same /v2/languages + /v2/check wire API, backed by the
# grammar-llm engine (GRMR-V3-G4B fine-tuned LLM via node-llama-cpp).
#
# Build (from this project directory):
# docker build -t grammar-llm .
#
# Run (model auto-downloads on first start unless pre-provisioned):
# docker run -p 8010:8010 --name grammar-llm -v grammar-llm-models:/models grammar-llm
#
# The model (~2.5 GB for Q4_K_M) is NOT baked into the image: put it in the
# /models volume (file name GRMR-V3-G4B-Q4_K_M.gguf by default) or let the
# container download it from Hugging Face on first start. See README.md.
ARG NODE_TAG=24
# ---------- build stage ----------
FROM node:${NODE_TAG}-bookworm-slim AS build
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /build
# Install all deps (needed for bundling: esbuild; runtime: node-llama-cpp).
# The @node-llama-cpp/* prebuilt platform package for THIS build platform is
# what makes the bundle loadable; we re-install runtime deps for the TARGET
# platform in the next stage (docker build --platform handles cross-arch).
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY build.mjs ./
COPY core/ core/
COPY src/ src/
RUN node build.mjs \
&& npm prune --omit=dev
# Sanity: the bundle must resolve its only external (node-llama-cpp).
RUN node --input-type=module -e "import('node-llama-cpp').then(() => process.exit(0), (e) => { console.error(e.message); process.exit(1); })"
# ---------- runtime stage ----------
FROM node:${NODE_TAG}-bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive \
NODE_ENV=production \
PORT=8010 \
GRAMMAR_LLM_MODEL_DIR=/models \
GRAMMAR_LLM_PRELOAD=true
WORKDIR /app
# Install runtime-only deps (node-llama-cpp + prebuilt native lib) for the
# target platform of the image.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund \
&& node -e "import('node-llama-cpp').then(() => process.exit(0)).catch(() => process.exit(1))"
COPY --from=build /build/dist ./dist
COPY start.sh ./
RUN chmod +x start.sh \
&& useradd -m -d /home/grammarllm -s /nonexistent grammarllm 2>/dev/null || true \
&& mkdir -p /models \
&& chown -R grammarllm:grammarllm /models /app
USER grammarllm
VOLUME ["/models"]
EXPOSE 8010
# Model download can take a while on first start → generous start period.
HEALTHCHECK --interval=30s --timeout=10s --start-period=1800s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 8010) + '/v2/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
ENTRYPOINT ["./start.sh"]
+133
View File
@@ -0,0 +1,133 @@
# grammar-llm container — development & ops tasks
#
# Everything maps to the documented npm scripts / docs; pick whichever you
# like. Run `make help` (the default) for the list.
SHELL := /bin/bash
NODE ?= node
DOCKER ?= docker
NODE_TAG ?= 24
IMAGE ?= grammar-llm
TAG ?= latest
PORT ?= 8010
CONTAINER_NAME := glm-run
MODEL_VOLUME := glm-models
MODEL_DIR ?= models
MODEL_FILE ?= GRMR-V3-G4B-Q4_K_M.gguf
MODEL_URL ?= https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf
# BASE for the smoke script (server to smoke-test against)
BASE ?= http://127.0.0.1:8010
.PHONY: help
help:
@echo "grammar-llm container — make targets"
@echo
@echo " Local dev"
@echo " install npm install (node-llama-cpp + esbuild)"
@echo " build bundle src/ -> dist/ (esbuild)"
@echo " test build + node --test test/ (no model needed)"
@echo " start run the server locally (needs model; see download-model)"
@echo " download-model fetch GRMR-V3-G4B GGUF into ./$(MODEL_DIR)/ (local server)"
@echo " sync-core re-vendor core/ from grammar-llm (scripts/sync-core.sh)"
@echo " smoke BASE=<url> e2e smoke against a RUNNING server (default $(BASE))"
@echo
@echo " Docker"
@echo " docker-build docker build -t $(IMAGE):$(TAG) ."
@echo " docker-smoke docker-build + run on :$(PORT) + smoke against it"
@echo " docker-run start container on :$(PORT) (model volume, detached)"
@echo " docker-stop stop + rm the container (volume kept)"
@echo " docker-clobber docker-stop + delete the model volume"
@echo
@echo " Misc"
@echo " all build the Docker image (compiles inside the node base image — no local Node needed)"
@echo " docker-test run the unit tests inside a node base-image container (no local Node needed)"
@echo " logs tail the running container's logs"
@echo " clean rm -rf dist node_modules (keep core/, models)"
.PHONY: install
install:
npm install
.PHONY: ensure-deps
ensure-deps:
@if [ ! -d node_modules ]; then \
echo "node_modules missing — running npm install…"; \
npm install; \
fi
.PHONY: build
build: ensure-deps
$(NODE) build.mjs
.PHONY: test
test: ensure-deps
$(NODE) build.mjs --quiet && $(NODE) --test test/*.test.mjs
.PHONY: start
start: ensure-deps build
GRAMMAR_LLM_MODEL_DIR=$(MODEL_DIR) PORT=$${PORT:-8010} $(NODE) dist/server.mjs
.PHONY: download-model
download-model: ensure-deps build
mkdir -p $(MODEL_DIR)
$(NODE) dist/download-model.mjs --url "$(MODEL_URL)" --out "$(MODEL_DIR)/$(MODEL_FILE)"
.PHONY: sync-core
sync-core:
bash scripts/sync-core.sh $(GRAMMAR_LLM_SRC)
.PHONY: smoke
smoke:
BASE=$(BASE) bash test/smoke.sh
# --- docker ---------------------------------------------------------------
.PHONY: docker-build
docker-build:
$(DOCKER) build -t $(IMAGE):$(TAG) .
.PHONY: docker-test
docker-test:
@echo "running the test suite inside the $(NODE_TAG:24=24)-bookworm-slim base image (no local Node needed)"
$(DOCKER) run --rm -v "$(PWD):/src:ro" -w /app node:$(NODE_TAG)-bookworm-slim sh -c \
'cp -r /src/. /app && npm ci --no-audit --no-fund && node build.mjs --quiet && node --test test/*.test.mjs'
.PHONY: docker-smoke
docker-smoke: docker-build
$(DOCKER) rm -f $(CONTAINER_NAME) 2>/dev/null || true
$(DOCKER) run -d --name $(CONTAINER_NAME) -p $(PORT):$(PORT) -v $(MODEL_VOLUME):/models $(IMAGE):$(TAG)
@echo "waiting for model load…"
@for i in $$(seq 1 120); do \
[ "$$($(DOCKER) logs $(CONTAINER_NAME) 2>&1 | grep -c 'model loaded and ready')" -ge 1 ] && break; \
sleep 5; \
done
BASE=http://127.0.0.1:$(PORT) bash test/smoke.sh
@echo "container $(CONTAINER_NAME) left running — stop with: make docker-stop"
.PHONY: docker-run
docker-run:
$(DOCKER) rm -f $(CONTAINER_NAME) 2>/dev/null || true
$(DOCKER) run -d --name $(CONTAINER_NAME) -p $(PORT):$(PORT) -v $(MODEL_VOLUME):/models $(IMAGE):$(TAG)
@echo "$(CONTAINER_NAME) is up on :$(PORT) — check with: make smoke BASE=http://127.0.0.1:$(PORT)"
.PHONY: docker-stop
docker-stop:
$(DOCKER) stop $(CONTAINER_NAME) 2>/dev/null || true
$(DOCKER) rm $(CONTAINER_NAME) 2>/dev/null || true
.PHONY: docker-clobber
docker-clobber: docker-stop
$(DOCKER) volume rm $(MODEL_VOLUME)
.PHONY: logs
logs:
$(DOCKER) logs -f $(CONTAINER_NAME)
.PHONY: clean
clean:
rm -rf dist node_modules
.PHONY: all
all: docker-build
@echo "all done — image: $(IMAGE):$(TAG) (compiled inside the node base image; no local Node needed)"
+373
View File
@@ -0,0 +1,373 @@
# grammar-llm container
A **drop-in replacement for the LanguageTool container**
([docker-languagetool](https://github.com/Erikvl87/docker-languagetool),
`erikvl87/languagetool`) for grammar checking — same port, same wire API —
powered by the **grammar-llm** engine: a fine-tuned local LLM
(`GRMR-V3-G4B`, [whiteh4cker-tr/grammar-llm](https://github.com/whiteh4cker-tr/grammar-llm))
running through [`node-llama-cpp`](https://github.com/withcatai/node-llama-cpp).
The intent: one admin can run the classic `languagetool` container, another
can run this container — and the rest of the stack (e.g. the Overleaf
LanguageTool module) points at whichever one is up, without code changes.
```
┌────────────┐ LanguageTool v2 API (port 8010) ┌───────────────────────────┐
│ Overleaf │ ───────────────────────────────────▶ │ grammar-llm container │
│ (or any │ ◀─────────────────────────────────── │ ┌─────────────────────┐ │
│ LT client) │ { software, matches[] … } JSON │ │ HTTP server (node) │ │
└────────────┘ │ │ grammar-llm core* │ │
│ │ node-llama-cpp + │ │
│ │ GRMR-V3-G4B model │ │
│ └─────────────────────┘ │
└───────────────────────────┘
* vendored from /data_1/compare/grammar-llm/src/electron/core (see scripts/sync-core.sh)
```
## Quickstart
```sh
# from this directory
docker build -t grammar-llm .
docker run -d --name grammar-llm -p 8010:8010 -v grammar-llm-models:/models grammar-llm
# or with compose
docker compose up -d
```
First start downloads the model (~2.4 GB) into the `/models` volume from
Hugging Face (`icecubetr/GRMR-V3-G4B-GGUF`, Q4_K_M). Subsequent starts use the
cached file. The server answers immediately; `/v2/health` reports
`state: loading/ready` and `/v2/check` returns `503` until the model is
loaded — the same "silent linter" behavior Overleaf already has for timeouts.
Probe it like LanguageTool:
```sh
curl -s http://localhost:8010/v2/languages | jq '.[0]'
curl -s -X POST http://localhost:8010/v2/check \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'language=auto' \
--data-urlencode 'level=picky' \
--data-urlencode 'text=I has two cats. They is very good.' | jq '.matches'
```
Use it as the Overleaf LT backend (see `services/web/modules/languagetool`):
```sh
# env for the overleaf web service — unchanged from the languagetool setup,
# just point at this container:
LANGUAGE_TOOL_ENABLED=true
LANGUAGE_TOOL_HOST=grammar-llm # or 127.0.0.1 on the host
LANGUAGE_TOOL_PORT=8010
# …or explicitly:
# LANGUAGE_TOOL_URL=http://grammar-llm:8010
```
## API (LanguageTool v2 subset)
| Method & path | LanguageTool equivalent | Notes |
|--------------------|-------------------------|-------|
| `GET /v2/languages` | `GET /v2/languages` | English variants only (model is English-only) |
| `POST /v2/check` | `POST /v2/check` | form-urlencoded **or** JSON body |
| `GET /v2/version` | version info | container/version + engine state |
| `GET /v2/health` | — (not an LT endpoint) | model state, counter; used by Docker HEALTHCHECK |
| `GET /` | — | JSON info payload (endpoints, software) |
### `POST /v2/check` parameters
| Param | Status | Notes |
|-------|--------|-------|
| `text` | **used** | plain text to check |
| `data` | **used** | JSON `{"text": "…"}` or `{"annotation": [{text|markup}, …]}` — Overleaf sends `annotation` (LaTeX markup = "ignore this text"); markup is skipped and offsets stay aligned with the editor document |
| `language` | **used** | English codes (`en`, `en-US`, `en-GB`, …) and `auto`. Non-English: by default → `200` with empty matches (linter silent; admin can set `GRAMMAR_LLM_UNSUPPORTED_LANGUAGE=error` for HTTP 400 instead) |
| `level` (`default`/`picky`) | **used** | **Picky mode for the LLM** (see below). `picky` → stricter prompt (style + word choice + phrasing on top of base grammar). `default` → base prompt only (byte-identical to the desktop app). Always forwarded verbatim to the real LanguageTool box when one is configured. |
| `disabledRules` | forwarded / ignored | Forwarded to the real LanguageTool box (understands LT rule ids); ignored by the LLM (it never produces them) |
| `enabled`, `disabled`, `motherTongue`, `replacedText`, … | accepted, ignored | tolerance for other LT clients |
### Response shape
Full LanguageTool envelope (schema reference:
`org.languagetool.tools.RuleMatchesAsJsonSerializer` in the `languagetool` source):
```jsonc
{
"software": { "name": "LanguageTool", "version": "6.8", "apiVersion": 1, "premium": false, "…": "…" },
"warnings": { "incompleteResults": false }, // true + reason if the time budget cut the check short
"language": { "name": "English (US)", "code": "en-US", "detectedLanguage": { … } },
"matches": [
{
"message": "Shouldn't this be 'the'?",
"shortMessage": "the",
"replacements": [ { "value": "the" } ], // Overleaf shows the first 5 as apply-buttons
"offset": 24, // absolute into the submitted document
"length": 3,
"context": { "text": "… I has two …", "offset": 19, "length": 3 },
"type": { "typeName": "grammar" },
"rule": {
"id": "GRAMMAR_LLM",
"description": "Grammar, spelling, or style issue detected by grammar-llm (GRMR-V3-G4B)",
"issueType": "Grammar",
"category": { "id": "GRAMMAR", "name": "Grammar" }, // deliberately NOT 'TYPOS' (see below)
"tags": ["LLM", "GRMR-V3-G4B"]
},
"ignoreForIncompleteSentence": false,
"contextForSureMatch": -1
}
],
"sentenceRanges": [[0, 16], [17, 33]]
}
```
Notes on the mapping:
- **Word-level fixes** (replacement / deletion from the core's `wordFixes`)
become one match each. **Pure insertions** (zero-width core fixes) are
anchored on the neighboring token — e.g. the core's `slow → slowly` +
insert-`,` becomes two matches that re-compose exactly to `slowly, …`.
- **Quoted-word artifact unwrapping**: GRMR models sometimes emit the
corrected word wrapped in quotes (`goed → "good"`). The desktop app shows
that in its review UI; a LanguageTool client would apply it verbatim and
inject literal quote characters into the document — so the adapter strips
one pair of matching surrounding quotes from single-word replacements.
(Vendor core is untouched; this happens in `src/languagetool.ts`.)
- **Sentence-level rewrites** without word fixes become one match over the
changed span.
- `category.id` is **`GRAMMAR`**, not `TYPOS`: Overleaf's editor filters out
`TYPOS` matches while the Hunspell spellchecker is active — this way
grammar-llm suggestions are never silently filtered, while both tools stay
usable together.
- Matches never span LaTeX `markup` segments: the core pipeline runs per
plain-text run (markup is invisible to the model, as in LanguageTool).
- Errors mirror LanguageTool: `400` missing `text`/`data`, `413` text too
long, `503` model not ready / server busy, `500` unexpected.
## Model
| What | Where |
|------|-------|
| Default model | `GRMR-V3-G4B-Q4_K_M.gguf` (2.4 GB) from `https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/` |
| Stored in | `/models` (container volume) |
| Higher quality | `GRMR-V3-G4B-Q8_0.gguf` (set `GRAMMAR_LLM_MODEL_FILE` + matching `GRAMMAR_LLM_MODEL_URL`) |
| Any custom GGUF | set `GRAMMAR_LLM_MODEL_FILE` and pre-place the file in `/models` (or point `GRAMMAR_LLM_MODEL_URL` at it) |
The model download is done by the container's own `dist/download-model.mjs`
(node-llama-cpp `createModelDownloader`, the same API grammar-llm's desktop
modelManager uses). It's skipped automatically if the file already exists.
## Configuration
| Env | Default | Description |
|-----|---------|-------------|
| `PORT` | `8010` | HTTP port (matches docker-languagetool & Overleaf's default) |
| `HOST` | `0.0.0.0` | bind address |
| `GRAMMAR_LLM_MODEL_DIR` | `/models` | model storage dir |
| `GRAMMAR_LLM_MODEL_FILE` | `GRMR-V3-G4B-Q4_K_M.gguf` | model file name inside the dir |
| `GRAMMAR_LLM_MODEL_URL` | HF Q4_K_M URL | download source (only used when file is absent) |
| `GRAMMAR_LLM_DOWNLOAD_AT_START` | `true` | auto-download at boot when file is absent |
| `GRAMMAR_LLM_PRELOAD` | `true` | start model load at boot (vs. first check) |
| `GRAMMAR_LLM_CONTEXT_SIZE` | `8192` | LLM context (desktop-compatible clamp 256…131072) |
| `GRAMMAR_LLM_CONCURRENCY` | `1` | (reserved; single shared session today) |
| `GRAMMAR_LLM_MAX_TEXT_LENGTH` | `100000` | max characters per request (→ 413; matches Overleaf's 100 KB cap) |
| `GRAMMAR_LLM_MAX_RUNS` | `0` (unlimited) | cap on checkable text runs per request (→ `incompleteResults`) |
| `GRAMMAR_LLM_REQUEST_TIMEOUT_MS` | `55000` | per-request time budget; remaining sentences pass through unchanged + `warnings.incompleteResults` (keep under Overleaf's 60 s proxy timeout) |
| `GRAMMAR_LLM_SENTENCE_TIMEOUT_MS` | `20000` | per-sentence guard (informational) |
| `GRAMMAR_LLM_MAX_IN_FLIGHT` | `4` | concurrent checks before 503 |
| `GRAMMAR_LLM_MAX_BODY_BYTES` | `2097152` | HTTP body cap |
| `GRAMMAR_LLM_LANGUAGES` | `auto,en,en-us,en-gb,en-au,en-ca,en-ie,en-nz,en-in,en-za` | accepted `language` codes (lower-cased) |
| `GRAMMAR_LLM_UNSUPPORTED_LANGUAGE` | `empty` | `empty` = 200 + no matches, `error` = 400 |
| `GRAMMAR_LLM_BACKEND` | `local` | `local` (node-llama-cpp) or `openai` (OpenAI-compatible remote endpoint) |
| `GRAMMAR_LLM_OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL (OpenAI, OpenRouter, vLLM, llama.cpp `llama-server`, Ollama `/v1`, a self-hosted GRMR endpoint, …) |
| `GRAMMAR_LLM_OPENAI_API_KEY` | _empty_ | Bearer token (only if the endpoint requires one) |
| `GRAMMAR_LLM_OPENAI_MODEL` | _required for openai_ | model name/id on the server (e.g. `gpt-4o-mini`, or your GRMR model) |
| `GRAMMAR_LLM_OPENAI_STYLE` | `openai` | `openai` = standard `/chat/completions`; `ollama` = native `/api/chat` with `think:false` (needed for Ollama Qwen3-family models, see below) |
| `GRAMMAR_LLM_OPENAI_TIMEOUT_MS` | `60000` | timeout for one remote call |
| `GRAMMAR_LLM_LANGUAGETOOL_URL` | _empty_ | optional **real LanguageTool container to query first** (e.g. `http://languagetool:8010`); empty = LLM only |
| `GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS` | `45000` | real-LT call timeout — its window to win the race |
| `GRAMMAR_LLM_CASCADE_BUDGET_MS` | `55000` | **shared wall-clock budget for the whole cascaded check** (LT attempt + LLM fallback). Must stay under the LT client's cap — Overleaf's controller times out at **60 s and then silently shows nothing** |
| `GRAMMAR_LLM_LLM_GRACE_MS` | `400` | how long a fast real LT can win before the LLM starts in parallel (keeps the compute-saving benefit; 0 = pure parallel) |
| `GRAMMAR_LLM_DEFAULT_LEVEL` | `default` | level used when the client sends none (`default` \| `picky`) |
| `GRAMMAR_LLM_ALLOW_ORIGIN` | `*` | CORS origin (docker-languagetool runs with `--allow-origin '*'`) |
| `GRAMMAR_LLM_TEMPERATURE` / `GRAMMAR_LLM_TOP_K` / `GRAMMAR_LLM_TOP_P` / `GRAMMAR_LLM_MIN_P` | `0.7 / 40 / 0.95 / 0.01` | sampling — identical defaults to the grammar-llm desktop app |
| `GRAMMAR_LLM_REPORTED_LT_VERSION` | `6.8` | version string reported as "LanguageTool" |
| `GRAMMAR_LLM_LOG_LEVEL` | `info` | `debug\|info\|warn\|error` |
| `LLAMA_USE_MLOCK`, `GGML_N_THREADS`, … | — | node-llama-cpp / llama.cpp passthrough env vars work as-is |
## Check routing & modes
Three independent, composable knobs control where and how a check is computed.
### 1) Optional real LanguageTool first (compute-saving cascade)
Point this container at a real LanguageTool box and it runs **first**; the LLM
only runs when LanguageTool found nothing — saving local inference on the
cases the rule engine already solved (typo, casing, punctuation):
```sh
GRAMMAR_LLM_LANGUAGETOOL_URL=http://languagetool:8010 # same compose network
```
**How the race works** (all on ONE shared budget, `GRAMMAR_LLM_CASCADE_BUDGET_MS,
default 55 s` — chosen to sit under Overleaf's 60 s controller timeout):
1. The request is forwarded **verbatim** (`text` **or** the original
`data.annotation`, `language`, `disabledRules`, and **`level` —
`picky`→`picky`**) to `$GRAMMAR_LLM_LANGUAGETOOL_URL/v2/check`.
2. After a short grace (`GRAMMAR_LLM_LLM_GRACE_MS`, default 400 ms — long
enough that a *warm* real LT, which answers in a few hundred ms, wins for
free and the LLM start is **cancelled**), the LLM runs **in parallel**.
3. Resolution:
- real-LT outcome with **≥1 match** → returned as-is (same envelope
Overleaf already consumes);
- LLM ready → returned; if the LT is still running it gets a final capped
grace (≤ 1.2 s) to land a match ("real LT first", with a lid on it);
- an LLM 400/503 outcome only surfaces when the LT had nothing either.
Why parallel: a *serial* "wait for LT, then run LLM" pushed the total past
clients' hard caps (measured 15 s LT timeout + 56 s LLM = 71 s > Overleaf's
60 s → the UI silently showed nothing). The shared budget guarantees the
response always lands in time — worst case is a *partial* result
(`warnings.incompleteResults`), never a client-side timeout. The fallback is
silent and automatic, so a dead LanguageTool box never breaks checking — it
just costs one attempt. Set the URL back to empty to disable.
### 2) Optional remote LLM (OpenAI-compatible API)
When the Overleaf host can't afford local inference, redirect the LLM step to
any OpenAI-compatible `chat/completions` endpoint (OpenAI, OpenRouter, vLLM,
llama.cpp `llama-server`, Ollama `/v1`, a hosted GRMR/V3 LoRA, …):
```sh
GRAMMAR_LLM_BACKEND=openai
GRAMMAR_LLM_OPENAI_BASE_URL=https://api.openai.com/v1 # or your endpoint
GRAMMAR_LLM_OPENAI_MODEL=gpt-4o-mini # required
GRAMMAR_LLM_OPENAI_API_KEY=sk-… # only if needed
```
**Ollama (Qwen3 and similar reasoning models):** use the native style instead. On
Ollama's OpenAI-compat layer the `think:false` flag is ignored for Qwen3-family
models and the response can spend *all* completion tokens on hidden reasoning
(measured: 111–400 tokens burned for ~15-token answers, occasionally hitting
max_tokens and returning an empty answer with HTTP 200). The native
`/api/chat` honors top-level `think:false` and suppresses reasoning entirely
(measured: ~15 completion tokens, same answer):
```sh
GRAMMAR_LLM_BACKEND=openai
GRAMMAR_LLM_OPENAI_STYLE=ollama
GRAMMAR_LLM_OPENAI_BASE_URL=http://127.0.0.1:11434 # /v1 suffix is stripped automatically
GRAMMAR_LLM_OPENAI_MODEL=qwen3.8:latest
GRAMMAR_LLM_OPENAI_API_KEY=ollama # Ollama ignores it
```
If the Ollama server only listens on the host's loopback, add a tiny TCP relay
container bound to the docker bridge gateway IP (see
`/data_1/docker/compose/ollama-gw/` in this deployment) so containers on the
bridge network can reach it without exposing Ollama to the LAN.
With `backend=openai` **no local model is downloaded or loaded** (`start.sh`
skips the model step; the image is unchanged). The same grammar-llm core
pipeline (sentence diff, word-fix extraction, quote cleanup, LanguageTool
mapping) runs locally — only the per-sentence generation call is remote.
Prompt/stop/sampling semantics match the local backend exactly.
### 3) Picky mode for the LLM (`level`)
LanguageTool's `default` vs `picky` maps to two LLM prompt modes, mirroring
LT's idea that *picky adds rule families on top of the default set*:
| level | LLM behavior |
|------------|--------------|
| `default` | base correction prompt only — **byte-identical to the grammar-llm desktop app** (grammar, spelling, punctuation). |
| `picky` | base prompt **plus** a "highly picky editor" addendum: word choice, awkward/redundant phrasing, weak punctuation, style. Produces the fuller suggestion set. |
`/v2/check` sends whatever `level` the client gave (Overleaf default is
`picky`). When a client sends no level, `GRAMMAR_LLM_DEFAULT_LEVEL` decides
(default `default`). The requested level is always forwarded verbatim to the
real LanguageTool box (step 1), so a `picky` Overleaf tab still gets
`level=picky` there.
> Tuning: the picky addendum text is fixed in code (
> `DEFAULT_PICKY_EXTRA` in `src/llama.ts`). If you want it stronger or
> weaker, send a PR or vendor the constant — the level *mechanism* (two
> prompts + LT forwarding) stays the same.
## Behavior & caveats (vs. LanguageTool)
| Topic | LanguageTool | grammar-llm container |
|-------|--------------|-----------------------|
| Languages | 30+ | English only (GRMR-V3-G4B). Non-English → silent (or 400, configurable) |
| Speed | ms | seconds per sentence (CPU). Short docs fine; very long docs hit the time budget → `incompleteResults` + partial matches (a LanguageTool-native concept) |
| Rules/levels | fine-grained rules, levels `default…picky` | **level supported**: `default` (base prompt, desktop parity) / `picky` (base + style addendum); `disabledRules` forward to a real LT box when configured, ignored by the LLM |
| Where it runs | same box | **local** node-llama-cpp, or **remote** OpenAI-compatible endpoint (`GRAMMAR_LLM_BACKEND=openai`), or a real-LT-first cascade (`GRAMMAR_LLM_LANGUAGETOOL_URL`) — pick your compute strategy per deployment |
| Dictionary | rule-based + user dicts | statistical (model) |
| Memory | ~1–2 GB | model ~2.6 GB (Q4) + context ≈ **give the container ≥ 4 GB RAM** |
| GPU | n/a | auto-detected by node-llama-cpp (build/run with a CUDA build + `--gpus all` for best performance) |
| Telemetry/premium endpoints | has some | not needed by Overleaf; others → 404 |
The engine output is byte-identical to the grammar-llm desktop app for a given
model: same system prompt, stop sequences, sampling, sentence pipeline
(vendored `core/` + mirrored `llamaService` semantics).
## Development
**Build the Docker image** — no local Node installation required; the
compile (`npm ci` + esbuild) happens inside the `node:24-bookworm-slim`
base image's own Node:
```sh
make all # docker build -t grammar-llm:latest .
```
**Local development** (optional; needs Node ≥ 20 + npm to run the bundle
and unit tests directly on the host):
```sh
npm install
npm run build # esbuild → dist/*.mjs (self-contained; node-llama-cpp external)
npm test # build + node --test (LLM-free: fake engine + unit tests)
npm run smoke # end-to-end curl against a running local server (needs model)
```
`Makefile` exposes the same targets plus Docker ops — `make help`:
```make
make all # build the Docker image (default target; no local Node needed)
make docker-test # run the unit tests inside a node base-image container (no local Node needed)
make docker-build # docker build -t grammar-llm:latest .
make docker-smoke # build + container on :8010 + e2e smoke
make docker-run # start the container (model volume kept)
make docker-stop # stop + rm (volume kept) / make docker-clobber (also deletes volume)
make logs # tail container logs
```
### Updating the vendored core
The functional core is vendored under `core/` (snapshot of
`grammar-llm/src/electron/core/*.ts` — provenance in `core/.sync-info`):
```sh
scripts/sync-core.sh # from /data_1/compare/grammar-llm (default)
scripts/sync-core.sh /path/to/grammar-llm
npm run build && npm test
```
`src/llama.ts` mirrors `grammar-llm/src/electron/llamaService.ts` — when the
desktop changes prompts/sampling, keep both in sync.
## Docker build layout
- **builder**: `node:24-bookworm-slim`, `npm ci` (installs
`node-llama-cpp` + platform prebuilds + `esbuild`), bundles `dist/`.
- **runtime**: `node:24-bookworm-slim`, `npm ci --omit=dev` (native libs for
the target platform), non-root user, `VOLUME /models`, `HEALTHCHECK` against
`/v2/health` (1800 s start-period to cover first-run model download).
## License & credits
See [CREDITS.md](CREDITS.md). The functional core is vendored from
[whiteh4cker-tr/grammar-llm](https://github.com/whiteh4cker-tr/grammar-llm)
(MIT, © 2025 whiteh4cker-tr) — provenance and per-file hashes in
`core/.sync-info`. The external protocol is a compatibility implementation of
the [LanguageTool](https://languagetool.org) v2 REST API (LGPL; API-level
only, no LT code included).
+25
View File
@@ -0,0 +1,25 @@
// Build: bundle the TypeScript server + vendored core into self-contained
// ES modules under dist/. node-llama-cpp stays external (native, platform
// packages installed by npm at install time).
import { build } from 'esbuild';
const quiet = process.argv.includes('--quiet');
await build({
entryPoints: ['src/server.ts', 'src/download-model.ts', 'src/languagetool.ts', 'src/config.ts'],
outdir: 'dist',
outExtension: { '.js': '.mjs' },
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
// Native module: keep as a runtime import; npm installs the right
// @node-llama-cpp/* prebuilt for the target platform.
external: ['node-llama-cpp'],
sourcemap: false,
minify: false,
logLevel: quiet ? 'warning' : 'info',
});
if (!quiet) console.log('✓ build complete → dist/ (server.mjs, download-model.mjs, languagetool.mjs, config.mjs)');
@@ -0,0 +1,34 @@
services:
grammar-llm:
image: grammar-llm:latest
container_name: languagetool
hostname: languagetool
restart: unless-stopped
# Optional environment (uncomment and set as a mapping — compose rejects
# an environment key with no entries):
# environment:
# GRAMMAR_LLM_MODEL_FILE: GRMR-V3-G4B-Q8_0.gguf # optional: higher-quality (slower) quant
# GRAMMAR_LLM_MODEL_URL: "..." # optional: custom model source
# GRAMMAR_LLM_DOWNLOAD_AT_START: "true" # default: download model on first start
# GRAMMAR_LLM_REQUEST_TIMEOUT_MS: "55000" # per-request time budget
# GRAMMAR_LLM_PRELOAD: "true" # load model at boot
volumes:
- ./models:/models # the model downloaded here (make download-model → ./models/GRMR-V3-G4B-Q4_K_M.gguf); survives container recreation
# For NVIDIA GPU inference (build with the linux-x64-cuda prebuilt and
# uncomment; node-llama-cpp auto-detects CUDA when available):
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
networks:
- overleaf-network
networks:
overleaf-network:
external: true
@@ -0,0 +1,10 @@
2026-10-28T01:38:51Z
source: /data_1/compare/grammar-llm/src/electron/core
ceff65a
57303d960bf50d2084f71c80149f94e593f1927f4cc6eb1019ce2bd8b2961f73 /data_1/compare/grammar-llm/src/electron/core/apply.ts
7c851d7a0c9fd3118a37119d9850ef149792381f82687cee70b58800dfda402d /data_1/compare/grammar-llm/src/electron/core/clean.ts
57d585afe1ac6e232d05f03e46c33a2b3ae2c65a18478c798ad3d6f9bd68892f /data_1/compare/grammar-llm/src/electron/core/correction.ts
d97ad1c71100da76c3ce0b356727aa51a5f66c43afaf4f73a75d0b125072c594 /data_1/compare/grammar-llm/src/electron/core/diff.ts
f750127beb34b35fb1de3dab02ab402d2a29c2e52d852079f49af22a4e923e8a /data_1/compare/grammar-llm/src/electron/core/reconstruct.ts
5588044015fde745805ad194d582a8604b3db2636b59c1c36aeda8ecec376593 /data_1/compare/grammar-llm/src/electron/core/sentences.ts
133f88bc76531a8727f7c0346f1ae2a4be7ec519532d16409b8c84648fa48ebc /data_1/compare/grammar-llm/src/electron/core/types.ts
+76
View File
@@ -0,0 +1,76 @@
import type { Suggestion } from './types.js';
export function findAllOccurrences(haystack: string, needle: string): Array<[number, number]> {
if (!needle || !haystack) return [];
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(escaped, 'g');
const matches: Array<[number, number]> = [];
let match: RegExpExecArray | null;
while ((match = re.exec(haystack)) !== null) {
matches.push([match.index, match.index + needle.length]);
if (match.index === re.lastIndex) re.lastIndex++;
}
return matches;
}
function nearestOccurrence(occurrences: Array<[number, number]>, approxStart: number): [number, number] {
return occurrences.reduce((best, occ) =>
Math.abs(occ[0] - approxStart) < Math.abs(best[0] - approxStart) ? occ : best,
);
}
export function applySuggestion(text: string, suggestionIndex: number, suggestions: Suggestion[]): string {
if (suggestionIndex < 0 || suggestionIndex >= suggestions.length) {
throw new RangeError('Invalid suggestion index');
}
const suggestion = suggestions[suggestionIndex];
const { start_index: start, end_index: end } = suggestion;
if (start >= 0 && start <= end && end <= text.length && text.slice(start, end) === suggestion.original) {
return text.slice(0, start) + suggestion.corrected + text.slice(end);
}
const occurrences = findAllOccurrences(text, suggestion.original);
if (occurrences.length > 0) {
const [tStart, tEnd] = nearestOccurrence(occurrences, start);
return text.slice(0, tStart) + suggestion.corrected + text.slice(tEnd);
}
return text;
}
export function applySuggestionsBulk(text: string, suggestions: Suggestion[]): string {
if (suggestions.length === 0) return text;
const sorted = suggestions
.filter((s) => s.start_index >= 0 && s.end_index >= 0 && s.start_index <= s.end_index)
.sort((a, b) => b.start_index - a.start_index);
const appliedIntervals: Array<[number, number]> = [];
let result = text;
for (const s of sorted) {
const { start_index: start, end_index: end } = s;
let candidate: [number, number] | null = null;
if (end <= result.length && result.slice(start, end) === s.original) {
candidate = [start, end];
} else {
const occurrences = findAllOccurrences(result, s.original);
if (occurrences.length > 0) {
candidate = nearestOccurrence(occurrences, start);
}
}
if (!candidate) continue;
const [cStart, cEnd] = candidate;
const overlaps = appliedIntervals.some(([aStart, aEnd]) => !(cEnd <= aStart || cStart >= aEnd));
if (overlaps) continue;
result = result.slice(0, cStart) + s.corrected + result.slice(cEnd);
appliedIntervals.push([cStart, cEnd]);
}
return result;
}
+87
View File
@@ -0,0 +1,87 @@
const TEMPLATE_TAG_RE = /<\|.*?\|>/g;
const QUOTE_PERIOD_RE = /([.!?])(["'])\s*\./g;
const QUOTE_DUP_RE = /([.!?])(["'])\s*\1/g;
// Mojibake: the UTF-8 bytes of smart quotes (e.g. U+201C = E2 80 9C) can arrive
// as Latin-1/CP1252 characters (\u00e2\u20ac\u0153 = "“", or \u00e2\u0080\u009c
// when the C1 control bytes survive). Models fine-tuned on scraped data often
// reproduce these instead of proper quotes. Replace longer patterns first.
const MOJIBAKE_PATTERNS: Array<[RegExp, string]> = [
[/\u00e2\u20ac\u0153/g, '\u201C'], // “
[/\u00e2\u0080\u009c/g, '\u201C'], // “ (raw control bytes)
[/\u00e2\u20ac\u009d/g, '\u201D'], // ”
[/\u00e2\u0080\u009d/g, '\u201D'], // ” (raw control bytes)
[/\u00e2\u20ac\u02dc/g, '\u2018'], // ‘
[/\u00e2\u0080\u0098/g, '\u2018'], // ‘ (raw control bytes)
[/\u00e2\u20ac\u2122/g, '\u2019'], // ’
[/\u00e2\u0080\u0099/g, '\u2019'], // ’ (raw control bytes)
[/\u00e2\u20ac\u00a6/g, '\u2026'], // …
[/\u00e2\u0080\u00a6/g, '\u2026'], // … (raw control bytes)
];
/** Normalize mojibake smart quotes back to proper Unicode characters. */
export function fixMojibakeQuotes(s: string): string {
let result = s;
for (const [pattern, replacement] of MOJIBAKE_PATTERNS) {
result = result.replace(pattern, replacement);
}
return result;
}
const INSTRUCTION_PREFIXES = [
'correct the grammar and spelling of this sentence:',
'here is the corrected sentence:',
'corrected sentence:',
'the corrected version is:',
'grammar correction:',
'corrected:',
];
export function cleanCorrectedText(corrected: string, original: string): string {
if (!corrected) return original;
let result = fixMojibakeQuotes(corrected).replace(TEMPLATE_TAG_RE, '').trim();
for (const prefix of INSTRUCTION_PREFIXES) {
if (result.toLowerCase().startsWith(prefix)) {
result = result.slice(prefix.length).trim();
result = result.replace(/^[:]\s*/, '');
}
}
const words = result.split(/\s+/);
if (words.length > 10) {
for (let i = 0; i < words.length - 5; i++) {
const segment = words.slice(i, i + 5).join(' ');
if (words.slice(i + 5).join(' ').includes(segment)) {
result = words.slice(0, i + 5).join(' ');
break;
}
}
}
if (original && /^[A-Z]/.test(original) && result && /^[a-z]/.test(result)) {
result = result[0].toUpperCase() + result.slice(1);
}
for (let i = 0; i < 3; i++) {
const before = result;
result = result.replace(QUOTE_PERIOD_RE, '$1$2');
result = result.replace(QUOTE_DUP_RE, '$1$2');
if (result === before) break;
}
if (original && /[.!?]$/.test(original) && result && !/[.!?]$/.test(result)) {
result += original[original.length - 1];
}
return result.trim();
}
export function isOnlyQuoteChange(original: string, corrected: string): boolean {
if (original === corrected) return false;
const normalize = (s: string): string => s.replace(/[\u2018\u2019]/g, "'").replace(/[\u201C\u201D]/g, '"');
if (normalize(original) === normalize(corrected)) return true;
if (normalize(original.trim()) === normalize(corrected.trim())) return true;
return false;
}
@@ -0,0 +1,76 @@
import { splitIntoSentences } from './sentences.js';
import { cleanCorrectedText, isOnlyQuoteChange } from './clean.js';
import { highlightWordDifferences, extractWordFixes } from './diff.js';
import { reconstructTextFromSentences } from './reconstruct.js';
import type { CorrectionResponse, SentenceCorrector } from './types.js';
export function cleanQuotePunctuation(s: string): string {
let result = s;
for (let i = 0; i < 3; i++) {
const before = result;
result = result.replace(/([.!?])(["'])\s*\./g, '$1$2');
result = result.replace(/([.!?])(["'])\s*\1/g, '$1$2');
if (result === before) break;
}
return result;
}
export async function correctText(text: string, corrector: SentenceCorrector): Promise<CorrectionResponse> {
const trimmed = text.trim();
if (!trimmed) return { suggestions: [], correctedText: '' };
const sentenceData = splitIntoSentences(text);
const suggestions: CorrectionResponse['suggestions'] = [];
const correctedSentences: string[] = [];
for (let i = 0; i < sentenceData.length; i++) {
const sent = sentenceData[i];
const sentence = sent.text;
if (sentence.length < 2) {
correctedSentences.push(sentence);
continue;
}
let corrected = await corrector.correct(sentence);
corrected = cleanCorrectedText(corrected, sentence);
if (isOnlyQuoteChange(sentence, corrected)) {
corrected = sentence;
}
if (corrected.length > sentence.length * 2) {
corrected = sentence;
}
corrected = cleanQuotePunctuation(corrected);
// Diff once: restore the original quote style (curly vs straight) so
// quote changes are never applied to the user's text.
const diff = highlightWordDifferences(sentence, corrected);
corrected = diff.preservedCorrected;
correctedSentences.push(corrected);
if (
corrected.toLowerCase().trim() !== sentence.toLowerCase().trim() &&
corrected.trim() !== sentence.trim() &&
corrected.length <= sentence.length * 1.5 &&
!isOnlyQuoteChange(sentence, corrected)
) {
suggestions.push({
original: sentence,
corrected,
sentence: `Sentence ${i + 1}`,
start_index: sent.start,
end_index: sent.end,
original_highlighted: diff.originalHighlighted,
corrected_highlighted: diff.correctedHighlighted,
wordFixes: extractWordFixes(sentence, corrected, sent.start),
});
}
}
const correctedText = cleanQuotePunctuation(
reconstructTextFromSentences(text, sentenceData, correctedSentences),
);
return { suggestions, correctedText };
}
+222
View File
@@ -0,0 +1,222 @@
import { diffArrays } from 'diff';
import type { WordFix } from './types.js';
export function tokenize(text: string): string[] {
const tokens: string[] = [];
let current = '';
for (const char of text) {
if (/\s/.test(char)) {
if (current) {
tokens.push(current);
current = '';
}
tokens.push(char);
} else if ('.,!?;:"()[]{}\u201C\u201D'.includes(char)) {
if (current) {
tokens.push(current);
current = '';
}
tokens.push(char);
} else {
current += char;
}
}
if (current) tokens.push(current);
return tokens;
}
// Curly quotes compare equal to their straight counterparts so that
// quote-style changes alone never trigger word highlighting.
function normalizeToken(token: string): string {
return token
.replace(/[\u201C\u201D]/g, '"')
.replace(/[\u2018\u2019]/g, "'");
}
export function highlightWordDifferences(
original: string,
corrected: string,
): {
originalHighlighted: string;
correctedHighlighted: string;
/** Corrected text with the original's quote characters restored. */
preservedCorrected: string;
} {
const originalTokens = tokenize(original);
const correctedTokens = tokenize(corrected);
const parts = diffArrays(originalTokens.map(normalizeToken), correctedTokens.map(normalizeToken));
let originalHighlighted = '';
let correctedHighlighted = '';
let preservedCorrected = '';
let originalIndex = 0;
let correctedIndex = 0;
for (const part of parts) {
const value = Array.isArray(part.value) ? part.value : [part.value];
const length = value.length;
if (part.added) {
for (let k = 0; k < length; k++) {
const token = correctedTokens[correctedIndex + k];
correctedHighlighted += token.trim()
? `<span class="corrected-word">${token}</span>`
: token;
preservedCorrected += token;
}
correctedIndex += length;
} else if (part.removed) {
for (let k = 0; k < length; k++) {
const token = originalTokens[originalIndex + k];
originalHighlighted += token.trim()
? `<span class="error-word">${token}</span>`
: token;
}
originalIndex += length;
} else {
// Equal after quote normalization: emit the ORIGINAL characters on both
// sides so quote style stays untouched and words aren't highlighted.
const originalSlice = originalTokens.slice(originalIndex, originalIndex + length).join('');
originalHighlighted += originalSlice;
correctedHighlighted += originalSlice;
preservedCorrected += originalSlice;
originalIndex += length;
correctedIndex += length;
}
}
return { originalHighlighted, correctedHighlighted, preservedCorrected };
}
export function extractWordFixes(
original: string,
corrected: string,
baseOffset: number,
): WordFix[] {
const originalTokens = tokenize(original);
const correctedTokens = tokenize(corrected);
const parts = diffArrays(originalTokens.map(normalizeToken), correctedTokens.map(normalizeToken));
// Sentence-relative start offset of each original token. Tokens are
// contiguous slices of the original, so cumulative lengths give offsets.
const originalStarts: number[] = [];
let pos = 0;
for (const token of originalTokens) {
originalStarts.push(pos);
pos += token.length;
}
const fixes: WordFix[] = [];
let originalIndex = 0;
let correctedIndex = 0;
let pendingRemoved: Array<{ token: string; start: number }> = [];
const emitDeletion = (removed: Array<{ token: string; start: number }>) => {
const originalText = removed.map((r) => r.token).join('');
if (originalText.trim() === '') return; // whitespace-only — nothing visible to click
const last = removed[removed.length - 1];
fixes.push({
original: originalText,
corrected: '',
start: baseOffset + removed[0].start,
end: baseOffset + last.start + last.token.length,
});
};
for (const part of parts) {
const value = Array.isArray(part.value) ? part.value : [part.value];
if (part.removed) {
for (let k = 0; k < value.length; k++) {
const idx = originalIndex + k;
pendingRemoved.push({ token: originalTokens[idx], start: originalStarts[idx] });
}
originalIndex += value.length;
} else if (part.added) {
const addedTokens = correctedTokens.slice(correctedIndex, correctedIndex + value.length);
if (pendingRemoved.length > 0 && addedTokens.length > 0) {
if (pendingRemoved.length === addedTokens.length) {
// 1:1 pairing.
for (let k = 0; k < pendingRemoved.length; k++) {
const removed = pendingRemoved[k];
const added = addedTokens[k];
if (removed.token !== added && removed.token.trim() !== '' && added.trim() !== '') {
fixes.push({
original: removed.token,
corrected: added,
start: baseOffset + removed.start,
end: baseOffset + removed.start + removed.token.length,
});
}
}
} else {
// Unequal run. Pair as many leading tokens as possible 1:1; if the
// leftovers are punctuation/whitespace only (e.g. 'slow' ->
// 'slowly,'), emit them as a separate insertion so the comma shows
// as its own marker in the editor. Otherwise keep the whole-run
// joined fix (e.g. 'is are' -> 'was' must stay a single unit).
const pairCount = Math.min(pendingRemoved.length, addedTokens.length);
const leftoverRemoved = pendingRemoved.slice(pairCount);
const leftoverAdded = addedTokens.slice(pairCount);
// eslint-disable-next-line no-useless-escape -- the [ ] escapes are required here; unescaped [] matches nothing
const punctOnly = (tokens: string[]) => tokens.every((t) => /^[\s.,!?;:'"()\[\]{}\u201C\u201D\u2018\u2019]*$/.test(t));
if (punctOnly(leftoverRemoved.map((r) => r.token)) && punctOnly(leftoverAdded)) {
for (let k = 0; k < pairCount; k++) {
const removed = pendingRemoved[k];
const added = addedTokens[k];
if (removed.token !== added && removed.token.trim() !== '' && added.trim() !== '') {
fixes.push({
original: removed.token,
corrected: added,
start: baseOffset + removed.start,
end: baseOffset + removed.start + removed.token.length,
});
}
}
const pairEnd = pendingRemoved[pairCount - 1].start + pendingRemoved[pairCount - 1].token.length;
const addedText = leftoverAdded.join('');
if (addedText.trim() !== '') {
fixes.push({ original: '', corrected: addedText, start: baseOffset + pairEnd, end: baseOffset + pairEnd });
}
// leftoverRemoved is whitespace/punctuation only — nothing
// visible to click, so no deletion fix is emitted.
} else {
const originalText = pendingRemoved.map((r) => r.token).join('');
const addedText = addedTokens.join('');
if (originalText.trim() !== '' && addedText.trim() !== '') {
const last = pendingRemoved[pendingRemoved.length - 1];
fixes.push({
original: originalText,
corrected: addedText,
start: baseOffset + pendingRemoved[0].start,
end: baseOffset + last.start + last.token.length,
});
}
}
}
} else if (pendingRemoved.length === 0 && addedTokens.length > 0) {
// Pure insertion: mark the point before the next original token.
const addedText = addedTokens.join('');
if (addedText.trim() !== '') {
const insertAt = originalIndex < originalTokens.length
? originalStarts[originalIndex]
: original.length;
fixes.push({ original: '', corrected: addedText, start: baseOffset + insertAt, end: baseOffset + insertAt });
}
}
pendingRemoved = [];
correctedIndex += value.length;
} else {
// Equal part closes any pending change group; a removed run with no
// added counterpart is a pure deletion.
if (pendingRemoved.length > 0) emitDeletion(pendingRemoved);
pendingRemoved = [];
originalIndex += value.length;
correctedIndex += value.length;
}
}
// A removed run at the very end of the diff is also a pure deletion.
if (pendingRemoved.length > 0) emitDeletion(pendingRemoved);
return fixes;
}
@@ -0,0 +1,56 @@
import type { SentenceData } from './types.js';
export function reconstructTextFromSentences(
originalText: string,
sentenceData: SentenceData[],
correctedSentences: string[],
): string {
if (sentenceData.length !== correctedSentences.length) return originalText;
const resultParts: string[] = [];
let lastSpanEnd = 0;
for (let i = 0; i < sentenceData.length; i++) {
const sent = sentenceData[i];
const corrected = correctedSentences[i];
const { start, end } = sent;
const spanEnd = sent.spanEnd ?? end;
const gapBeforeStart = sent.gapBeforeStart ?? lastSpanEnd;
if (gapBeforeStart > lastSpanEnd) {
resultParts.push(originalText.slice(lastSpanEnd, gapBeforeStart));
} else if (start > lastSpanEnd) {
resultParts.push(originalText.slice(lastSpanEnd, start));
}
resultParts.push(corrected);
if (spanEnd > end) {
resultParts.push(originalText.slice(end, spanEnd));
} else if (i < sentenceData.length - 1) {
if (end < originalText.length) {
let whitespaceEnd = end;
while (whitespaceEnd < originalText.length && /\s/.test(originalText[whitespaceEnd])) {
whitespaceEnd++;
}
if (whitespaceEnd > end) {
resultParts.push(originalText.slice(end, whitespaceEnd));
} else if (corrected && /[.!?]$/.test(corrected) && whitespaceEnd < originalText.length) {
const nextChar = originalText[whitespaceEnd];
// Python: next_char.isalpha() and next_char.isupper()
if (nextChar && /[A-Z]/.test(nextChar)) {
resultParts.push(' ');
}
}
}
}
lastSpanEnd = spanEnd;
}
if (lastSpanEnd < originalText.length) {
resultParts.push(originalText.slice(lastSpanEnd));
}
return resultParts.join('');
}
@@ -0,0 +1,125 @@
import type { SentenceData } from './types.js';
const ABBREVIATIONS = new Set([
'etc', 'eg', 'e.g', 'ie', 'i.e', 'vs', 'viz', 'cf', 'ca', 'approx',
'no', 'vol', 'fig', 'p', 'pp', 'ch', 'sec', 'ex', 'al', 'et', 'seq',
'etc.', 'e.g.', 'i.e.', 'vs.', 'viz.', 'cf.', 'ca.', 'approx.',
'no.', 'vol.', 'fig.', 'p.', 'pp.', 'ch.', 'sec.', 'ex.', 'et al.', 'seq.',
'mr', 'mrs', 'ms', 'dr', 'prof', 'rev', 'sr', 'jr', 'st',
]);
// Python: (?<=[.!?])(?!\w)(?<!\d\.\d)(?<!\s[A-Za-z]\.)\s+(?=[A-Z"'])|(?<=[.!?])\s*$
// (re.VERBOSE + re.IGNORECASE; \w is ASCII in JS — acceptable for English text)
const SENTENCE_BOUNDARY_RE = /(?<=[.!?])(?!\w)(?<!\d\.\d)(?<!\s[A-Za-z]\.)\s+(?=[A-Z"'])|(?<=[.!?])\s*$/gi;
// A missing space after terminal punctuation — "…on its functionality.The team
// were…". The original pattern needs \s+ after the punctuation, so such a run
// was swallowed into one sentence. This boundary is zero-width, so it is found
// separately, and only when:
// * the punctuated word has at least two letters — keeps single-letter
// initials glued ("U.S.Today") and decimals out ("3.5.Next"); abbreviations
// such as "etc.They" are rejected later by ABBREVIATIONS; and
// * the next word starts upper-case followed by a lower-case letter — keeps
// "README.TXT" glued and skips "happy.enough".
const MISSING_SPACE_BOUNDARY_RE = /(?<=[A-Za-z]{2}[.!?])(?=[A-Z][a-z])/g;
interface BoundaryMatch {
index: number; // position immediately after the terminal punctuation
length: number; // whitespace separating the two sentences (0 when missing)
}
function findBoundaryMatches(text: string): BoundaryMatch[] {
const matches: BoundaryMatch[] = [];
for (const pattern of [SENTENCE_BOUNDARY_RE, MISSING_SPACE_BOUNDARY_RE]) {
const re = new RegExp(pattern.source, pattern.flags);
let match: RegExpExecArray | null;
while ((match = re.exec(text)) !== null) {
matches.push({ index: match.index, length: match[0].length });
// JS exec() loops forever on zero-width matches; Python's finditer()
// auto-advances. Guard like findAllOccurrences does.
if (match.index === re.lastIndex) re.lastIndex++;
}
}
matches.sort((a, b) => a.index - b.index || b.length - a.length);
// The two patterns are mutually exclusive (one needs a non-word character
// right after the punctuation, the other an upper-case letter), but never
// emit the same split twice.
return matches.filter((m, i) => i === 0 || m.index !== matches[i - 1].index);
}
export function splitIntoSentences(text: string): SentenceData[] {
const sentences: SentenceData[] = [];
if (!text.trim()) return sentences;
let lastEnd = 0;
const potentialSplits = findBoundaryMatches(text);
for (const { index: splitPos, length: gapLength } of potentialSplits) {
const sentenceText = text.slice(lastEnd, splitPos).trim();
if (!sentenceText) {
lastEnd = splitPos + gapLength;
continue;
}
let isTrueBoundary = true;
const prevWords = sentenceText.toLowerCase().split(/\s+/);
if (prevWords.length > 0) {
const lastWord = prevWords[prevWords.length - 1].replace(/^[.,!?;:"']+|[.,!?;:"']+$/g, '');
if (ABBREVIATIONS.has(lastWord)) {
isTrueBoundary = false;
} else if (/\d\.\d/.test(sentenceText.slice(-10))) {
// Python quirk replicated: the single-letter-initial check was dead
// code in main.py (misspelled variable), so initials ARE split here.
isTrueBoundary = false;
}
}
// Only meaningful for whitespace boundaries: it inspects the character
// after the gap's first character to reject a lower-case continuation.
// A space-less boundary is already constrained to an upper-case start.
if (gapLength > 0 && splitPos + 2 < text.length) {
const nextChars = text.slice(splitPos + 1, splitPos + 3);
// Python: (next_chars and next_chars[0].islower()) or next_chars[0].isdigit()
if (/[a-z\d]/.test(nextChars[0])) {
isTrueBoundary = false;
}
}
if (!isTrueBoundary) continue;
let startNoWs = lastEnd;
while (startNoWs < splitPos && /\s/.test(text[startNoWs])) startNoWs++;
let spanEnd = splitPos;
while (spanEnd < text.length && /\s/.test(text[spanEnd])) spanEnd++;
// Nothing was consumed: only mark a one-character span when the boundary
// sits at the very end of the text. Mid-text space-less boundaries must keep
// spanEnd === end so reconstructTextFromSentences re-inserts the space.
if (spanEnd === splitPos && splitPos >= text.length) spanEnd = splitPos + 1;
sentences.push({ text: sentenceText, start: startNoWs, end: splitPos, spanEnd, gapBeforeStart: lastEnd });
lastEnd = spanEnd;
}
if (lastEnd < text.length) {
const remaining = text.slice(lastEnd).trim();
if (remaining) {
let startNoWs = lastEnd;
while (startNoWs < text.length && /\s/.test(text[startNoWs])) startNoWs++;
sentences.push({ text: remaining, start: startNoWs, end: text.length, spanEnd: text.length, gapBeforeStart: lastEnd });
}
}
if (sentences.length === 0) {
const content = text.trim();
if (content) {
let startNoWs = 0;
while (startNoWs < text.length && /\s/.test(text[startNoWs])) startNoWs++;
sentences.push({ text: content, start: startNoWs, end: text.length, spanEnd: text.length, gapBeforeStart: 0 });
}
}
return sentences;
}
+34
View File
@@ -0,0 +1,34 @@
export interface SentenceData {
text: string;
start: number;
end: number;
spanEnd: number;
gapBeforeStart: number;
}
export interface Suggestion {
original: string;
corrected: string;
sentence: string;
start_index: number;
end_index: number;
original_highlighted: string;
corrected_highlighted: string;
wordFixes: WordFix[];
}
export interface WordFix {
original: string; // misspelled word as it appears in the text
corrected: string; // replacement word
start: number; // absolute offset in the full input text
end: number; // absolute offset (exclusive)
}
export interface CorrectionResponse {
suggestions: Suggestion[];
correctedText: string;
}
export interface SentenceCorrector {
correct(sentence: string): Promise<string>;
}
@@ -0,0 +1,52 @@
# grammar-llm container — docker-compose (modeled on docker-languagetool's
# compose so admins can swap containers with a one-line service change).
#
# Mirrors erikvl87/languagetool: same default port 8010, /v2/* API.
# The model is kept in a named volume (auto-downloaded on first start from
# Hugging Face unless you pre-place it), so it survives container recreation.
services:
grammar-llm:
build:
context: .
dockerfile: Dockerfile
image: grammar-llm:latest
container_name: grammar-llm
restart: unless-stopped
ports:
- 8010:8010 # same default port as docker-languagetool
# Optional environment (uncomment and set as a mapping — compose rejects
# an environment key with no entries):
# environment:
# GRAMMAR_LLM_MODEL_FILE: GRMR-V3-G4B-Q8_0.gguf # optional: higher-quality (slower) quant
# GRAMMAR_LLM_MODEL_URL: "..." # optional: custom model source
# GRAMMAR_LLM_DOWNLOAD_AT_START: "true" # default: download model on first start
# GRAMMAR_LLM_REQUEST_TIMEOUT_MS: "55000" # per-request time budget
# GRAMMAR_LLM_PRELOAD: "true" # load model at boot
# # — (a) real LanguageTool first, LLM second (compute-saving cascade) —
# GRAMMAR_LLM_LANGUAGETOOL_URL: "http://languagetool:8010" # optional; empty = LLM only
# GRAMMAR_LLM_CASCADE_BUDGET_MS: "55000" # shared LT+LLM time budget (keep < LT-client cap; Overleaf: 60 s)
# GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: "45000" # LT's window to win the race
# GRAMMAR_LLM_LLM_GRACE_MS: "400" # ms before the LLM starts in parallel (0 = pure parallel)
# # — (b) route the LLM step to a remote OpenAI-compatible API —
# GRAMMAR_LLM_BACKEND: "openai" # local (default) | openai
# GRAMMAR_LLM_OPENAI_BASE_URL: "https://api.openai.com/v1"
# GRAMMAR_LLM_OPENAI_MODEL: "gpt-4o-mini" # required when backend=openai
# GRAMMAR_LLM_OPENAI_API_KEY: "sk-..." # only if the endpoint needs one
# # — (c) level used when the client sends none —
# GRAMMAR_LLM_DEFAULT_LEVEL: "default" # default | picky
volumes:
- grammar-llm-models:/models # persistent model storage (~2.5 GB)
# For NVIDIA GPU inference (build with the linux-x64-cuda prebuilt and
# uncomment; node-llama-cpp auto-detects CUDA when available):
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
volumes:
grammar-llm-models:
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "grammar-llm-container",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "LanguageTool-compatible (/v2/* API) HTTP grammar checker — drop-in replacement for docker-languagetool — powered by the grammar-llm engine (GRMR-V3-G4B via node-llama-cpp).",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "node build.mjs",
"start": "node dist/server.mjs",
"download-model": "node dist/download-model.mjs",
"test": "node build.mjs --quiet && node --test test/*.test.mjs",
"smoke": "bash test/smoke.sh",
"sync-core": "bash scripts/sync-core.sh"
},
"dependencies": {
"node-llama-cpp": "3.19.1"
},
"devDependencies": {
"diff": "^9.0.0",
"esbuild": "^0.25.0"
}
}
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
#
# sync-core.sh — refresh the vendored grammar-llm core in ./core/ from the
# grammar-llm source tree.
#
# The container's functional core lives in the grammar-llm project
# (/data_1/compare/grammar-llm on this machine). This project vendors a
# snapshot of src/electron/core/*.ts (the pure, framework-free engine) so the
# Docker build stays self-contained. Run this script after updating
# grammar-llm to pick up engine changes.
#
# Usage:
# scripts/sync-core.sh [path-to-grammar-llm-repo]
#
# The default location is the sibling repo at <compare-root>/grammar-llm.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Default: /data_1/compare/grammar-llm (project is at
# /data_1/compare/overleaf-cep/tools/grammar-llm-container)
DEFAULT_SRC="$(cd "$PROJECT_ROOT/../../.." && pwd)/grammar-llm"
SRC="${1:-$DEFAULT_SRC}"
if [ ! -d "$SRC/src/electron/core" ]; then
echo "ERROR: $SRC/src/electron/core not found." >&2
echo "Usage: $0 [path-to-grammar-llm-repo]" >&2
exit 1
fi
DEST="$PROJECT_ROOT/core"
mkdir -p "$DEST"
# Copy the non-test core modules (apply.ts is UI-side app logic, kept for
# completeness; the server uses sentences/clean/correction/diff/reconstruct/types).
shopt -s nullglob
for f in "$SRC"/src/electron/core/*.ts; do
b="$(basename "$f")"
case "$b" in
*.test.ts) ;;
*) cp -f "$f" "$DEST/$b" ;;
esac
done
# Record provenance
{
date -u +%Y-%m-%dT%H:%M:%SZ
echo "source: $SRC"
if git -C "$SRC" rev-parse --short HEAD >/dev/null 2>&1; then
echo "commit: $(git -C "$SRC" rev-parse --short HEAD)"
echo "status: $(git -C "$SRC" status --porcelain -- src/electron/core | head -1)"
fi
sha256sum "$SRC"/src/electron/core/*.ts | grep -v test || true
} > "$DEST/.sync-info"
echo "Synced $(ls "$DEST"/*.ts | wc -l) core module(s) from $SRC"
echo "Provenance written to $DEST/.sync-info"
+211
View File
@@ -0,0 +1,211 @@
import path from 'node:path';
const DEFAULT_MODEL_URL =
'https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf';
export interface Sampling {
temperature: number;
topK: number;
topP: number;
minP: number;
}
export interface Config {
host: string;
port: number;
allowOrigin: string;
/** Reported as the LanguageTool software version in response envelopes. */
reportedVersion: string;
modelDir: string;
modelFile: string;
modelUrl: string;
modelPath: string;
contextSize: number;
concurrency: number;
preload: boolean;
maxTextLength: number;
maxRuns: number; // 0 = unlimited checkable text runs per request
requestTimeoutMs: number;
sentenceTimeoutMs: number;
maxBodyBytes: number;
maxInFlight: number;
/** Language codes accepted by /v2/check (lower-cased, includes 'auto'). */
supportedLanguages: Set<string>;
/** What to do for a non-English language code: 'empty' (silent) or 'error' (HTTP 400). */
unsupportedLanguageMode: 'empty' | 'error';
/** LLM backend: 'local' (node-llama-cpp) or 'openai' (OpenAI-compatible API). */
backend: 'local' | 'openai';
/**
* Wire style for the 'openai' backend: 'openai' = standard /chat/completions
* (OpenAI, vLLM, llama-server, …); 'ollama' = Ollama's native /api/chat
* (needed for models like Qwen3 where the OpenAI-compat layer ignores
* think:false and burns the token budget on hidden reasoning).
*/
openaiStyle: 'openai' | 'ollama';
/**
* Optional custom base system prompt for the 'openai' backend (e.g. a
* fine-tuned model's own trained prompt, such as
* ifioravanti/mistral-grammar-checker's). Empty = the built-in grammar-llm
* prompt. The 'picky' level still appends its addendum on top.
*/
openaiSystemPrompt: string;
/** OpenAI-compatible base URL (e.g. https://api.openai.com/v1, a llama-server, vLLM, Ollama /v1, …). */
openaiBaseUrl: string;
openaiApiKey: string;
openaiModel: string;
/** Timeout for a single remote LLM call (ms). */
openaiTimeoutMs: number;
/**
* Completion-token headroom added to the sentence length (per-sentence
* max_tokens / num_predict). Some models (hidden reasoning, retokenization)
* need more than the visible output — raise when a model returns empty
* answers (e.g. muse-glimmer needs ~75 completion tokens per sentence).
*/
openaiMaxTokenPad: number;
/**
* Optional real LanguageTool container to query FIRST (cascade).
* LT runs with the requested level (picky→picky), and its matches win;
* when LT finds nothing, the LLM runs second. null = LLM only.
*/
gtUrl: string | null;
/** Timeout for the real-LanguageTool call (ms) — the LT's window to win the race. */
gtTimeoutMs: number;
/**
* Total wall-clock budget (ms) for a cascaded check (LT attempt + LLM
* fallback). Kept under LT-client caps (Overleaf's controller times out at
* 60 s and then shows nothing) so the response always arrives in time.
*/
cascadeBudgetMs: number;
/**
* How long (ms) to let a fast real-LT win before starting the LLM in the
* cascade. Keeps the "compute-saving" benefit when LT answers quickly, but
* the LLM is otherwise running in parallel so the fallback is not blocked.
* 0 = start the LLM immediately (pure parallel).
*/
llmGraceMs: number;
/** Level used when the client does not send one ('default' or 'picky'). */
defaultLevel: 'default' | 'picky';
sampling: Sampling;
logLevel: 'debug' | 'info' | 'warn' | 'error';
}
function envStr(env: NodeJS.ProcessEnv, key: string, def: string): string {
const v = env[key];
return v !== undefined && v !== '' ? v : def;
}
function envInt(env: NodeJS.ProcessEnv, key: string, def: number): number {
const v = env[key];
if (v === undefined || v === '') return def;
const n = Number(v);
if (!Number.isFinite(n)) throw new Error(`env ${key} must be an integer, got ${v}`);
return Math.trunc(n);
}
function envBool(env: NodeJS.ProcessEnv, key: string, def: boolean): boolean {
const v = env[key];
if (v === undefined || v === '') return def;
const s = String(v).toLowerCase();
return s === '1' || s === 'true' || s === 'yes' || s === 'on';
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const contextSize = Math.min(131_072, Math.max(256, envInt(env, 'GRAMMAR_LLM_CONTEXT_SIZE', 8192)));
const modelDir = envStr(env, 'GRAMMAR_LLM_MODEL_DIR', '/models');
const modelFile = envStr(env, 'GRAMMAR_LLM_MODEL_FILE', 'GRMR-V3-G4B-Q4_K_M.gguf');
const logLevel = (envStr(env, 'GRAMMAR_LLM_LOG_LEVEL', 'info').toLowerCase()) as Config['logLevel'];
if (!['debug', 'info', 'warn', 'error'].includes(logLevel)) {
throw new Error(`GRAMMAR_LLM_LOG_LEVEL must be one of debug|info|warn|error, got '${env.GRAMMAR_LLM_LOG_LEVEL}'`);
}
const unsupportedLanguageMode = envStr(env, 'GRAMMAR_LLM_UNSUPPORTED_LANGUAGE', 'empty').toLowerCase();
if (unsupportedLanguageMode !== 'empty' && unsupportedLanguageMode !== 'error') {
throw new Error(`GRAMMAR_LLM_UNSUPPORTED_LANGUAGE must be 'empty' or 'error', got '${env.GRAMMAR_LLM_UNSUPPORTED_LANGUAGE}'`);
}
const backend = envStr(env, 'GRAMMAR_LLM_BACKEND', 'local').toLowerCase();
if (backend !== 'local' && backend !== 'openai') {
throw new Error(`GRAMMAR_LLM_BACKEND must be 'local' or 'openai', got '${env.GRAMMAR_LLM_BACKEND}'`);
}
const openaiStyleRaw = envStr(env, 'GRAMMAR_LLM_OPENAI_STYLE', 'openai').toLowerCase();
if (openaiStyleRaw !== 'openai' && openaiStyleRaw !== 'ollama') {
throw new Error(`GRAMMAR_LLM_OPENAI_STYLE must be 'openai' or 'ollama', got '${env.GRAMMAR_LLM_OPENAI_STYLE}'`);
}
const openaiModel = envStr(env, 'GRAMMAR_LLM_OPENAI_MODEL', '');
if (backend === 'openai' && !openaiModel) {
throw new Error('GRAMMAR_LLM_BACKEND=openai requires GRAMMAR_LLM_OPENAI_MODEL (e.g. gpt-4o-mini, or your GRMR model id on the server)');
}
const defaultLevel = envStr(env, 'GRAMMAR_LLM_DEFAULT_LEVEL', 'default').toLowerCase();
if (defaultLevel !== 'default' && defaultLevel !== 'picky') {
throw new Error(`GRAMMAR_LLM_DEFAULT_LEVEL must be 'default' or 'picky', got '${env.GRAMMAR_LLM_DEFAULT_LEVEL}'`);
}
const gtUrlRaw = envStr(env, 'GRAMMAR_LLM_LANGUAGETOOL_URL', '');
if (gtUrlRaw && !/^https?:\/\//.test(gtUrlRaw)) {
throw new Error(`GRAMMAR_LLM_LANGUAGETOOL_URL must be an http(s) URL, got '${gtUrlRaw}'`);
}
return {
host: envStr(env, 'HOST', '0.0.0.0'),
port: Math.max(1, Math.min(65535, envInt(env, 'PORT', 8010))),
allowOrigin: envStr(env, 'GRAMMAR_LLM_ALLOW_ORIGIN', '*'),
reportedVersion: envStr(env, 'GRAMMAR_LLM_REPORTED_LT_VERSION', '6.8'),
modelDir,
modelFile,
modelUrl: envStr(env, 'GRAMMAR_LLM_MODEL_URL', DEFAULT_MODEL_URL),
modelPath: path.join(modelDir, modelFile),
contextSize,
concurrency: Math.max(1, envInt(env, 'GRAMMAR_LLM_CONCURRENCY', 1)),
preload: envBool(env, 'GRAMMAR_LLM_PRELOAD', true),
maxTextLength: Math.max(1000, envInt(env, 'GRAMMAR_LLM_MAX_TEXT_LENGTH', 100_000)),
maxRuns: Math.max(0, envInt(env, 'GRAMMAR_LLM_MAX_RUNS', 0)),
requestTimeoutMs: Math.max(1000, envInt(env, 'GRAMMAR_LLM_REQUEST_TIMEOUT_MS', 55_000)),
sentenceTimeoutMs: Math.max(1000, envInt(env, 'GRAMMAR_LLM_SENTENCE_TIMEOUT_MS', 20_000)),
maxBodyBytes: Math.max(10 * 1024, envInt(env, 'GRAMMAR_LLM_MAX_BODY_BYTES', 2 * 1024 * 1024)),
maxInFlight: Math.max(1, envInt(env, 'GRAMMAR_LLM_MAX_IN_FLIGHT', 4)),
cascadeBudgetMs: Math.max(2000, envInt(env, 'GRAMMAR_LLM_CASCADE_BUDGET_MS', 55_000)),
llmGraceMs: Math.max(0, envInt(env, 'GRAMMAR_LLM_LLM_GRACE_MS', 400)),
supportedLanguages: new Set(
envStr(env, 'GRAMMAR_LLM_LANGUAGES', 'auto,en,en-us,en-gb,en-au,en-ca,en-ie,en-nz,en-za,en-in')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
),
unsupportedLanguageMode,
backend,
openaiStyle: openaiStyleRaw as 'openai' | 'ollama',
openaiSystemPrompt: envStr(env, 'GRAMMAR_LLM_OPENAI_SYSTEM_PROMPT', ''),
openaiBaseUrl: envStr(env, 'GRAMMAR_LLM_OPENAI_BASE_URL', 'https://api.openai.com/v1').replace(/\/$/, ''),
openaiApiKey: envStr(env, 'GRAMMAR_LLM_OPENAI_API_KEY', ''),
openaiModel,
openaiTimeoutMs: Math.max(1000, envInt(env, 'GRAMMAR_LLM_OPENAI_TIMEOUT_MS', 60_000)),
openaiMaxTokenPad: Math.max(0, envInt(env, 'GRAMMAR_LLM_OPENAI_MAX_TOKEN_PAD', 40)),
gtUrl: gtUrlRaw ? gtUrlRaw.replace(/\/$/, '') : null,
gtTimeoutMs: Math.max(2000, envInt(env, 'GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS', 45_000)),
defaultLevel: defaultLevel === 'picky' ? 'picky' : 'default',
sampling: {
temperature: Number(envStr(env, 'GRAMMAR_LLM_TEMPERATURE', '0.7')),
topK: Math.max(1, envInt(env, 'GRAMMAR_LLM_TOP_K', 40)),
topP: Number(envStr(env, 'GRAMMAR_LLM_TOP_P', '0.95')),
minP: Number(envStr(env, 'GRAMMAR_LLM_MIN_P', '0.01')),
},
logLevel,
};
}
export function isLanguageSupported(cfg: Config, code: string | null | undefined): boolean {
if (code === undefined || code === null || String(code).trim() === '') return true; // LT default
const c = String(code).trim().toLowerCase();
return cfg.supportedLanguages.has(c);
}
@@ -0,0 +1,77 @@
// One-shot model downloader (start.sh calls this before the server boots).
// Uses node-llama-cpp's createModelDownloader (same API the desktop app's
// modelManager.ts adapter uses against v3.19:
// await createModelDownloader({ modelUri, dirPath, fileName, onProgress })
// → { download(): Promise, cancel(): Promise }
// onProgress(status: { downloadedSize, totalSize, ... })
//
// Usage:
// node dist/download-model.mjs --url <url> --out /models/GRMR-V3-G4B-Q4_K_M.gguf
// node dist/download-model.mjs --url <url> --dir /models --file name.gguf
import fs from 'node:fs';
import path from 'node:path';
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return undefined;
return process.argv[i + 1];
}
function die(msg: string): never {
console.error(`ERROR: ${msg}`);
process.exit(2);
}
const url = arg('url');
const out = arg('out');
const dir = arg('dir');
const file = arg('file');
if (!url) die('--url is required');
if (!out && !(dir && file)) die('either --out or both --dir and --file are required');
const outPath = out ?? path.join(dir as string, file as string);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const existing = fs.existsSync(outPath) ? fs.statSync(outPath).size : 0;
if (existing > 0) {
console.log(`Model already present (${(existing / 1024 / 1024).toFixed(0)} MB): ${outPath}`);
process.exit(0);
}
console.log(`Downloading model…`);
console.log(` url: ${url}`);
console.log(` target: ${outPath}`);
const { createModelDownloader } = await import('node-llama-cpp');
let lastLog = 0;
let lastPct = -1;
const downloader = await createModelDownloader({
modelUri: url,
dirPath: path.dirname(outPath),
fileName: path.basename(outPath),
onProgress: (status: { downloadedSize: number; totalSize: number }) => {
const now = Date.now();
const pct = status.totalSize > 0 ? Math.floor((status.downloadedSize / status.totalSize) * 100) : 0;
if (now - lastLog > 1000 || pct !== lastPct) {
lastLog = now;
lastPct = pct;
const mb = (status.downloadedSize / 1024 / 1024).toFixed(1);
const tot = status.totalSize > 0 ? ` / ${(status.totalSize / 1024 / 1024).toFixed(0)} MB` : '';
process.stdout.write(`\r ${pct}% ${mb} MB${tot} `);
}
},
});
try {
await downloader.download();
console.log('');
const size = fs.statSync(outPath).size;
console.log(`Model downloaded: ${outPath} (${(size / 1024 / 1024).toFixed(0)} MB)`);
} catch (err) {
console.error('');
console.error(`Model download failed: ${String(err)}`);
process.exit(1);
}
+264
View File
@@ -0,0 +1,264 @@
// Check engine: runs the grammar-llm core pipeline (sentence splitting, LLM
// correction, cleaning, diff/word-fix extraction, reconstruction) over the
// plain-text runs of a document, with a per-request time budget.
//
// The per-sentence guard pipeline mirrors grammar-llm's core/correction.ts
// (correctText) exactly — same order of guards, same suggestion shape — so
// container results stay identical to the desktop app for the same model.
import { cleanCorrectedText, isOnlyQuoteChange } from '../core/clean.js';
import { cleanQuotePunctuation } from '../core/correction.js';
import { extractWordFixes, highlightWordDifferences } from '../core/diff.js';
import { reconstructTextFromSentences } from '../core/reconstruct.js';
import { splitIntoSentences } from '../core/sentences.js';
import type { CorrectionResponse, Suggestion } from '../core/types.js';
import { LlamaService, type CheckLevel } from './llama.js';
import { OpenAIService } from './openai.js';
import type { Config } from './config.js';
import type { Logger } from './log.js';
import type { TextRun } from './languagetool.js';
/** Common contract of the two backends (local node-llama-cpp / OpenAI-compatible). */
interface SentenceBackend {
isReady: boolean;
ensureLoaded(): Promise<void>;
correct(sentence: string, level: CheckLevel): Promise<string>;
loadErrorText: string | null;
describe(): string;
}
function describeLocal(svc: LlamaService): string {
return `local model=${svc.modelPath}`;
}
export type EngineState = 'stopped' | 'loading' | 'ready' | 'error';
export interface EngineStatus {
state: EngineState;
errorMessage: string | null;
modelPath: string;
checksCompleted: number;
checksFailed: number;
}
export interface RunCheckResult extends CorrectionResponse {
/** true when the request time budget cut the check short. */
incomplete: boolean;
}
export class CheckEngine {
private backend: SentenceBackend;
private localLlama: LlamaService | null = null;
private state: EngineState = 'stopped';
private loadError: string | null = null;
private loading: Promise<void> | null = null;
/**
* Serialization chain: the LLM backend keeps ONE shared chat context
* (resetChatHistory() per sentence), so concurrent checks must not interleave.
* Checks queue here; each keeps its own internal deadline semantics.
*/
private checkChain: Promise<unknown> = Promise.resolve();
checksCompleted = 0;
checksFailed = 0;
constructor(private cfg: Config, private log: Logger) {
if (cfg.backend === 'openai') {
this.backend = new OpenAIService({
baseUrl: cfg.openaiBaseUrl,
apiKey: cfg.openaiApiKey,
model: cfg.openaiModel,
sampling: cfg.sampling,
timeoutMs: cfg.openaiTimeoutMs,
tokenPad: cfg.openaiMaxTokenPad,
logger: log,
style: cfg.openaiStyle,
systemPrompt: cfg.openaiSystemPrompt || undefined,
});
} else {
const svc = new LlamaService(
{
modelPath: cfg.modelPath,
contextSize: cfg.contextSize,
sampling: cfg.sampling,
logger: log,
},
log,
);
this.localLlama = svc;
this.backend = {
isReady: svc.isReady,
ensureLoaded: () => svc.ensureLoaded(),
correct: (s, l) => svc.correct(s, l),
get loadErrorText() {
return svc.loadErrorText;
},
describe: () => describeLocal(svc),
};
}
}
status(): EngineStatus {
return {
state: this.state,
errorMessage: this.loadError ?? this.backend.loadErrorText,
modelPath: this.cfg.backend === 'openai' ? `(remote: ${this.cfg.openaiModel})` : this.cfg.modelPath,
checksCompleted: this.checksCompleted,
checksFailed: this.checksFailed,
};
}
async ensureReady(): Promise<void> {
if (this.state === 'ready') return;
if (this.loading) await this.loading;
if (this.state === 'ready') return;
this.state = 'loading';
this.loading = this.backend
.ensureLoaded()
.then(() => {
this.state = 'ready';
this.loadError = null;
})
.catch((err) => {
this.state = 'error';
this.loadError = String(err);
throw err;
})
.finally(() => {
this.loading = null;
});
await this.loading;
}
/** Check a single plain-text run. Never throws for per-sentence failures. */
async correctRun(run: TextRun, deadline: number, level: CheckLevel = 'default'): Promise<RunCheckResult> {
const text = run.text;
const trimmed = text.trim();
if (!trimmed) return { suggestions: [], correctedText: '', incomplete: false };
const sentenceData = splitIntoSentences(text);
const n = sentenceData.length;
const deadlineExceeded = () => Date.now() > deadline;
// Process sentences one at a time (single shared LLM session), stopping
// at the request deadline; unprocessed sentences pass through unchanged.
const suggestions: Suggestion[] = [];
const correctedSentences: string[] = [];
let incomplete = false;
for (let i = 0; i < n; i++) {
const sent = sentenceData[i];
const sentence = sent.text;
if (sentence.length < 2) {
correctedSentences.push(sentence);
continue;
}
if (deadlineExceeded()) {
incomplete = true;
correctedSentences.push(sentence);
continue;
}
let corrected = await this.backend.correct(sentence, level);
corrected = cleanCorrectedText(corrected, sentence);
if (isOnlyQuoteChange(sentence, corrected)) corrected = sentence;
if (corrected.length > sentence.length * 2) corrected = sentence;
corrected = cleanQuotePunctuation(corrected);
// Diff once: restore the original quote style (curly vs straight) so
// quote changes are never applied to the user's text.
const diff = highlightWordDifferences(sentence, corrected);
corrected = diff.preservedCorrected;
correctedSentences.push(corrected);
if (
corrected.toLowerCase().trim() !== sentence.toLowerCase().trim() &&
corrected.trim() !== sentence.trim() &&
corrected.length <= sentence.length * 1.5 &&
!isOnlyQuoteChange(sentence, corrected)
) {
suggestions.push({
original: sentence,
corrected,
sentence: `Sentence ${i + 1}`,
start_index: sent.start,
end_index: sent.end,
original_highlighted: diff.originalHighlighted,
corrected_highlighted: diff.correctedHighlighted,
wordFixes: extractWordFixes(sentence, corrected, sent.start),
});
}
}
const correctedText = cleanQuotePunctuation(reconstructTextFromSentences(text, sentenceData, correctedSentences));
return { suggestions, correctedText, incomplete };
}
/**
* Check all runs of a resolved input (sequentially; each run is bounded by
* the shared deadline). Returns per-run results in order.
* Concurrent callers are serialized on the shared LLM context.
*/
check(
runs: TextRun[],
opts: { maxRuns?: number; requestTimeoutMs?: number; level?: CheckLevel },
): Promise<{ results: RunCheckResult[]; incomplete: boolean }> {
const task = this.checkChain.then(() => this.runCheck(runs, opts));
// Keep the chain alive without swallowing task errors for the caller.
this.checkChain = task.catch(() => { /* errors reach the caller via `task` */ });
return task;
}
private async runCheck(
runs: TextRun[],
opts: { maxRuns?: number; requestTimeoutMs?: number; level?: CheckLevel },
): Promise<{ results: RunCheckResult[]; incomplete: boolean }> {
await this.ensureReady();
const start = Date.now();
const budget = opts.requestTimeoutMs ?? this.cfg.requestTimeoutMs;
const deadline = start + budget;
const maxRuns = opts.maxRuns ?? this.cfg.maxRuns;
const level = opts.level ?? this.cfg.defaultLevel;
const results: RunCheckResult[] = [];
let incomplete = false;
const considered = maxRuns > 0 ? runs.slice(0, maxRuns) : runs;
if (maxRuns > 0 && runs.length > maxRuns) incomplete = true;
for (let i = 0; i < considered.length; i++) {
const run = considered[i];
if (Date.now() > deadline) {
incomplete = true;
this.log.warn('check time budget exhausted; remaining sentences passed through unchanged', {
budgetMs: budget,
ranMs: Date.now() - start,
run: i,
});
results.push({ suggestions: [], correctedText: run.text, incomplete: true });
continue;
}
try {
const r = await this.correctRun(run, deadline, level);
results.push(r);
if (r.incomplete) incomplete = true;
} catch (err) {
this.checksFailed++;
this.log.error('check run failed', { err: String(err) });
results.push({ suggestions: [], correctedText: run.text, incomplete: true });
incomplete = true;
}
}
this.checksCompleted++;
this.log.info('check completed', {
backend: this.backend.describe(),
level,
runs: results.length,
suggestions: results.reduce((a, r) => a + r.suggestions.length, 0),
ranMs: Date.now() - start,
incomplete,
});
return { results, incomplete };
}
}
export interface SentenceCorrectorShim {
correct(sentence: string): Promise<string>;
}
@@ -0,0 +1,439 @@
// LanguageTool response builder + request resolution.
//
// Converts grammar-llm core output (suggestions/wordFixes) into the
// LanguageTool HTTP v2 "check" JSON shape so Overleaf — and any other client
// speaking the LanguageTool protocol — works unchanged.
//
// Key invariant (from Overleaf's LanguageTool module):
// annotations.map(a => a.text ?? a.markup).join('') === editor source
// so match offsets must be offsets into the EDITOR SOURCE document.
//
// Strategy:
// - Reconstruct the full document text (text + markup segments in order).
// - Identify maximal plain-text runs (language: markup = "ignore this text").
// - Run the grammar-llm core pipeline on each run (the LLM never sees LaTeX
// markup, and no match can ever span it).
// - Rebase suggestion offsets by the run's document offset — matches end up
// in editor-source coordinates, as Overleaf expects.
//
// Response schema reference
// (languagetool: org.languagetool.tools.RuleMatchesAsJsonSerializer):
// top-level : { software, warnings, language, matches[], sentenceRanges }
// match : { message, shortMessage?, replacements[], offset, length,
// context, type, rule, ignoreForIncompleteSentence, contextForSureMatch }
// replacement: { value, suffix?, type? }
// rule : { id, description, issueType, category, tags? }
// category : { id, name }
//
// Overleaf consumes: matches[].offset/.length, matches[].message,
// matches[].replacements[] (first 5 values), matches[].rule.category.id
// (filters id === 'TYPOS' while Hunspell is active). We use
// category.id = 'GRAMMAR' so our results survive that filter.
import type { Suggestion, WordFix } from '../core/types.js';
export interface LtReplacement {
value: string;
}
export interface LtRule {
id: string;
description: string;
issueType: string;
category: { id: string; name: string };
tags?: string[];
}
export interface LtMatch {
message: string;
shortMessage?: string;
replacements: LtReplacement[];
offset: number;
length: number;
context: { text: string; offset: number; length: number };
type: { typeName: string };
rule: LtRule;
ignoreForIncompleteSentence: boolean;
contextForSureMatch: number;
}
export interface LtLanguage {
name: string;
code: string;
longCode: string;
}
export interface LtCheckResponse {
software: {
name: string;
version: string;
buildDate: string;
apiVersion: number;
premium: boolean;
status: string;
};
warnings: { incompleteResults: boolean; incompleteResultsReason?: string };
language: {
name: string;
code: string;
detectedLanguage: { name: string; code: string; confidence: number; source: string };
};
matches: LtMatch[];
sentenceRanges: Array<[number, number]>;
}
/** Static rule object reported on every match. */
const RULE: LtRule = {
id: 'GRAMMAR_LLM',
description: 'Grammar, spelling, or style issue detected by grammar-llm (GRMR-V3-G4B)',
issueType: 'Grammar',
category: { id: 'GRAMMAR', name: 'Grammar' },
tags: ['LLM', 'GRMR-V3-G4B'],
};
/** Languages we advertise (GRMR-V3-G4B is English-only). */
const LANGUAGES: LtLanguage[] = [
{ name: 'English (US)', code: 'en-US', longCode: 'en-US' },
{ name: 'English (UK)', code: 'en-GB', longCode: 'en-GB' },
{ name: 'English (Australia)', code: 'en-AU', longCode: 'en-AU' },
{ name: 'English (Canada)', code: 'en-CA', longCode: 'en-CA' },
{ name: 'English (Ireland)', code: 'en-IE', longCode: 'en-IE' },
{ name: 'English (New Zealand)', code: 'en-NZ', longCode: 'en-NZ' },
{ name: 'English (India)', code: 'en-IN', longCode: 'en-IN' },
{ name: 'English (South Africa)', code: 'en-ZA', longCode: 'en-ZA' },
{ name: 'English', code: 'en', longCode: 'en' },
];
export function getLanguages(): LtLanguage[] {
return LANGUAGES;
}
export function normalizeLanguage(code: string | null | undefined): LtLanguage | null {
if (code === null || code === undefined || String(code).trim() === '' || String(code).trim().toLowerCase() === 'auto') {
return LANGUAGES[0]; // default: English (US)
}
const c = String(code).trim().toLowerCase();
return LANGUAGES.find((l) => l.code.toLowerCase() === c) ?? null;
}
/** A plain-text segment with its absolute offset into the full document. */
export interface TextRun {
text: string;
offset: number;
}
export interface ResolvedCheckInput {
/** Full document text (markup included) — offsets/limits are against this. */
text: string;
/** Maximal checkable runs (plain text), offsets absolute into `text`. */
runs: TextRun[];
}
export type CheckInput = ResolvedCheckInput | { error: string };
/**
* Resolve the text to check from a request body (LanguageTool `text`/`data`
* parameters; `data` may be a JSON object {text, annotation:[{text|markup}]}
* — Overleaf sends annotation when it has a Lezer syntax tree).
*/
export function resolveCheckInput(body: Record<string, unknown>): CheckInput {
const rawText = body['text'];
const rawData = body['data'];
if (rawText === undefined && rawData === undefined) {
return { error: "Missing 'text' or 'data' parameter" };
}
// 1) plain `text` parameter
if (typeof rawText === 'string') {
return { text: rawText, runs: [{ text: rawText, offset: 0 }] };
}
// 2) `data` parameter (string JSON or object)
let data: unknown = rawData;
if (typeof rawData === 'string') {
try {
data = JSON.parse(rawData);
} catch {
// Not JSON — treat the raw string as plain text.
return { text: rawData, runs: [{ text: rawData, offset: 0 }] };
}
}
if (data && typeof data === 'object') {
const d = data as Record<string, unknown>;
if (typeof d['text'] === 'string' && d['annotation'] === undefined) {
return { text: d['text'], runs: [{ text: d['text'], offset: 0 }] };
}
if (Array.isArray(d['annotation'])) {
// Reconstruct the full document and collect maximal plain-text runs
// with offsets absolute into it (markup segments keep alignment).
let text = '';
let pendingStart = -1;
const runs: TextRun[] = [];
const flush = () => {
if (pendingStart >= 0) {
runs.push({ text: text.slice(pendingStart), offset: pendingStart });
pendingStart = -1;
}
};
for (const seg of d['annotation']) {
if (!seg || typeof seg !== 'object') continue;
const s = seg as Record<string, unknown>;
if (typeof s['text'] === 'string') {
if (pendingStart < 0) pendingStart = text.length;
text += s['text'];
} else if (typeof s['markup'] === 'string') {
flush();
text += s['markup'];
}
}
flush();
return { text, runs: runs.filter((r) => r.text.trim().length > 0) };
}
}
return { error: "Missing 'text' or 'data' parameter" };
}
function buildContext(fullText: string, offset: number, length: number, radius = 25): { text: string; offset: number; length: number } {
const from = Math.max(0, offset - radius);
const to = Math.min(fullText.length, offset + length + radius);
return {
text: fullText.slice(from, to),
offset: offset - from,
length: Math.max(0, length),
};
}
interface BaseMatchOpts {
message: string;
shortMessage?: string;
replacement: string;
}
function makeMatch(fullText: string, offset: number, length: number, opts: BaseMatchOpts): LtMatch {
return {
message: opts.message,
...(opts.shortMessage !== undefined ? { shortMessage: opts.shortMessage } : {}),
replacements: [{ value: opts.replacement }],
offset,
length,
context: buildContext(fullText, offset, length),
type: { typeName: 'grammar' },
rule: RULE,
ignoreForIncompleteSentence: false,
contextForSureMatch: -1,
};
}
/**
* Anchor a zero-width (pure insertion) fix to the nearest non-whitespace
* token, expressed as a LanguageTool replacement match:
* - insertion before token T (at or before T): match T, replace with added+T
* - insertion at run end: match the last token, replace with T+added
*/
function anchorInsertion(
fullText: string,
run: TextRun,
pos: number,
addedText: string,
): LtMatch | null {
const added = addedText.trim();
if (!added) return null;
const base = run.offset;
const isPunct = /^\s*[.,!?;:'"()\[\]{}]+\s*$/.test(added);
// Next non-whitespace token at/after pos (within the run).
const rest = run.text.slice(pos);
const mNext = rest.match(/^\s*(\S+)/);
if (mNext) {
const wsLen = mNext[0].length - mNext[1].length;
const tok = mNext[1];
const tokStart = pos + wsLen;
const tokEnd = tokStart + tok.length;
if (tokEnd <= run.text.length) {
const gap = run.text.slice(pos, tokStart);
// Punctuation attaches to the preceding word (no leading gap); a word
// keeps the preceding gap and gets exactly one space before the token.
const replacement = (isPunct ? added + (gap ? ' ' : '') + tok : gap + added + ' ' + tok) as string;
const collapsed = replacement.replace(/\s{2,}/g, ' ');
return makeMatch(fullText, base + pos, tokEnd - pos, {
message: isPunct ? `Add '${added}' after the previous word` : `Add '${added}' before '${tok}'`,
shortMessage: `add "${added}"`,
replacement: collapsed,
});
}
}
// No token after the insertion point → anchor on the last non-whitespace
// token of the run (insertion at the run's end).
const before = run.text.slice(0, pos);
const mLast = before.match(/(\S+)\s*$/);
if (mLast && mLast.index !== undefined) {
const tok = mLast[1];
const tokStart = mLast.index;
const replacement = (tok + (isPunct ? added : ` ${added}`)).replace(/\s{2,}/g, ' ');
return makeMatch(fullText, base + tokStart, tok.length, {
message: `Add '${added}' at the end of the sentence`,
shortMessage: `add "${added}"`,
replacement,
});
}
return null;
}
/**
* Map one core suggestion (offsets are absolute into the original full input
* text passed to correctText) to LanguageTool matches, rebased onto the
* editor document via `run`.
*/
export function suggestionToMatches(fullText: string, run: TextRun, suggestion: Suggestion): LtMatch[] {
const out: LtMatch[] = [];
const fixes = suggestion.wordFixes ?? [];
let usedWordFixes = false;
for (const fix of fixes) {
const m = wordFixToMatch(fullText, run, fix);
if (m) {
out.push(m);
usedWordFixes = true;
}
}
// Full rewrite with no word-level fixes: one sentence-level match over the
// changed middle span, replacing it with the corrected middle.
if (!usedWordFixes) {
const span = sentenceChangeSpan(run, suggestion);
if (span) {
out.push(
makeMatch(fullText, span.offset, span.length, {
message: 'Suggested grammar/style correction (rewrite by grammar-llm)',
shortMessage: 'rewrite',
replacement: span.replacement,
}),
);
}
}
return out;
}
/**
* GRMR models sometimes emit the corrected word wrapped in quotes:
* 'goed' -> '"good"', 'cat' -> '"Cat"'.
* The desktop app shows that verbatim in the review UI, but a LanguageTool-style
* client applies replacements[] verbatim, which would inject literal quote
* characters into the document (bad in LaTeX). The adapter layer therefore
* unwraps one pair of matching surrounding quotes from a word fix.
*/
function unwrapQuotedCorrection(corrected: string): string {
if (!corrected || corrected.length < 3) return corrected;
const pairs: Array<[string, string]> = [
['\u201C', '\u201D'], // “ ”
['"', '"'], // “ ”
['\u2018', '\u2019'], // ‘ ’
["'", "'"], // ' '
];
for (const [open, close] of pairs) {
if (corrected.startsWith(open) && corrected.endsWith(close) && corrected.length >= open.length + close.length + 1) {
const inner = corrected.slice(open.length, corrected.length - close.length);
if (inner.trim().length > 0 && !inner.includes('\n') && (inner.match(/\s/g) ?? []).length <= 1) {
return inner;
}
}
}
return corrected;
}
function wordFixToMatch(fullText: string, run: TextRun, fix: WordFix): LtMatch | null {
// WordFix offsets are absolute in the text passed to correctText (the run's
// text, 0-based) — rebase via run.offset.
const relStart = fix.start - 0; // fix.start is already run-relative
const relEnd = fix.end;
if (relStart < 0 || relStart > run.text.length) return null;
const base = run.offset;
if (relEnd > relStart) {
const length = Math.min(relEnd - relStart, run.text.length - relStart);
if (length <= 0) return null;
const replacement = unwrapQuotedCorrection(fix.corrected ?? '');
const message = replacement === '' ? `Remove '${fix.original}'` : `Shouldn't this be '${replacement}'?`;
return makeMatch(fullText, base + relStart, length, {
message,
shortMessage: replacement === '' ? undefined : replacement,
replacement,
});
}
// Zero width → pure insertion; anchor on a neighbouring token.
return anchorInsertion(fullText, run, relEnd, fix.corrected ?? '');
}
interface SpanResult {
offset: number;
length: number;
replacement: string;
}
/** Changed middle span of a sentence-level rewrite (run-relative). */
function sentenceChangeSpan(run: TextRun, s: Suggestion): SpanResult | null {
const original = s.original;
const corrected = s.corrected ?? '';
if (!original || !corrected || original === corrected) return null;
if (s.start_index < 0 || s.end_index <= s.start_index) return null;
const a = original;
const b = corrected;
let p = 0;
const max = Math.min(a.length, b.length);
while (p < max && a[p] === b[p]) p++;
let suffix = 0;
while (suffix < a.length - p && suffix < b.length - p && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) suffix++;
const relOffset = s.start_index + p;
const relLength = a.length - p - suffix;
if (relOffset < 0 || relLength <= 0 || relOffset >= run.text.length) return null;
const length = Math.min(relLength, run.text.length - relOffset);
if (length <= 0) return null;
return {
offset: run.offset + relOffset,
length,
replacement: b.slice(p, b.length - suffix),
};
}
/**
* Build the full LanguageTool v2 "check" JSON body.
*/
export function buildCheckResponse(opts: {
fullText: string;
matches: LtMatch[];
sentenceRanges: Array<[number, number]>;
languageCode?: string;
incomplete?: string;
reportedVersion?: string;
}): LtCheckResponse {
const lang = normalizeLanguage(opts.languageCode) ?? LANGUAGES[0];
const matches = [...opts.matches].sort((x, y) => x.offset - y.offset || x.length - y.length);
return {
software: {
name: 'LanguageTool',
version: opts.reportedVersion ?? '6.8',
buildDate: new Date().toISOString().slice(0, 10),
apiVersion: 1,
premium: false,
status: '',
},
warnings: {
incompleteResults: Boolean(opts.incomplete),
...(opts.incomplete ? { incompleteResultsReason: opts.incomplete } : {}),
},
language: {
name: lang.name,
code: lang.code,
detectedLanguage: { name: lang.name, code: lang.code, confidence: 1, source: 'given' },
},
matches,
sentenceRanges: opts.sentenceRanges ?? [],
};
}
+165
View File
@@ -0,0 +1,165 @@
// LLM inference wrapper. Mirrors grammar-llm's src/electron/llamaService.ts
// (SYSTEM_PROMPT, STOP_SEQUENCES, sampling params, per-sentence semantics) so
// the container produces the same corrections as the desktop app.
//
// node-llama-cpp v3.19 API (verified against the desktop's usage, which is
// itself "API verified against installed node-llama-cpp v3.19 types"):
// - getLlama() auto-detects CUDA / Metal / Vulkan / CPU
// - llama.loadModel({ modelPath }) -> model
// - contextSize belongs on model.createContext(), not loadModel()
// - sequences are created via context.getSequence()
// - Sampling params (temperature/topK/topP/minP/maxTokens/repeatPenalty/
// customStopTriggers) are per-prompt options for session.prompt(), NOT
// session options.
// - session.resetChatHistory() is called before each sentence.
// - node-llama-cpp is ESM with top-level await, so it is loaded via dynamic
// import() (see desktop modelManager.ts notes).
import type { Logger } from './log.js';
export interface Sampling {
temperature: number;
topK: number;
topP: number;
minP: number;
}
export interface LlamaServiceOptions {
modelPath: string;
contextSize: number;
sampling: Sampling;
pickyExtraPrompt?: string;
logger?: Logger;
}
/**
* Check level (LanguageTool's `level` parameter).
* - 'default': base correction prompt — byte-identical to the desktop app.
* - 'picky': base prompt + strict picky addendum (style, word choice,
* phrasing, punctuation — like LanguageTool's picky level
* adding rule families on top of the default set).
*/
export type CheckLevel = 'default' | 'picky';
export const SYSTEM_PROMPT =
'You are a grammar correction assistant. Correct the grammar, spelling, and punctuation of the given text. Return only the corrected text without any additional explanations or prefixes.';
export const DEFAULT_PICKY_EXTRA =
'Additionally, be highly picky, like a careful editor: also fix word choice, awkward or redundant phrasing, weak punctuation, and style issues. Prefer the most precise and idiomatic English. ';
export const STOP_SEQUENCES = ['<|endoftext|>', '<|corrected_end|>', '\n\n', 'Corrected:', 'Here is'];
interface SessionLike {
resetChatHistory(): void;
prompt(sentence: string, options: Record<string, unknown>): Promise<unknown>;
}
export class LlamaService {
private baseSession: SessionLike | null = null;
private pickySession: SessionLike | null = null;
private loading: Promise<void> | null = null;
private loadError: unknown = null;
readonly modelPath: string;
readonly contextSize: number;
readonly sampling: Sampling;
readonly pickyExtraPrompt: string;
private log: Logger;
constructor(opts: LlamaServiceOptions, log?: Logger) {
this.modelPath = opts.modelPath;
this.contextSize = opts.contextSize;
this.sampling = opts.sampling;
this.pickyExtraPrompt = opts.pickyExtraPrompt ?? DEFAULT_PICKY_EXTRA;
this.log = log ?? defaultLogger;
}
get loadErrorText(): string | null {
return this.loadError ? String(this.loadError) : null;
}
get isReady(): boolean {
return this.baseSession !== null;
}
async ensureLoaded(): Promise<void> {
if (this.baseSession) return;
if (this.loading) return this.loading;
this.loading = (async () => {
if (!this.modelPath) throw new Error('No model path configured');
// Dynamic import: node-llama-cpp is ESM with top-level await.
const { getLlama, LlamaChatSession } = await import('node-llama-cpp');
const llama = await getLlama(); // auto-detects CUDA / Metal / Vulkan / CPU
const model = await llama.loadModel({ modelPath: this.modelPath });
// sequences: 2 → one chat session per level (shared KV cache context).
const context = await model.createContext({ contextSize: this.contextSize, sequences: 2 });
// Two chat sessions on the shared KV context (separate sequences):
// - base: the desktop system prompt (default level = desktop parity)
// - picky: base + picky addendum in the SYSTEM slot. (A GRMR fine-tune
// echoes instructions placed in the user message, so the
// addendum must not go there.)
this.baseSession = new LlamaChatSession({
contextSequence: context.getSequence(),
systemPrompt: SYSTEM_PROMPT,
}) as unknown as SessionLike;
this.pickySession = new LlamaChatSession({
contextSequence: context.getSequence(),
systemPrompt: SYSTEM_PROMPT + ' ' + this.pickyExtraPrompt,
}) as unknown as SessionLike;
this.log.info('model loaded and ready', {
modelPath: this.modelPath,
contextSize: this.contextSize,
levels: ['default(base)', 'picky(base+editor addendum)'],
});
})();
try {
await this.loading;
} catch (err) {
this.loadError = err;
this.log.error('failed to load model', { err: String(err) });
throw err;
} finally {
this.loading = null;
}
}
/**
* Correct a single sentence — raw sentence in the user slot (desktop
* parity; GRMR fine-tunes echo user-slot instructions, so never put
* instructions there). Per-level selection happens via the session's
* system prompt (base vs base+picky addendum).
* Mirrors the desktop corrector: reset chat history, per-prompt sampling,
* trim, fall back to the original sentence on error so the pipeline keeps
* going.
*/
async correct(sentence: string, level: CheckLevel = 'default'): Promise<string> {
await this.ensureLoaded();
const session = level === 'picky' ? this.pickySession : this.baseSession;
if (!session) throw new Error('session not ready');
try {
session.resetChatHistory();
const response = (await session.prompt(sentence, {
maxTokens: sentence.length + 20,
temperature: this.sampling.temperature,
topK: this.sampling.topK,
topP: this.sampling.topP,
minP: this.sampling.minP,
repeatPenalty: { penalty: 1 }, // penalty 1 = disabled (desktop parity)
customStopTriggers: STOP_SEQUENCES,
})) as unknown as string;
return response.trim();
} catch (error) {
this.log.error(`Error correcting sentence '${sentence}':`, { err: String(error) });
return sentence;
}
}
}
const defaultLogger: Logger = {
debug: () => undefined,
info: (m) => console.log(m),
warn: (m) => console.warn(m),
error: (m, x) => console.error(m, x ? String(x) : ''),
};
+29
View File
@@ -0,0 +1,29 @@
/* Minimal leveled logger (no dependencies). Writes ISO-timestamped lines. */
type Level = 'debug' | 'info' | 'warn' | 'error';
const RANK: Record<Level, number> = { debug: 10, info: 20, warn: 30, error: 40 };
export interface Logger {
debug: (msg: string, extra?: Record<string, unknown>) => void;
info: (msg: string, extra?: Record<string, unknown>) => void;
warn: (msg: string, extra?: Record<string, unknown>) => void;
error: (msg: string, extra?: Record<string, unknown>) => void;
}
export function createLogger(minLevel: Level = 'info'): Logger {
const threshold = RANK[minLevel];
const emit = (level: Level, msg: string, extra?: Record<string, unknown>) => {
if (RANK[level] < threshold) return;
const line = extra && Object.keys(extra).length > 0 ? `${msg} ${JSON.stringify(extra)}` : msg;
const ts = new Date().toISOString();
if (level === 'error') console.error(`[${ts}] ${level.toUpperCase()} ${line}`);
else if (level === 'warn') console.warn(`[${ts}] ${level.toUpperCase()} ${line}`);
else console.log(`[${ts}] ${level.toUpperCase()} ${line}`);
};
return {
debug: (m, x) => emit('debug', m, x),
info: (m, x) => emit('info', m, x),
warn: (m, x) => emit('warn', m, x),
error: (m, x) => emit('error', m, x),
};
}
+204
View File
@@ -0,0 +1,204 @@
// OpenAI-compatible (chat/completions) backend — same contract as LlamaService
// so an Overleaf server without local inference capacity can redirect the LLM
// step to a hosted model (OpenAI, OpenRouter, vLLM, llama.cpp llama-server,
// Ollama /v1, a self-hosted GRMR-V3 LoRA endpoint, …).
//
// Prompt/stop/sampling semantics match src/llama.ts (and therefore the
// grammar-llm desktop app): same system prompt, same level addendum, same
// stop sequences, same sampling, same "fall back to the original sentence on
// error" behavior so the pipeline keeps going.
import type { Logger } from './log.js';
import type { Sampling, CheckLevel } from './llama.js';
import { SYSTEM_PROMPT, DEFAULT_PICKY_EXTRA, STOP_SEQUENCES } from './llama.js';
export interface OpenAIServiceOptions {
baseUrl: string;
apiKey: string;
model: string;
sampling: Sampling;
timeoutMs: number;
/** completion-token headroom over sentence length (num_predict / max_tokens margin). */
tokenPad?: number;
pickyExtraPrompt?: string;
/** Custom base system prompt (e.g. a fine-tune's trained prompt). Undefined = built-in. */
systemPrompt?: string;
logger?: Logger;
/** 'openai' = /chat/completions (default); 'ollama' = native /api/chat with think:false. */
style?: 'openai' | 'ollama';
}
export class OpenAIService {
readonly model: string;
private baseUrl: string;
private apiKey: string;
private sampling: Sampling;
private timeoutMs: number;
private tokenPad: number;
private pickyExtraPrompt: string;
private sysPrompt: string;
private log: Logger;
private style: 'openai' | 'ollama';
private pingDone = false;
constructor(opts: OpenAIServiceOptions) {
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
if (opts.style === 'ollama') this.baseUrl = this.baseUrl.replace(/\/v1$/, '');
this.apiKey = opts.apiKey;
this.model = opts.model;
this.sampling = opts.sampling;
this.timeoutMs = opts.timeoutMs;
this.tokenPad = Math.max(0, opts.tokenPad ?? 40);
this.pickyExtraPrompt = opts.pickyExtraPrompt ?? DEFAULT_PICKY_EXTRA;
this.sysPrompt = (opts.systemPrompt ?? '').trim();
this.log = opts.logger ?? defaultLogger;
this.style = opts.style ?? 'openai';
}
get isReady(): boolean {
return true; // remote backend — readiness is a connectivity question
}
get loadErrorText(): string | null {
return null;
}
/** No local model to load. Best-effort availability ping (never fatal). */
async ensureLoaded(): Promise<void> {
if (this.pingDone) return;
this.pingDone = true;
this.log.info('openai-compatible backend', { baseUrl: this.baseUrl, model: this.model, style: this.style });
try {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 5000);
const r = await fetch(`${this.baseUrl}/${this.style === 'ollama' ? 'api/tags' : 'models'}`, {
headers: this.authHeaders(),
signal: ctl.signal,
});
clearTimeout(t);
if (!r.ok) this.log.warn('model list endpoint not reachable/ok (will still attempt completions)', { status: r.status });
} catch (err) {
this.log.warn('model list endpoint unreachable (will still attempt completions)', { err: String(err) });
}
}
describe(): string {
return `openai:${this.baseUrl} model=${this.model}`;
}
private authHeaders(): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.apiKey) h['Authorization'] = `Bearer ${this.apiKey}`;
return h;
}
/**
* One chat call per sentence. `level === 'picky'` uses the system prompt +
* picky addendum; 'default' is the base prompt (identical to the desktop
* app). Returns the original sentence on any error.
*/
async correct(sentence: string, level: CheckLevel = 'default'): Promise<string> {
const base = this.sysPrompt || SYSTEM_PROMPT;
const system = level === 'picky' ? `${base} ${this.pickyExtraPrompt}` : base;
if (this.style === 'ollama') return this.correctOllama(sentence, system);
const body = {
model: this.model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: sentence },
],
temperature: this.sampling.temperature,
top_p: this.sampling.topP,
top_k: this.sampling.topK,
max_tokens: sentence.length + this.tokenPad,
stop: STOP_SEQUENCES,
};
try {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), this.timeoutMs);
const r = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(body),
signal: ctl.signal,
});
clearTimeout(t);
if (!r.ok) {
const text = await r.text().catch(() => '');
this.log.error('openai backend error', { status: r.status, body: text.slice(0, 300) });
return sentence;
}
const json: { choices?: Array<{ message?: { content?: string } }> } = await r.json();
const content = json.choices?.[0]?.message?.content;
if (typeof content !== 'string' || content.length === 0) {
this.log.error('openai backend returned no usable content', {});
return sentence;
}
return content.trim();
} catch (err) {
this.log.error(`Error correcting sentence '${sentence}' via openai backend:`, { err: String(err) });
return sentence;
}
}
/**
* Ollama native /api/chat path. Used for Qwen3-family and similar thinking
* models: the OpenAI-compat layer of this Ollama build ignores the
* top-level think flag and ~100× burns the completion budget on hidden
* reasoning (measured: 111–400 completion tokens for ~15-token answers,
* occasionally hitting max_tokens with an EMPTY answer). Native think:false
* suppresses reasoning entirely (measured: ~15 completion tokens, same
* answer) — same contract, deterministic budget.
*/
private async correctOllama(sentence: string, system: string): Promise<string> {
const body = {
model: this.model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: sentence },
],
stream: false,
think: false,
options: {
temperature: this.sampling.temperature,
top_k: this.sampling.topK,
top_p: this.sampling.topP,
stop: STOP_SEQUENCES,
num_predict: Math.max(sentence.length + this.tokenPad, 128),
},
};
try {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), this.timeoutMs);
const r = await fetch(`${this.baseUrl}/api/chat`, {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(body),
signal: ctl.signal,
});
clearTimeout(t);
if (!r.ok) {
const text = await r.text().catch(() => '');
this.log.error('ollama backend error', { status: r.status, body: text.slice(0, 300) });
return sentence;
}
const json: { message?: { content?: string } } = await r.json();
const content = json.message?.content;
if (typeof content !== 'string' || content.length === 0) {
this.log.error('ollama backend returned no usable content', {});
return sentence;
}
return content.trim();
} catch (err) {
this.log.error(`Error correcting sentence '${sentence}' via ollama backend:`, { err: String(err) });
return sentence;
}
}
}
const defaultLogger: Logger = {
debug: () => undefined,
info: (m) => console.log(m),
warn: (m) => console.warn(m),
error: (m, x) => console.error(m, x ? String(x) : ''),
};
+647
View File
@@ -0,0 +1,647 @@
// grammar-llm server — LanguageTool-compatible HTTP API (v2).
//
// Endpoints:
// GET /v2/languages LanguageTool language list
// POST /v2/check grammar check (form-urlencoded or JSON body)
// GET /v2/version version info
// GET /v2/health engine/model status (container HEALTHCHECK)
// GET / short info payload
//
// Drop-in for erikvl87/languagetool (docker-languagetool): same default port
// (8010), same wire API, `GET /v2/languages` + `POST /v2/check` semantics.
import http from 'node:http';
import { pathToFileURL } from 'node:url';
import { isLanguageSupported, loadConfig, type Config } from './config.js';
import { CheckEngine, type CheckEngine } from './engine.js';
import { createLogger, type Logger } from './log.js';
import {
buildCheckResponse,
getLanguages,
resolveCheckInput,
suggestionToMatches,
type LtMatch,
type TextRun,
} from './languagetool.js';
import { splitIntoSentences } from '../core/sentences.js';
import type { Suggestion } from '../core/types.js';
// Re-export for consumers/tests (e.g. building the real engine with the
// openai backend directly).
export { CheckEngine };
/** LLM half of a check (cascade race participant). */
interface LlmOutcome {
status: number;
body: unknown;
}
/** Real-LanguageTool half of the cascade race: null = nothing found / down. */
interface LtOutcome {
kind: 'lt';
value: Record<string, unknown> | null;
}
export interface EngineLike {
status(): {
state: string;
errorMessage: string | null;
modelPath: string;
checksCompleted: number;
checksFailed: number;
};
ensureReady(): Promise<void>;
check(
runs: TextRun[],
opts: { maxRuns?: number; requestTimeoutMs?: number; level?: 'default' | 'picky' },
): Promise<{ results: Array<{ suggestions: Suggestion[]; correctedText: string; incomplete: boolean }>; incomplete: boolean }>;
}
export interface ServerDeps {
cfg: Config;
engine: EngineLike;
log?: Logger;
}
interface RequestBody {
params: Record<string, string | undefined>;
json: Record<string, unknown> | null;
}
function readBody(req: http.IncomingMessage, maxBytes: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
let done = false;
req.on('data', (c: Buffer) => {
if (done) return;
size += c.length;
if (size > maxBytes) {
done = true;
req.removeAllListeners('data');
req.resume();
reject(Object.assign(new Error('body too large'), { statusCode: 413 }));
return;
}
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', (err) => reject(err));
});
}
function parseRequestBody(body: string, req: http.IncomingMessage): RequestBody {
const ctype = String(req.headers['content-type'] ?? '').toLowerCase();
const params: Record<string, string | undefined> = {};
let json: Record<string, unknown> | null = null;
if (ctype.includes('application/json')) {
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
json = parsed;
// Also expose scalar params (some clients mix both encodings).
for (const [k, v] of Object.entries(parsed)) {
if (typeof v === 'string') params[k] = v;
}
} catch {
// fall through to urlencoded
}
}
if (!json && (ctype.includes('application/x-www-form-urlencoded') || ctype === '' || ctype.includes('text/plain'))) {
const sp = new URLSearchParams(body);
for (const [k, v] of sp.entries()) {
params[k] = v;
}
// If it was actually JSON (no content-type), be helpful.
if (params['text'] === undefined && params['data'] === undefined && body.trim().startsWith('{')) {
try {
json = JSON.parse(body) as Record<string, unknown>;
for (const [k, v] of Object.entries(json)) {
if (typeof v === 'string') params[k] = v;
}
} catch {
/* not JSON after all */
}
}
}
return { params, json };
}
function jsonOut(res: http.ServerResponse, status: number, obj: unknown, allowOrigin: string): void {
const payload = JSON.stringify(obj);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': allowOrigin,
'Cache-Control': 'no-store',
});
res.end(payload);
}
function errorOut(res: http.ServerResponse, status: number, message: string, allowOrigin: string): void {
jsonOut(res, status, { message }, allowOrigin);
}
export class LtServer {
private inFlight = 0;
private readonly log: Logger;
constructor(private deps: ServerDeps) {
this.log = deps.log ?? createLogger(deps.cfg.logLevel);
this.httpServer = http.createServer((req, res) => {
this.log.debug('request', { method: req.method, url: req.url ?? '' });
void this.handle(req, res).catch((err) => {
this.log.error('unhandled error', { err: String(err) });
if (!res.headersSent) errorOut(res, 500, 'internal error', deps.cfg.allowOrigin);
else res.end();
});
});
}
readonly httpServer: http.Server;
listen(port: number, host: string): Promise<void> {
return new Promise((resolve, reject) => {
this.httpServer.once('error', reject);
this.httpServer.listen(port, host, () => {
this.log.info(`listening on http://${host}:${port} (LanguageTool-compatible API)`);
resolve();
});
});
}
close(): Promise<void> {
return new Promise((resolve) => this.httpServer.close(() => resolve()));
}
private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const { cfg } = this.deps;
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
const path = url.pathname;
const method = (req.method ?? 'GET').toUpperCase();
if (method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': cfg.allowOrigin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept',
'Access-Control-Max-Age': '86400',
});
res.end();
return;
}
if (method === 'GET' && (path === '/v2/languages' || path === '/languages')) {
jsonOut(res, 200, getLanguages(), cfg.allowOrigin);
return;
}
if (method === 'GET' && path === '/v2/version') {
jsonOut(res, 200, this.versionObject(), cfg.allowOrigin);
return;
}
if (method === 'GET' && (path === '/v2/health' || path === '/healthz' || path === '/health')) {
this.handleHealth(res);
return;
}
if (method === 'GET' && (path === '/' || path === '/index.html')) {
jsonOut(res, 200, { ...this.versionObject(), endpoints: ['/v2/languages', 'POST /v2/check', '/v2/version', '/v2/health'] }, cfg.allowOrigin);
return;
}
if (method === 'POST' && (path === '/v2/check' || path === '/check')) {
await this.handleCheck(req, res, url);
return;
}
if (path.startsWith('/v2/')) {
errorOut(res, 404, `unknown v2 endpoint: ${path}`, cfg.allowOrigin);
return;
}
errorOut(res, 404, `not found: ${path}`, cfg.allowOrigin);
}
private versionObject(): Record<string, unknown> {
const { cfg } = this.deps;
const st = this.deps.engine.status();
return {
name: 'LanguageTool',
version: cfg.reportedVersion,
apiVersion: 1,
engine: {
name: 'grammar-llm',
backend: cfg.backend,
...(cfg.backend === 'openai' ? { model: cfg.openaiModel } : { model: cfg.modelFile }),
state: st.state,
},
languageToolProxy: cfg.gtUrl ?? null,
defaultLevel: cfg.defaultLevel,
};
}
private handleHealth(res: http.ServerResponse): void {
const { cfg } = this.deps;
const st = this.deps.engine.status();
const ok = st.state === 'ready';
jsonOut(res, ok ? 200 : 503, {
status: ok ? 'ok' : 'degraded',
state: st.state,
...(st.errorMessage ? { error: st.errorMessage } : {}),
model: {
file: cfg.modelFile,
path: cfg.modelPath,
},
checks: { completed: st.checksCompleted, failed: st.checksFailed },
backend: cfg.backend,
languageToolProxy: cfg.gtUrl ?? null,
defaultLevel: cfg.defaultLevel,
inFlight: this.inFlight,
version: cfg.reportedVersion,
uptimeSec: Math.round(process.uptime()),
}, cfg.allowOrigin);
}
/**
* Forward the request to a real LanguageTool container (v2 API), exactly
* like LanguageTool itself would see it:
* - original `text` or `data` (annotation) payload — LT handles Overleaf's
* LaTeX annotations natively and returns offsets in editor-document space
* - `level` always present ('picky'→'picky', otherwise 'default')
* - `disabledRules` / `language` passed through
* Returns the LT response when it produced ≥1 match, otherwise null (the
* caller then runs the LLM). Never throws — LT failure means "try the LLM".
*/
private async forwardToLanguagetool(args: {
cfg: Config;
body: Buffer;
params: Record<string, string | undefined>;
bodyObj: Record<string, unknown>;
language: string;
level: 'default' | 'picky';
deadline: number;
}): Promise<Record<string, unknown> | null> {
const { cfg, body, params, bodyObj, language, level, deadline } = args;
const gtUrl = cfg.gtUrl;
if (!gtUrl) return null;
// Rebuild a clean form body from the merged params (LT v2 is form-based).
const text = typeof bodyObj['text'] === 'string' ? bodyObj['text'] : params['text'];
const data = typeof bodyObj['data'] === 'string' ? bodyObj['data'] : bodyObj['data'] ? JSON.stringify(bodyObj['data']) : params['data'];
if (!text && !data) return null; // nothing to forward (caller 400s)
const form = new URLSearchParams();
form.set('language', language);
form.set('level', level); // ← the picky flag, always explicit
if (typeof text === 'string') form.set('text', text);
if (typeof data === 'string') form.set('data', data);
const disabledRules = params['disabledRules'];
if (disabledRules) form.set('disabledRules', disabledRules);
const remaining = Math.max(500, Math.min(cfg.gtTimeoutMs, deadline - Date.now()));
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), remaining);
try {
const res = await fetch(`${gtUrl}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
signal: ctl.signal,
});
if (!res.ok) {
this.log.warn('real LanguageTool returned an error — falling back to grammar-llm', { status: res.status });
return null;
}
const json = (await res.json()) as Record<string, unknown>;
const matches = Array.isArray(json['matches']) ? (json['matches'] as unknown[]) : [];
if (matches.length === 0) return null; // nothing found → LLM runs second
// Pass the genuine LT response through (add missing envelope bits if
// the upstream LT is an unusual build).
if (json['software'] === undefined) json['software'] = { name: 'LanguageTool', version: cfg.reportedVersion, apiVersion: 1 };
if (json['warnings'] === undefined) json['warnings'] = { incompleteResults: false };
return json;
} catch (err) {
this.log.warn('real LanguageTool unreachable — falling back to grammar-llm', { err: String(err), gtUrl });
return null;
} finally {
clearTimeout(timer);
}
}
private async handleCheck(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {
const { cfg, engine } = this.deps;
// In-flight guard (protect the single LLM session from pileups).
if (this.inFlight >= cfg.maxInFlight) {
errorOut(res, 503, 'server busy — too many checks in flight, retry shortly', cfg.allowOrigin);
return;
}
this.inFlight++;
try {
const body = await readBody(req, cfg.maxBodyBytes).catch((err: unknown) => {
if ((err as { statusCode?: number })?.statusCode === 413) {
errorOut(res, 413, 'request body too large', cfg.allowOrigin);
return null;
}
throw err;
});
if (body === null) return;
// Query-string params may also carry the request (LT allows both); merged into bodyObj below.
const parsed = parseRequestBody(body.toString('utf8'), req);
const bodyObj: Record<string, unknown> = parsed.json ?? {};
// LanguageTool accepts text/data/level/language as query or body params;
// merge so both styles work.
if (bodyObj['text'] === undefined && parsed.params['text'] !== undefined) bodyObj['text'] = parsed.params['text'];
if (bodyObj['data'] === undefined && parsed.params['data'] !== undefined) bodyObj['data'] = parsed.params['data'];
const params: Record<string, string | undefined> = {
...Object.fromEntries(url.searchParams.entries()),
...Object.fromEntries(Object.entries(parsed.params)),
};
const language = params['language'] ?? 'auto';
// LanguageTool's `level`: 'picky' or 'default'. The level is ALWAYS forwarded
// to the real LanguageTool container (picky→picky) and drives the LLM mode.
const levelRaw = String(params['level'] ?? bodyObj['level'] ?? '').trim().toLowerCase();
const level: 'default' | 'picky' = levelRaw === 'picky' ? 'picky' : levelRaw === 'default' ? 'default' : cfg.defaultLevel;
if (levelRaw) this.log.debug('level requested', { levelRaw, resolved: level });
const disabledRules = params['disabledRules'] ?? (parsed.json?.['disabledRules'] as string | undefined);
if (disabledRules) this.log.debug('disabledRules (forwarded to real LT when configured; ignored by grammar-llm)', { disabledRules });
// 2) resolve text/runs (also validates text length)
const input = resolveCheckInput(bodyObj);
if ('error' in input) {
errorOut(res, 400, input.error, cfg.allowOrigin);
return;
}
const fullText = input.text;
if (fullText.trim().length === 0) {
jsonOut(res, 200, buildCheckResponse({ fullText, matches: [], sentenceRanges: [], languageCode: language, reportedVersion: cfg.reportedVersion }), cfg.allowOrigin);
return;
}
if (fullText.length > cfg.maxTextLength) {
errorOut(res, 413, `text too long (${fullText.length} > ${cfg.maxTextLength})`, cfg.allowOrigin);
return;
}
// 3) LANGUAGE GATE for the LLM (English model only). The real LT (when
// configured as a cascade front) can still serve other languages below.
// (Applied early so an unsupported language never spends LLM compute.)
if (!isLanguageSupported(cfg, language)) {
if (cfg.unsupportedLanguageMode === 'error') {
errorOut(res, 400, `language not supported (this deployment runs the English grammar-llm model; got '${language}')`, cfg.allowOrigin);
return;
}
this.log.info('unsupported language requested — returning empty matches', { language });
jsonOut(res, 200, buildCheckResponse({ fullText: '', matches: [], sentenceRanges: [], languageCode: language, reportedVersion: cfg.reportedVersion }), cfg.allowOrigin);
return;
}
// 4) CASCADE (when a real LanguageTool is configured): run the real LT
// and the LLM on ONE shared wall-clock budget (cascadeBudgetMs, kept
// under Overleaf's 60 s controller timeout — over that it silently shows
// nothing). Rules:
// - real LT with ≥1 match wins the moment it resolves (fast path,
// compute-saving: the LLM start is cancelled if it has not begun);
// - the LLM starts after llmGraceMs so a warm LT can win for free, but
// it then runs in PARALLEL — a slow/empty LT no longer blocks it;
// - whichever resolves first with a 200 result is served; 400/503 LLM
// outcomes only surface when the LT had nothing either.
if (cfg.gtUrl) {
const cascadeDeadline = Date.now() + cfg.cascadeBudgetMs;
const t0 = Date.now();
const ltPromise = this.forwardToLanguagetool({
cfg, body, params, bodyObj, language, level,
deadline: cascadeDeadline,
}).then((value) => ({ kind: 'lt' as const, value }));
const gate = { cancelled: false };
let cancelGate = (): void => { /* default: grace is 0 */ };
const gatePromise = new Promise<void>((resolve) => {
if (cfg.llmGraceMs === 0) { resolve(); return; }
const t = setTimeout(resolve, cfg.llmGraceMs);
cancelGate = () => { clearTimeout(t); gate.cancelled = true; resolve(); };
});
const llmPromise = (async () => {
await gatePromise;
if (gate.cancelled) {
// LT already won with matches: never start the LLM (zero waste).
return new Promise<LlmOutcome>(() => { /* suppressed on purpose */ });
}
return this.runLlmCheck({ cfg, language, level, fullText, runs: input.runs, cascadeDeadline });
})();
const outcome = await this.resolveCascade(ltPromise, llmPromise, () => cancelGate());
if (outcome.ltWon) {
this.log.info('real LanguageTool produced matches — LLM skipped', { gtUrl: cfg.gtUrl, level, ms: Date.now() - t0, matches: (outcome.body as { matches?: unknown[] })?.matches?.length });
} else {
this.log.info('real LT found no matches (or was unreachable) — serving LLM result', { gtUrl: cfg.gtUrl, level });
}
jsonOut(res, outcome.status, outcome.body, cfg.allowOrigin);
return;
}
// 5) LLM-ONLY path (no real LT configured).
const outcome = await this.runLlmCheck({ cfg, language, level, fullText, runs: input.runs, cascadeDeadline: null });
if (outcome.status !== 200) {
errorOut(res, outcome.status as 400 | 500 | 503, String((outcome.body as { message?: string })?.message ?? 'check failed'), cfg.allowOrigin);
return;
}
jsonOut(res, 200, outcome.body, cfg.allowOrigin);
} finally {
this.inFlight--;
}
}
/**
* The LLM half of a check (language gate → engine → LT JSON). Resolves to a
* full HTTP outcome, never throws (internal errors become 500 outcomes).
*/
private async runLlmCheck(args: {
cfg: Config;
language: string;
level: 'default' | 'picky';
fullText: string;
runs: TextRun[];
/** shared cascade deadline (Date.now()+budget), or null for LLM-only. */
cascadeDeadline: number | null;
}): Promise<LlmOutcome> {
const { cfg, language, level, fullText, runs, cascadeDeadline } = args;
const { engine } = this.deps;
try {
await engine.ensureReady();
} catch { /* surfaced via status() */ }
const st = engine.status();
if (st.state !== 'ready') {
const hint = st.state === 'error'
? ` engine error: ${st.errorMessage ?? 'unknown'}`
: ' (model still loading or downloading)';
return { status: 503, body: { message: `grammar-llm engine not ready${hint}` } };
}
try {
// The LLM gets the budget that is LEFT of the shared cascade window
// (or its own default in LLM-only mode), so the total response time
// stays under LT-client caps like Overleaf's 60 s.
const budgetMs = cascadeDeadline
? Math.min(cfg.requestTimeoutMs, Math.max(1500, cascadeDeadline - Date.now()))
: cfg.requestTimeoutMs;
const { results, incomplete } = await engine.check(runs, {
maxRuns: cfg.maxRuns,
requestTimeoutMs: budgetMs,
level,
});
const matches: LtMatch[] = [];
const sentenceRanges: Array<[number, number]> = [];
results.forEach((r, i) => {
for (const s of r.suggestions) {
matches.push(...suggestionToMatches(fullText, runs[i], s));
}
for (const s of splitIntoSentences(runs[i].text)) {
sentenceRanges.push([runs[i].offset + s.start, runs[i].offset + s.end]);
}
});
sentenceRanges.sort((a, b) => a[0] - b[0]);
this.log.info('LLM check completed', {
language, level, runs: results.length,
matches: matches.length, incomplete,
budgetMs,
});
const incompleteReason = incomplete
? 'Check stopped early: per-request time budget exhausted. Only a subset of the document was checked.'
: undefined;
return {
status: 200,
body: buildCheckResponse({
fullText,
matches,
sentenceRanges,
languageCode: language,
incomplete: incompleteReason,
reportedVersion: cfg.reportedVersion,
}),
};
} catch (err) {
this.log.error('LLM check failed', { err: String(err) });
return { status: 500, body: { message: 'grammar-llm engine failed during check' } };
}
}
/**
* Race between the real-LT outcome and the LLM outcome with LT preference:
* - LT resolves with matches → served immediately (LLM cancelled/ignored);
* - LLM resolves with 200 → served immediately (LT is a lost cause then);
* - a 400/503 LLM outcome is held back while the LT is still running;
* it is served only if the LT also had nothing.
*/
private async resolveCascade(
ltPromise: Promise<LtOutcome>,
llmPromise: Promise<LlmOutcome>,
onLltWin: () => void,
/**
* When the LLM is ready but the LT is still running, give the LT this many
* extra ms (capped) to land a match before serving the LLM. Keeps "real LT
* first" without waiting out the LT's full budget.
*/
ltExtraWaitMs = 1200,
): Promise<{ status: number; body: unknown; ltWon: boolean }> {
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
// Tag each side so we can identify which promise settled (identity is not
// preserved through Promise.race). The ids are unique, one per side.
const ltTagged = ltPromise.then((outcome) => ({ id: 'lt' as const, outcome }));
const llmTagged = llmPromise.then((outcome) => ({ id: 'llm' as const, outcome }));
const pending: Array<Promise<{ id: 'lt' | 'llm'; outcome: LtOutcome | LlmOutcome }>> = [ltTagged, llmTagged];
let staleLlm: { status: number; body: unknown } | undefined;
while (pending.length > 0) {
const { id, outcome } = await Promise.race(pending);
// Drop whichever entry settled, by its unique tag (not by object identity).
const settledWrapper = id === 'lt' ? ltTagged : llmTagged;
const at = pending.indexOf(settledWrapper);
if (at >= 0) pending.splice(at, 1);
if (id === 'lt') {
if (outcome.value) {
onLltWin(); // cancel the LLM start if it has not begun yet
return { status: 200, body: outcome.value, ltWon: true };
}
if (pending.length === 0) {
return staleLlm
? { status: staleLlm.status, body: staleLlm.body, ltWon: false }
: { status: 200, body: { software: { name: 'LanguageTool', version: '6.8', apiVersion: 1 }, warnings: {}, matches: [] }, ltWon: false };
}
continue; // wait for the LLM
}
// LLM outcome:
if (outcome.status === 200) {
if (pending.length > 0) {
// The LT is still running: give it a short capped chance to win.
const maybeLt = await Promise.race([
pending[0].then((o) => o),
sleep(Math.max(0, Math.min(ltExtraWaitMs, 1200))).then(() => null),
]);
if (maybeLt && maybeLt.id === 'lt' && maybeLt.outcome.value) {
onLltWin();
return { status: 200, body: maybeLt.outcome.value, ltWon: true };
}
}
return { status: 200, body: outcome.body, ltWon: false };
}
staleLlm = { status: outcome.status, body: outcome.body };
if (pending.length === 0) return { status: outcome.status, body: outcome.body, ltWon: false };
}
throw new Error('cascade resolved without an outcome');
}
}
export async function main(): Promise<void> {
const cfg = loadConfig();
const log = createLogger(cfg.logLevel);
const engine = new CheckEngine(cfg, log);
const lt = new LtServer({ cfg, engine, log });
if (cfg.preload) {
if (cfg.backend === 'openai') {
log.info('openai-compatible backend — no local model to preload', { baseUrl: cfg.openaiBaseUrl, model: cfg.openaiModel });
} else {
log.info('preloading model (first check waits for this to finish)…', { modelPath: cfg.modelPath });
}
engine.ensureReady().catch(() => {
// surfaced via /v2/health and as 503s on /v2/check
});
}
await lt.listen(cfg.port, cfg.host);
const shutdown = (sig: string) => {
log.info(`${sig} received, shutting down`);
void lt.close().then(() => process.exit(0));
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
// Auto-start when executed directly (node dist/server.mjs); no-op when imported by tests.
const isMain = (() => {
const entry = process.argv[1];
if (!entry) return false;
try {
return import.meta.url === pathToFileURL(entry).href;
} catch {
return false;
}
})();
if (isMain) {
main().catch((err) => {
console.error('fatal:', err);
process.exit(1);
});
}
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# grammar-llm container entrypoint.
#
# 1. Warn about LanguageTool-container env vars that don't apply here
# (drop-in sanity: admins often copy the old container's env verbatim).
# 2. Ensure the model file is present (download on first start by default).
# 3. Exec the HTTP server.
set -euo pipefail
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- drop-in compatibility warnings ---------------------------------------
while IFS= read -r name; do
[ -n "$name" ] || continue
echo "WARN: env '$name' is a LanguageTool (Java) setting; the grammar-llm container ignores it." >&2
done < <(env | grep -Eo '^langtool_[A-Za-z0-9_]*' | sort -u)
for var in Java_Xms Java_Xmx; do
case "$var" in Java_Xms) val="${Java_Xms:-}" ;; Java_Xmx) val="${Java_Xmx:-}" ;; *) val="" ;; esac
if [ -n "$val" ]; then
echo "WARN: env '$var' is a LanguageTool (Java) setting; the grammar-llm container ignores it." >&2
fi
done
# --- configuration (defaults match docker-languagetool expectations) -------
BACKEND="${GRAMMAR_LLM_BACKEND:-local}"
MODEL_DIR="${GRAMMAR_LLM_MODEL_DIR:-/models}"
MODEL_FILE="${GRAMMAR_LLM_MODEL_FILE:-GRMR-V3-G4B-Q4_K_M.gguf}"
MODEL_URL="${GRAMMAR_LLM_MODEL_URL:-https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf}"
MODEL_PATH="$MODEL_DIR/$MODEL_FILE"
DOWNLOAD_AT_START="${GRAMMAR_LLM_DOWNLOAD_AT_START:-true}"
export GRAMMAR_LLM_MODEL_DIR="$MODEL_DIR"
export GRAMMAR_LLM_MODEL_FILE="$MODEL_FILE"
export GRAMMAR_LLM_MODEL_URL="$MODEL_URL"
mkdir -p "$MODEL_DIR"
if [ "${BACKEND}" != "openai" ]; then
if [ ! -s "$MODEL_PATH" ]; then
if [ "$DOWNLOAD_AT_START" = "true" ]; then
echo "INFO: model not found at $MODEL_PATH — downloading $(basename "$MODEL_PATH") ..."
node "$APP_DIR/dist/download-model.mjs" --url "$MODEL_URL" --out "$MODEL_PATH"
else
echo "ERROR: model not found at $MODEL_PATH and GRAMMAR_LLM_DOWNLOAD_AT_START=false" >&2
echo " Mount the model file into $MODEL_DIR or set GRAMMAR_LLM_DOWNLOAD_AT_START=true." >&2
exit 1
fi
fi
if [ ! -s "$MODEL_PATH" ]; then
echo "ERROR: model still missing at $MODEL_PATH after download step" >&2
exit 1
fi
echo "INFO: using model $MODEL_PATH (local backend)"
else
echo "INFO: backend=openai (model=${GRAMMAR_LLM_OPENAI_MODEL:-<unset>} base=${GRAMMAR_LLM_OPENAI_BASE_URL:-https://api.openai.com/v1}) — no local model needed"
fi
echo "INFO: starting grammar-llm server (LanguageTool-compatible API) on port ${PORT:-8010}"
exec node "$APP_DIR/dist/server.mjs"
@@ -0,0 +1,303 @@
// Unit tests for the LanguageTool mapper & request resolution.
// Runs against the esbuild bundle (dist/languagetool.mjs) with the vendored
// core — no LLM, no network.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildCheckResponse,
getLanguages,
normalizeLanguage,
resolveCheckInput,
suggestionToMatches,
} from '../dist/languagetool.mjs';
const RUN = { text: 'He said teh thing.', offset: 0 };
function suggestionWith(fixes, original = 'He said teh thing.', corrected = 'He said the thing.') {
return {
original,
corrected,
sentence: 'Sentence 1',
start_index: 0,
end_index: original.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: fixes,
};
}
test('replacement word fix → LT match with absolute offset', () => {
const matches = suggestionToMatches(RUN.text, RUN, suggestionWith([{ original: 'teh', corrected: 'the', start: 8, end: 11 }]));
assert.equal(matches.length, 1);
const m = matches[0];
assert.equal(m.offset, 8);
assert.equal(m.length, 3);
assert.deepEqual(m.replacements, [{ value: 'the' }]);
assert.match(m.message, /'the'/);
assert.equal(m.rule.category.id, 'GRAMMAR'); // must not be 'TYPOS' (Overleaf filter)
assert.equal(m.context.text.slice(m.context.offset, m.context.offset + m.length), 'teh');
});
test('deletion word fix → empty replacement', () => {
const text = 'He said very very thing.';
const run = { text, offset: 0 };
const matches = suggestionToMatches(text, run, {
original: text,
corrected: 'He said very thing.',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: 'very', corrected: '', start: 13, end: 17 }],
});
assert.equal(matches.length, 1);
assert.equal(matches[0].offset, 13);
assert.equal(matches[0].length, 4);
assert.deepEqual(matches[0].replacements, [{ value: '' }]);
assert.match(matches[0].message, /Remove/);
});
test('punctuation insertion after a word attaches to the previous word (core ground truth)', () => {
// core emit for 'slow and happy' -> 'slowly, and happy':
// slow->slowly [0,4) + pure insertion ',' at pos 4
const text = 'slow and happy';
const run = { text, offset: 0 };
const matches = suggestionToMatches(text, run, {
original: text,
corrected: 'slowly, and happy',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: 'slow', corrected: 'slowly', start: 0, end: 4 }, { original: '', corrected: ',', start: 4, end: 4 }],
});
const m = matches[1];
assert.equal(m.offset, 4);
assert.equal(m.length, 4); // covers ' and' (gap + token)
assert.equal(m.replacements[0].value, ', and');
// Apply both matches (reverse offset order) to the original doc.
let doc = text;
for (const m2 of [...matches].sort((a, b) => b.offset - a.offset)) {
doc = doc.slice(0, m2.offset) + m2.replacements[0].value + doc.slice(m2.offset + m2.length);
}
assert.equal(doc, 'slowly, and happy');
});
test('word insertion before a token keeps spacing (core ground truth)', () => {
// core emit for 'quick and happy' -> 'quick and very happy':
// pure insertion 'very ' at pos 10 (before 'happy')
const text = 'quick and happy';
const run = { text, offset: 0 };
const matches = suggestionToMatches(text, run, {
original: text,
corrected: 'quick and very happy',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: '', corrected: 'very ', start: 10, end: 10 }],
});
const m = matches[0];
assert.equal(m.offset, 10);
assert.equal(m.length, 5); // token 'happy'
assert.equal(m.replacements[0].value, 'very happy');
const result = text.slice(0, m.offset) + m.replacements[0].value + text.slice(m.offset + m.length);
assert.equal(result, 'quick and very happy');
});
test('punctuation insertion right before a token (no gap)', () => {
const text = 'He ate andbutter fast';
const run = { text, offset: 0 };
const matches = suggestionToMatches(text, run, {
original: text,
corrected: 'He ate and,butter fast',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: '', corrected: ',', start: 10, end: 10 }],
});
const m = matches[0];
assert.equal(m.offset, 10);
assert.equal(m.length, 6); // token 'butter'
assert.equal(m.replacements[0].value, ',butter');
const result = text.slice(0, m.offset) + m.replacements[0].value + text.slice(m.offset + m.length);
assert.equal(result, 'He ate and,butter fast');
});
test('insertion at run end anchors on last token', () => {
const text = 'He ran quick';
const run = { text, offset: 0 };
const matches = suggestionToMatches(text, run, {
original: text,
corrected: 'He ran quick.',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: '', corrected: '.', start: 12, end: 12 }],
});
const m = matches[0];
const result = text.slice(0, m.offset) + m.replacements[0].value + text.slice(m.offset + m.length);
assert.equal(result, 'He ran quick.');
});
test('model quote artifacts are unwrapped from replacements', () => {
// GRMR sometimes wraps the corrected word in quotes: 'goed' -> '"good"'
const text = 'The cat is goed.';
const run = { text, offset: 0 };
for (const corrected of ['"good"', '\u201Cgood\u201D', "'good'"]) {
const matches = suggestionToMatches(text, run, {
original: text,
corrected: 'The cat is good.',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: 'goed', corrected, start: 11, end: 15 }],
});
assert.equal(matches.length, 1, 'one match for ' + corrected);
assert.equal(matches[0].replacements[0].value, 'good');
}
// …same for a different word — a grammar model quoting its replacement is a
// display artifact, not a request to add quotes to the document
const m2 = suggestionToMatches(text, run, {
original: text,
corrected: 'The cat is wonderful.',
sentence: 'Sentence 1',
start_index: 0,
end_index: text.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: 'goed', corrected: '"wonderful"', start: 11, end: 15 }],
});
assert.equal(m2[0].replacements[0].value, 'wonderful');
});
test('sentence-level rewrite (no word fixes) → partial-span replacement, offset-rebased', () => {
const text = 'The committee met every day';
const run = { text, offset: 100 };
const s = suggestionWith([], 'The committee met every day', 'The committee has met daily');
const matches = suggestionToMatches(text, run, s);
assert.equal(matches.length, 1);
const m = matches[0];
const result = text.slice(0, m.offset - 100) + m.replacements[0].value + text.slice(m.offset - 100 + m.length);
assert.equal(result, 'The committee has met daily');
assert.ok(m.offset >= 100); // rebased onto the document
});
test('matches outside the run text are dropped', () => {
const run = { text: 'short', offset: 0 };
const matches = suggestionToMatches('short', run, suggestionWith([{ original: 'x', corrected: 'y', start: 99, end: 100 }]));
assert.equal(matches.length, 0);
});
test('resolveCheckInput: plain text param', () => {
const r = resolveCheckInput({ text: 'Hello world' });
assert.ok(!('error' in r));
assert.equal(r.text, 'Hello world');
assert.deepEqual(r.runs, [{ text: 'Hello world', offset: 0 }]);
});
test('resolveCheckInput: annotation (Overleaf LaTeX payload) keeps offset alignment', () => {
const annotation = [
{ text: 'The dog ' },
{ markup: '{\\textbf{runs}} ' },
{ text: 'fast.' },
];
const r = resolveCheckInput({ data: JSON.stringify({ annotation }) });
assert.ok(!('error' in r));
assert.equal(r.text, 'The dog {\\textbf{runs}} fast.');
assert.deepEqual(r.runs, [
{ text: 'The dog ', offset: 0 },
{ text: 'fast.', offset: 24 },
]);
});
test('resolveCheckInput: { text } data object', () => {
const r = resolveCheckInput({ data: { text: 'abc' } });
assert.equal(r.text, 'abc');
assert.deepEqual(r.runs, [{ text: 'abc', offset: 0 }]);
});
test('resolveCheckInput: missing text and data → error', () => {
const r = resolveCheckInput({});
assert.ok('error' in r);
assert.match(r.error, /Missing/);
});
test('resolveCheckInput: non-JSON string data is treated as text', () => {
const r = resolveCheckInput({ data: 'plain string data' });
assert.equal(r.text, 'plain string data');
});
test('normalizeLanguage: auto + en variants accepted, de rejected', () => {
assert.equal(normalizeLanguage(null).code, 'en-US');
assert.equal(normalizeLanguage('auto').code, 'en-US');
assert.equal(normalizeLanguage('en-GB').code, 'en-GB');
assert.equal(normalizeLanguage('EN').code, 'en');
assert.equal(normalizeLanguage('de'), null);
});
test('getLanguages: LanguageTool list shape (name/code/longCode)', () => {
const langs = getLanguages();
assert.ok(Array.isArray(langs));
for (const l of langs) {
assert.ok(typeof l.name === 'string');
assert.ok(typeof l.code === 'string');
assert.ok(typeof l.longCode === 'string');
}
assert.ok(langs.some((l) => l.code === 'en-US'));
});
test('buildCheckResponse: LT envelope shape + sorted matches + warnings', () => {
const body = buildCheckResponse({
fullText: 'He said teh thing.',
matches: [
{
message: 'm2',
replacements: [{ value: 'b' }],
offset: 10,
length: 1,
context: { text: 'x', offset: 0, length: 1 },
type: { typeName: 'grammar' },
rule: { id: 'GRAMMAR_LLM', description: 'd', issueType: 'Grammar', category: { id: 'GRAMMAR', name: 'Grammar' } },
ignoreForIncompleteSentence: false,
contextForSureMatch: -1,
},
{
message: 'm1',
replacements: [{ value: 'a' }],
offset: 3,
length: 1,
context: { text: 'x', offset: 0, length: 1 },
type: { typeName: 'grammar' },
rule: { id: 'GRAMMAR_LLM', description: 'd', issueType: 'Grammar', category: { id: 'GRAMMAR', name: 'Grammar' } },
ignoreForIncompleteSentence: false,
contextForSureMatch: -1,
},
],
sentenceRanges: [
[0, 18],
],
languageCode: 'en-GB',
incomplete: 'stopped early',
});
assert.equal(body.software.name, 'LanguageTool');
assert.equal(body.software.apiVersion, 1);
assert.equal(body.software.premium, false);
assert.equal(body.language.code, 'en-GB');
assert.equal(body.language.detectedLanguage.source, 'given');
assert.deepEqual(body.matches.map((m) => m.offset), [3, 10]);
assert.equal(body.warnings.incompleteResults, true);
assert.equal(body.warnings.incompleteResultsReason, 'stopped early');
assert.deepEqual(body.sentenceRanges, [[0, 18]]);
});
@@ -0,0 +1,737 @@
// HTTP API tests with a deterministic fake engine (no LLM, no network).
// Mirrors how Overleaf's LanguageToolController talks to a LT-compatible box:
// GET /v2/languages
// POST /v2/check (x-www-form-urlencoded: language, level, data/annotation,
// disabledRules — see LanguageToolController.mjs)
import test from 'node:test';
import assert from 'node:assert/strict';
import { LtServer } from '../dist/server.mjs';
import { CheckEngine } from '../dist/server.mjs';
import { loadConfig } from '../dist/config.mjs';
import http from 'node:http';
function makeConfig(over = {}) {
const env = {
PORT: '8010',
HOST: '127.0.0.1',
GRAMMAR_LLM_MODEL_DIR: '/tmp',
GRAMMAR_LLM_PRELOAD: 'false',
GRAMMAR_LLM_LOG_LEVEL: 'warn',
...over,
};
return loadConfig(env);
}
/** Deterministic "engine": corrects the literal word 'teh' → 'the' wherever it
* appears, and inserts a comma after 'quick'. */
class FakeEngine {
state = 'ready';
statusError = null;
checkCalls = 0;
lastLevel = null;
constructor() {}
status() {
return {
state: this.state,
errorMessage: this.statusError,
modelPath: '/tmp/fake.gguf',
checksCompleted: 0,
checksFailed: 0,
};
}
async ensureReady() {
if (this.state !== 'ready') throw new Error('model not ready');
}
async check(runs, opts = {}) {
if (this.state !== 'ready') throw new Error('not ready');
this.checkCalls++;
this.lastLevel = opts.level ?? null;
const results = [];
for (const run of runs) {
const out = { suggestions: [], correctedText: run.text, incomplete: false };
const t = run.text;
const idx = t.indexOf('teh');
if (idx >= 0) {
const corrected = t.slice(0, idx) + 'the' + t.slice(idx + 3);
out.correctedText = corrected;
out.suggestions = [
{
original: t,
corrected,
sentence: 'Sentence 1',
start_index: 0,
end_index: t.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: 'teh', corrected: 'the', start: idx, end: idx + 3 }],
},
];
}
const kidx = t.indexOf('quick');
if (kidx >= 0 && out.suggestions.length === 0) {
// insertion after 'quick' (pos = kidx+5)
const pos = kidx + 5;
const corrected = t.slice(0, pos) + ',' + t.slice(pos);
out.correctedText = corrected;
out.suggestions = [
{
original: t,
corrected,
sentence: 'Sentence 1',
start_index: 0,
end_index: t.length,
original_highlighted: '',
corrected_highlighted: '',
wordFixes: [{ original: '', corrected: ',', start: pos, end: pos }],
},
];
}
results.push(out);
}
return { results, incomplete: false };
}
}
async function boot(_over = {}, engineOverride, cfgOver = {}) {
const cfg = makeConfig(cfgOver);
const engine = engineOverride ?? new FakeEngine();
const server = new LtServer({ cfg, engine });
await server.listen(0, '127.0.0.1');
const port = server.httpServer.address().port;
return { cfg, engine, server, base: `http://127.0.0.1:${port}` };
}
async function close(server) {
await server.close();
}
test('GET /v2/languages: LT shape (Overleaf reads name+code)', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/languages`);
assert.equal(res.status, 200);
const body = (await res.json());
assert.ok(Array.isArray(body));
assert.ok(body.some((l) => l.code === 'en-US'));
} finally {
await close(server);
}
});
test('POST /v2/check (Overleaf style: form + annotation data)', async (t) => {
const { server, base } = await boot();
try {
const source = 'He wrote \\textbf{report} about teh project.';
const annotation = [
{ text: 'He wrote ' },
{ markup: '\\textbf{report} ' },
{ text: 'about teh project.' },
];
const params = new URLSearchParams();
params.set('language', 'auto');
params.set('level', 'picky');
params.set('data', JSON.stringify({ annotation }));
params.set('disabledRules', 'WHITESPACE_RULE,CONSECUTIVE_SPACES');
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: params.toString(),
});
assert.equal(res.status, 200);
const body = (await res.json());
assert.equal(body.software.name, 'LanguageTool');
assert.equal(body.software.apiVersion, 1);
assert.equal(body.warnings.incompleteResults, false);
assert.equal(body.language.code, 'en-US');
// 'teh' is at absolute offset: len('He wrote ') + len('\\textbf{report} ')
const baseOffset = 'He wrote '.length + '\\textbf{report} '.length;
assert.equal(body.matches.length, 1);
const m = body.matches[0];
assert.equal(m.offset, baseOffset + 'about '.length);
assert.equal(m.length, 3);
assert.deepEqual(m.replacements, [{ value: 'the' }]);
assert.equal(m.rule.category.id, 'GRAMMAR');
assert.ok(m.context.text.includes('teh'));
// overleaf invariant: offset within doc
assert.ok(m.offset + m.length <= source.length);
} finally {
await close(server);
}
});
test('POST /v2/check with JSON body', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: 'en-GB', level: 'default', text: 'I has teh book.' }),
});
assert.equal(res.status, 200);
const body = (await res.json());
assert.equal(body.language.code, 'en-GB');
assert.equal(body.matches.length, 1);
assert.equal(body.matches[0].offset, 6); // 'has ' before 'teh' → 'I has teh' → t@6
assert.deepEqual(body.matches[0].replacements, [{ value: 'the' }]);
} finally {
await close(server);
}
});
test('POST /v2/check: no text and no data → 400', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en',
});
assert.equal(res.status, 400);
const body = (await res.json());
assert.match(body.message, /Missing/);
} finally {
await close(server);
}
});
test('POST /v2/check: too-long text → 413', async (t) => {
const { server, base } = await boot({}, undefined, { GRAMMAR_LLM_MAX_TEXT_LENGTH: '3000' });
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&text=' + encodeURIComponent('a'.repeat(3500)),
});
assert.equal(res.status, 413);
} finally {
await close(server);
}
});
test('POST /v2/check: unsupported language → empty matches (default mode)', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=de&text=' + encodeURIComponent('Ich habe teh Buch.'),
});
assert.equal(res.status, 200);
const body = (await res.json());
assert.deepEqual(body.matches, []);
} finally {
await close(server);
}
});
test('POST /v2/check: unsupported language → 400 (error mode)', async (t) => {
const { server, base } = await boot({}, undefined, { GRAMMAR_LLM_UNSUPPORTED_LANGUAGE: 'error' });
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=de&text=' + encodeURIComponent('Ich habe teh Buch.'),
});
assert.equal(res.status, 400);
} finally {
await close(server);
}
});
test('POST /v2/check: engine not ready → 503 with message', async (t) => {
const engine = new FakeEngine();
engine.state = 'loading';
const { server, base } = await boot({}, engine);
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&text=' + encodeURIComponent('I has teh book.'),
});
assert.equal(res.status, 503);
const body = (await res.json());
assert.match(body.message, /not ready/);
} finally {
await close(server);
}
});
test('GET /v2/health reflects engine state', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/health`);
assert.equal(res.status, 200);
const body = (await res.json());
assert.equal(body.status, 'ok');
assert.equal(body.state, 'ready');
} finally {
await close(server);
}
});
test('GET /v2/health: not ready → 503', async (t) => {
const engine = new FakeEngine();
engine.state = 'error';
engine.statusError = 'boom';
const { server, base } = await boot({}, engine);
try {
const res = await fetch(`${base}/v2/health`);
assert.equal(res.status, 503);
const body = (await res.json());
assert.equal(body.state, 'error');
} finally {
await close(server);
}
});
test('GET /v2/version and / info payloads', async (t) => {
const { server, base } = await boot();
try {
const v = (await (await fetch(`${base}/v2/version`)).json());
assert.equal(v.name, 'LanguageTool');
assert.ok(v.engine);
const i = (await (await fetch(`${base}/`)).json());
assert.ok(Array.isArray(i.endpoints));
} finally {
await close(server);
}
});
test('unknown /v2 endpoint → 404 JSON', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/nope`);
assert.equal(res.status, 404);
const body = (await res.json());
assert.match(body.message, /unknown v2 endpoint/);
} finally {
await close(server);
}
});
test('OPTIONS preflight → CORS headers', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/check`, { method: 'OPTIONS' });
assert.ok([200, 204].includes(res.status));
assert.ok((res.headers.get('access-control-allow-origin') ?? '').includes('*'));
} finally {
await close(server);
}
});
test('responses carry CORS + no-store headers (browser client compat)', async (t) => {
const { server, base } = await boot();
try {
const res = await fetch(`${base}/v2/languages`);
assert.ok((res.headers.get('access-control-allow-origin') ?? '').includes('*'));
assert.equal(res.headers.get('cache-control'), 'no-store');
assert.match(res.headers.get('content-type') ?? '', /application\/json/);
} finally {
await close(server);
}
});
// ---------------------------------------------------------------------------
// New: LT-first cascade, LLM level modes, OpenAI-compatible remote backend
// ---------------------------------------------------------------------------
/** In-process fake LanguageTool (v2 /check). Optional {delayMs} to simulate a slow LT. */
function fakeLt(handler, { delayMs = 0 } = {}) {
const seen = { bodies: [] };
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
const body = new URLSearchParams(raw);
seen.bodies.push(Object.fromEntries(body.entries()));
const done = () => {
const out = handler(body, seen.bodies.length - 1) ?? { matches: [] };
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(out));
};
if (delayMs > 0) setTimeout(done, delayMs);
else done();
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({
url: `http://127.0.0.1:${port}`,
seen,
close: () => new Promise((r) => server.close(() => r())),
});
});
});
}
/** In-process fake OpenAI-compatible chat/completions endpoint. */
function fakeOpenAi(corrections = {}) {
const seen = { requests: [] };
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url?.endsWith('/models')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"data":[{"id":"fake"}]}');
return;
}
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', async () => {
const json = JSON.parse(raw || '{}');
seen.requests.push(json);
const user = json.messages?.[1]?.content ?? '';
const corrected = corrections[user] ?? user;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
choices: [{ message: { content: corrected } }],
}));
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({
baseUrl: `http://127.0.0.1:${port}/v1`,
seen,
close: () => new Promise((r) => server.close(() => r())),
});
});
});
}
test('cascade: real LT produces matches → LT wins, LLM skipped, level forwarded', async () => {
const lt = await fakeLt(() => ({
software: { name: 'LanguageTool', version: '6.4' },
warnings: { incompleteResults: false },
matches: [
{
message: 'FROM-REAL-LT',
shortMessage: 'LT-match',
replacements: [{ value: 'the' }],
offset: 2,
length: 3,
context: { text: 'I teh has', offset: 0, length: 9 },
type: { typeName: 'Typographical' },
rule: { id: 'RULE_LT', description: 'x', issueType: 'Typo', category: { id: 'TYPOS', name: 'Typos' } },
},
],
}));
try {
const engine = new FakeEngine();
const { server, base } = await boot(
{},
engine,
{ GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url, GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '2000' },
);
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=picky&text=' + encodeURIComponent('I teh has cats'),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.matches.length, 1);
assert.equal(body.matches[0].message, 'FROM-REAL-LT');
assert.equal(body.software.version, '6.4');
assert.equal(engine.checkCalls, 0, 'LLM must be skipped when LT has matches');
// the picky flag must reach the real LT verbatim:
assert.equal(lt.seen.bodies[0].level, 'picky');
assert.equal(lt.seen.bodies[0].language, 'en');
} finally {
await close(server);
}
} finally {
await lt.close();
}
});
test('cascade: real LT finds nothing → LLM runs second (fallback)', async () => {
const lt = await fakeLt(() => ({ matches: [] }));
try {
const engine = new FakeEngine();
const { server, base } = await boot(
{},
engine,
{ GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url, GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '2000' },
);
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=default&text=' + encodeURIComponent('I teh has cats'),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.matches.length, 1, 'fallback LLM match expected');
assert.match(body.matches[0].message, /the/);
assert.equal(engine.checkCalls, 1, 'LLM must run exactly once as fallback');
assert.equal(lt.seen.bodies[0].level, 'default');
} finally {
await close(server);
}
} finally {
await lt.close();
}
});
test('cascade: real LT down → LLM fallback still returns 200', async () => {
const engine = new FakeEngine();
const { server, base } = await boot(
{},
engine,
{
GRAMMAR_LLM_LANGUAGETOOL_URL: 'http://127.0.0.1:9', // closed port
GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '800',
},
);
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&text=' + encodeURIComponent('I teh has cats'),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.matches.length, 1);
assert.equal(engine.checkCalls, 1);
} finally {
await close(server);
}
});
test('cascade race: slow empty LT does NOT block the LLM (the 71 s bug)', async () => {
// The real-world bug: LT took (or was given) a long time and answered "0
// matches"; the LLM then ran AFTER it, pushing the total past Overleaf's
// 60 s controller timeout → the UI silently showed nothing.
// With the shared-budget concurrent cascade the LLM must be served as soon
// as IT is ready, even while the LT is still running.
const lt = await fakeLt(() => ({ matches: [] }), { delayMs: 2000 });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const engine = new FakeEngine();
const origCheck = engine.check.bind(engine);
// make the LLM take ~300 ms so it settles well before the 2 s LT
engine.check = async (runs, opts = {}) => {
await sleep(300);
return origCheck(runs, opts);
};
const { server, base } = await boot(
{},
engine,
{
GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url,
GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '4000',
GRAMMAR_LLM_CASCADE_BUDGET_MS: '6000',
GRAMMAR_LLM_LLM_GRACE_MS: '50', // LLM starts quickly, as in production
},
);
try {
const t0 = Date.now();
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&text=' + encodeURIComponent('I teh has cats'),
});
const elapsed = Date.now() - t0;
const body = await res.json();
assert.equal(res.status, 200);
assert.ok(body.matches.length >= 1, 'LLM fallback match must be present');
assert.ok(
elapsed < 2000,
`LLM result must be served before the slow empty LT settles (elapsed ${elapsed} ms; LT was 2000 ms)`,
);
assert.equal(engine.checkCalls, 1, 'the LLM ran');
} finally {
await close(server);
await lt.close();
}
});
test('cascade race: LT with matches still wins even when slower than the LLM grace', async () => {
const lt = await fakeLt(() => ({
software: { name: 'LanguageTool', version: '6.4' },
warnings: { incompleteResults: false },
matches: [
{
message: 'FROM-REAL-LT',
replacements: [{ value: 'the' }],
offset: 2,
length: 3,
context: { text: 'I teh has', offset: 0, length: 9 },
type: { typeName: 'Typographical' },
rule: { id: 'RULE_LT', category: { id: 'TYPOS', name: 'Typos' } },
},
],
}), { delayMs: 700 });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const engine = new FakeEngine();
const origCheck = engine.check.bind(engine);
engine.check = async (runs, opts = {}) => {
await sleep(200); // LLM would "finish" first — LT (700 ms) must still win
return origCheck(runs, opts);
};
const { server, base } = await boot(
{},
engine,
{
GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url,
GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '4000',
GRAMMAR_LLM_CASCADE_BUDGET_MS: '6000',
GRAMMAR_LLM_LLM_GRACE_MS: '50',
},
);
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=picky&text=' + encodeURIComponent('I teh has cats'),
});
const body = await res.json();
assert.equal(res.status, 200);
assert.equal(body.matches.length, 1);
assert.equal(body.matches[0].message, 'FROM-REAL-LT', 'the LT match must win');
} finally {
await close(server);
await lt.close();
}
});
test('level: absent/invalid → cfg.defaultLevel; explicit respected (LLM receives it)', async () => {
const mk = (env) => {
const engine = new FakeEngine();
return { engine, env: { ...env } };
};
// absent level
{
const { engine } = mk({});
const { server, base } = await boot({}, engine, { GRAMMAR_LLM_DEFAULT_LEVEL: 'picky' });
await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&text=' + encodeURIComponent('I teh has'),
});
assert.equal(engine.lastLevel, 'picky');
await close(server);
}
// invalid level → falls back to defaultLevel
{
const { engine } = mk({});
const { server, base } = await boot({}, engine, { GRAMMAR_LLM_DEFAULT_LEVEL: 'picky' });
await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=bogus&text=' + encodeURIComponent('I teh has'),
});
assert.equal(engine.lastLevel, 'picky');
await close(server);
}
// explicit picky
{
const { engine } = mk({});
const { server, base } = await boot({}, engine, {});
await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=picky&text=' + encodeURIComponent('I teh has'),
});
assert.equal(engine.lastLevel, 'picky');
await close(server);
}
// explicit default
{
const { engine } = mk({});
const { server, base } = await boot({}, engine, {});
await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=default&text=' + encodeURIComponent('I teh has'),
});
assert.equal(engine.lastLevel, 'default');
await close(server);
}
void mk;
});
test('openai backend: real engine pipeline against a fake OpenAI endpoint (default level prompt)', async () => {
const fake = await fakeOpenAi({ 'He run fast to school.': 'He ran fast to school.' });
try {
const cfg = makeConfig({
GRAMMAR_LLM_BACKEND: 'openai',
GRAMMAR_LLM_OPENAI_MODEL: 'fake-model-123',
GRAMMAR_LLM_OPENAI_BASE_URL: fake.baseUrl,
GRAMMAR_LLM_OPENAI_TIMEOUT_MS: '5000',
GRAMMAR_LLM_DEFAULT_LEVEL: 'default',
});
const log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
const engine = new CheckEngine(cfg, log);
const server = new LtServer({ cfg, engine, log });
await server.listen(0, '127.0.0.1');
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
try {
const res = await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&text=' + encodeURIComponent('He run fast to school.'),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.matches.length, 1, 'core diff must produce the ran-fix');
assert.equal(body.matches[0].replacements[0].value, 'ran');
// request shape: model + system prompt
const req = fake.seen.requests[0];
assert.equal(req.model, 'fake-model-123');
assert.equal(req.messages[0].role, 'system');
assert.match(req.messages[0].content, /grammar correction assistant/);
assert.doesNotMatch(req.messages[0].content, /highly picky/);
} finally {
await server.close();
}
} finally {
await fake.close();
}
});
test('openai backend: picky level gets the stricter prompt; default does not', async () => {
const fake = await fakeOpenAi({ 'He run fast to school.': 'He ran fast to school.' });
try {
const mk = (defaultLevel) => {
const cfg = makeConfig({
GRAMMAR_LLM_BACKEND: 'openai',
GRAMMAR_LLM_OPENAI_MODEL: 'fake-model-123',
GRAMMAR_LLM_OPENAI_BASE_URL: fake.baseUrl,
GRAMMAR_LLM_OPENAI_TIMEOUT_MS: '5000',
GRAMMAR_LLM_DEFAULT_LEVEL: defaultLevel,
});
const log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
const engine = new CheckEngine(cfg, log);
return new LtServer({ cfg, engine, log });
};
// explicit picky
{
const server = mk('default');
await server.listen(0, '127.0.0.1');
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
await fetch(`${base}/v2/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'language=en&level=picky&text=' + encodeURIComponent('He run fast to school.'),
});
await server.close();
}
const pickyReq = fake.seen.requests[fake.seen.requests.length - 1];
assert.match(pickyReq.messages[0].content, /highly picky/);
assert.match(pickyReq.messages[0].content, /grammar correction assistant/);
} finally {
await fake.close();
}
});
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# smoke.sh — end-to-end check against a RUNNING grammar-llm server.
#
# This does NOT start the server (it would need the model). Run a server
# first, e.g.:
#
# node dist/download-model.mjs --out /tmp/models/GRMR-V3-G4B-Q4_K_M.gguf \
# --url https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf
# GRAMMAR_LLM_MODEL_DIR=/tmp/models PORT=8090 node dist/server.mjs &
#
# then:
#
# BASE=http://127.0.0.1:8090 npm run smoke
#
# or against the docker container:
#
# BASE=http://127.0.0.1:8010 npm run smoke
set -euo pipefail
BASE="${BASE:-http://127.0.0.1:8010}"
pass=0
fail=0
say() { printf '\n\033[1m== %s ==\033[0m\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; pass=$((pass+1)); }
bad() { printf ' \033[31m✗\033[0m %s\n' "$*"; fail=$((fail+1)); }
say "GET $BASE/v2/languages"
body="$(curl -fsS "$BASE/v2/languages")"
if echo "$body" | jq -e '.[0].name and .[0].code' >/dev/null 2>&1; then
ok "languages list has name+code"
echo "$body" | jq -c '.[0]'
else
bad "languages list unexpected: $body"
fi
say "GET $BASE/v2/health"
body="$(curl -fsS "$BASE/v2/health" || true)"
state="$(echo "$body" | jq -r '.state // "missing"' 2>/dev/null || echo missing)"
echo " state: $state"
[ "$state" = "ready" ] && ok "model ready" || { echo " (model not ready — /v2/check will 503; continuing for shape checks)"; }
say "POST $BASE/v2/check (text=…, Overleaf style)"
code="$(curl -s -o /tmp/glm-smoke-check.json -w '%{http_code}' -X POST "$BASE/v2/check" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'language=auto' \
--data-urlencode 'level=picky' \
--data-urlencode 'disabledRules=WHITESPACE_RULE,CONSECUTIVE_SPACES' \
--data-urlencode 'text=I has two cats. They is very goed cat.')"
resp="$(cat /tmp/glm-smoke-check.json)"
echo "$resp" | head -c 400; echo
if [ "$code" = "200" ]; then
n="$(echo "$resp" | jq '.matches | length' 2>/dev/null || echo '?')"
echo " matches: $n"
echo "$resp" | jq -c '.matches[]? | {offset, length, replacements: [ .replacements[].value ], message}' 2>/dev/null | head -5 || true
if [ "$n" != "?" ] && [ "$n" -ge 1 ]; then ok "produced matches"; else bad "expected >=1 match (model may have found none)"; fi
if echo "$resp" | jq -e '.software // .software' >/dev/null 2>&1; then ok "has software block"; else bad "no software block"; fi
else
# 503 (model not ready) is acceptable for a shape test, but report status
echo " status: $code"
[ "$code" = "503" ] && { echo " (model still loading — rerun smoke once /v2/health shows ready)"; } || bad "unexpected status $code"
fi
say "POST /v2/check with annotation (LaTeX markup must be skipped)"
resp="$(curl -s -X POST "$BASE/v2/check" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'language=en-US' \
--data-urlencode 'data={"annotation":[{"text":"The "},{"markup":"{\\textbf{bold}} "},{"text":"cat is goed."}]}' )"
echo "$resp" | head -c 400; echo
# No match may fall inside the markup span [4,16)
if echo "$resp" | jq -e '.matches[] | select(.offset >= 4 and .offset < 16)' >/dev/null 2>&1; then
bad "a match overlaps the LaTeX markup span (offsets misaligned?)"
else
ok "no match crosses the markup span"
fi
say "negative: missing text/data → 400"
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/v2/check" -H 'Content-Type: application/x-www-form-urlencoded' --data 'language=en')"
[ "$code" = "400" ] && ok "400 as expected" || bad "expected 400, got $code"
say "CORS preflight"
code="$(curl -s -o /dev/null -w '%{http_code}' -X OPTIONS "$BASE/v2/check")"
[ "$code" = "204" ] || [ "$code" = "200" ] && ok "preflight $code" || bad "preflight $code"
say "summary"
echo " pass=$pass fail=$fail"
[ "$fail" -eq 0 ]
@@ -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
-4
View File
@@ -1,4 +0,0 @@
.env
backup_seaweedfs_online
workbench
@@ -1,9 +0,0 @@
docker network create overleaf-network
snetz=`docker network inspect overleaf-network | grep "Subnet" | sed s/" "/""/g | sed s/"\,"/""/g | sed s/":"/"\n"/g | grep -v "Subnet" | sed s/'"'/''/g | sed s/"{"/""/g`
nid=`docker network ls | grep overleaf-network | awk '{print $1}'`
ufw allow in on br-$nid
ufw route allow in on br-$nid
ufw route allow out on br-$nid
iptables -t nat -A POSTROUTING ! -o br-$nid -s $snetz -j MASQUERADE
@@ -1,41 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Load environment variables from .env file
if [ -f .env ]; then
set -a
source .env
set +a
else
echo "Error: .env file not found!" >&2
exit 1
fi
export AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY
ENDPOINT="http://localhost:8333"
echo "Testing connection to SeaweedFS S3 gateway..."
aws --endpoint-url "$ENDPOINT" s3 ls
# Array of required Overleaf buckets
BUCKETS=(
"overleaf-user-files"
"overleaf-template-files"
"overleaf-project-blobs"
"overleaf-chunks"
)
echo "Checking and creating Overleaf buckets..."
for bucket in "${BUCKETS[@]}"; do
if ! aws --endpoint-url "$ENDPOINT" s3 ls "s3://$bucket" >/dev/null 2>&1; then
echo "Creating bucket: s3://$bucket"
aws --endpoint-url "$ENDPOINT" s3 mb "s3://$bucket"
else
echo "Bucket already exists: s3://$bucket"
fi
done
echo "Bucket setup complete. Current buckets:"
aws --endpoint-url "$ENDPOINT" s3 ls
@@ -1,89 +0,0 @@
#!/usr/bin/env bash
# 0_ensure_local_auth_type.sh — manage wakapi's users-table normalization
# (wakapi/ensure_local_auth_type.sql: trigger + one-time backfill).
#
# MODES
# preflight Drop the trigger (no-op if absent / if the table doesn't
# exist yet). Run BEFORE starting or recreating wakapi.
# apply Wait for wakapi to serve, then (re)apply trigger + backfill.
# Run AFTER wakapi is up. This is the default mode.
#
# WHY (twice-learned rule, 2026-10-26):
# A trigger referencing `users` BLOCKS wakapi's pending startup DDL —
# `ERROR: cannot alter type of a column used in a trigger definition
# (SQLSTATE 0A000)` — and wakapi crashloops until the trigger is gone.
# The batches are recorded per-run; while any of them is unrecorded, EVERY
# startup re-runs the batch and can hit the collision. So the only safe
# cycle is: drop -> wakapi up (batch runs free, records) -> apply.
# up.sh and up_wakapi.sh do exactly this. Rows written during the short
# window without the trigger are covered by the apply-time backfill.
#
# The SQL itself is idempotent (create or replace / drop if exists /
# guarded updates), so the dance costs a few SQL statements per `up`.
set -euo pipefail
cd "$(dirname "$0")"
MODE="${1:-apply}"
# shellcheck disable=SC1091
set -a; . ./.env; set +a
die() { echo "ERROR: $*" >&2; exit 1; }
PGCLIENT="docker run --rm --network overleaf-network -e PGPASSWORD=${PG_PASSWORD}
-e PGUSER=${WAKAPI_DB_USER:-wakapi} -e PGDATABASE=${WAKAPI_DB_NAME:-wakapi}
${POSTGRES_IMAGE}"
wait_pg() {
for i in $(seq 1 60); do
${PGCLIENT} sh -c 'pg_isready -h wakapi-pg -p 5432' >/dev/null 2>&1 && return 0
sleep 2
done
die "wakapi-pg not accepting connections after 120s"
}
case "$MODE" in
preflight)
echo "[preflight/1] waiting for wakapi-pg (needed to drop the trigger)..."
wait_pg
echo "[preflight/2] dropping normalization trigger (if any) so wakapi startup DDL can run..."
${PGCLIENT} psql -h wakapi-pg -p 5432 -v ON_ERROR_STOP=1 -c \
"do \$\$ begin execute 'drop trigger if exists trg_wakapi_normalize_local_auth on users'; exception when undefined_object then null; end \$\$;" \
>/dev/null
echo "[preflight] done (no-op if the trigger wasn't there)"
;;
apply)
echo "[1/3] waiting for wakapi-pg to accept connections..."
wait_pg
echo "[2/3] waiting for users table + wakapi serving (startup migrations complete)..."
READY=0
for i in $(seq 1 90); do
TBL=$( ${PGCLIENT} psql -h wakapi-pg -p 5432 -tAq -c "select to_regclass('public.users')")
if [ "$TBL" = "users" ] \
&& docker run --rm --network overleaf-network ${PROBE_IMAGE} \
wget -q --spider http://wakapi:3000/login >/dev/null 2>&1; then
READY=1; break
fi
sleep 2
done
[ "$READY" = "1" ] \
|| die "users table or wakapi not ready/serving after 180s — check: bash logs_wakapi.sh (look for 'migration failed')"
echo "[3/3] applying wakapi/ensure_local_auth_type.sql ..."
docker run --rm --network overleaf-network \
-e PGPASSWORD="${PG_PASSWORD}" \
-e PGUSER="${WAKAPI_DB_USER:-wakapi}" \
-e PGDATABASE="${WAKAPI_DB_NAME:-wakapi}" \
-v "$(pwd)/wakapi/ensure_local_auth_type.sql:/init.sql:ro" \
${POSTGRES_IMAGE} \
psql -h wakapi-pg -p 5432 -v ON_ERROR_STOP=1 -f /init.sql
echo "active trigger(s) on users:"
${PGCLIENT} psql -h wakapi-pg -p 5432 -tAq \
-c "select ' '||tgname from pg_trigger where tgrelid='public.users'::regclass and not tgisinternal order by 1"
echo "OK — local-auth normalization is active (staff 'Forgot password?' path works)."
;;
*)
die "unknown mode '$MODE' (expected: preflight | apply)"
;;
esac
@@ -1,2 +0,0 @@
sudo apt update
sudo apt install -y awscli
@@ -1,36 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Path to your .env file (adjust if your script runs from a different directory)
ENV_FILE="./.env"
if [ -f "$ENV_FILE" ]; then
set -a
source "$ENV_FILE"
set +a
else
echo "Error: .env file not found at $ENV_FILE" >&2
exit 1
fi
# Ensure AWS variables are exported for the AWS CLI
export AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY
ENDPOINT="http://localhost:8333"
BACKUP_DIR="./backup_seaweedfs_online"
TIMESTAMP=$(date +%F_%H-%M-%S)
mkdir -p "$BACKUP_DIR/$TIMESTAMP"
echo "1. Capturing online metadata snapshot..."
docker exec seaweedfs-filer weed shell -master="master:9333" <<< "fs.meta.save /data/filer_meta_$TIMESTAMP.json"
echo "2. Syncing S3 buckets live..."
for bucket in overleaf-user-files overleaf-template-files overleaf-project-blobs overleaf-chunks; do
echo "Syncing s3://$bucket..."
aws --endpoint-url "$ENDPOINT" s3 sync "s3://$bucket" "$BACKUP_DIR/$TIMESTAMP/$bucket"
done
echo "Online backup completed successfully at $TIMESTAMP"
@@ -1,22 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Load environment variables from .env file
if [ -f .env ]; then
set -a
source .env
set +a
else
echo "Error: .env file not found!" >&2
exit 1
fi
docker pull ${TEX_LIVE_REPO}/texlive-full:2026.1
docker pull ${TEX_LIVE_REPO}/texlive-full:2025.1
docker pull ${TEX_LIVE_REPO}/texlive-full:2024.1
docker pull ${TEX_LIVE_REPO}/texlive-full:2023.1
docker pull nginx:stable-alpine
docker pull mongo:${MONGO_VERSION}
docker pull redis:${REDIS_VERSION}
docker pull chrislusf/seaweedfs:latest

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