Swadhin Biswas

[email protected]
2026-06-06 09:56:35 +0600 committed NaN years ago
17189d8
commit 17189d8
13 files changed
+404 -550
▼opengrammar/backend/package-lock.json
+33-5
1
1
{
2
- "name": "backend",
3
- "version": "1.0.0",
2
+ "name": "opengrammar-server",
3
+ "version": "2.0.0",
4
4
"lockfileVersion": 3,
5
5
"requires": true,
6
6
"packages": {
7
7
"": {
8
- "name": "backend",
9
- "version": "1.0.0",
10
- "license": "ISC",
8
+ "name": "opengrammar-server",
9
+ "version": "2.0.0",
10
+ "license": "MIT",
11
11
"dependencies": {
12
12
"@hono/node-server": "^1.19.9",
13
13
"@hono/zod-validator": "^0.7.6",
17
17
"openai": "^4.22.0",
18
18
"zod": "^3.25.76"
19
19
},
20
+ "bin": {
21
+ "opengrammar": "bin/opengrammar-server.js",
22
+ "opengrammar-server": "bin/opengrammar-server.js"
23
+ },
20
24
"devDependencies": {
21
25
"@cloudflare/workers-types": "^4.20260219.0",
26
+ "@netlify/functions": "^5.1.5",
22
27
"@types/bun": "^1.3.10",
23
28
"@types/node": "^25.3.0",
24
29
"tsx": "^4.21.0",
1121
1126
"@jridgewell/sourcemap-codec": "^1.4.10"
1122
1127
}
1123
1128
},
1129
+ "node_modules/@netlify/functions": {
1130
+ "version": "5.2.0",
1131
+ "resolved": "https://registry.npmmirror.com/@netlify/functions/-/functions-5.2.0.tgz",
1132
+ "integrity": "sha512-Pj93qeQd1tkQ5xm9gWJZmBf/1riLYqYHc0OzFukrJomrj82Ott53Rr/Q88H1ms5cF+P5QXRKWmA2JSxSybKfjA==",
1133
+ "dev": true,
1134
+ "license": "MIT",
1135
+ "dependencies": {
1136
+ "@netlify/types": "2.6.0"
1137
+ },
1138
+ "engines": {
1139
+ "node": ">=18.0.0"
1140
+ }
1141
+ },
1142
+ "node_modules/@netlify/types": {
1143
+ "version": "2.6.0",
1144
+ "resolved": "https://registry.npmmirror.com/@netlify/types/-/types-2.6.0.tgz",
1145
+ "integrity": "sha512-yD20EizHJDQxajJ66Vo8RTwLwR2jMNVxufPG8MHd2AScX8jW4z0VPnnJHArq2GYPFTFZRHmiAhDrXr5m8zof6w==",
1146
+ "dev": true,
1147
+ "license": "MIT",
1148
+ "engines": {
1149
+ "node": "^18.14.0 || >=20"
1150
+ }
1151
+ },
1124
1152
"node_modules/@poppinss/colors": {
1125
1153
"version": "4.1.6",
1126
1154
"dev": true,
1127
1155
▼opengrammar/backend/server-node.ts
+2-145
1
1
import { serve } from '@hono/node-server';
2
-import { Hono } from 'hono';
3
-import { cors } from 'hono/cors';
4
-import { logger } from 'hono/logger';
5
-import { RuleBasedAnalyzer } from './src/analyzer.js';
6
-import { PROVIDERS } from './src/shared-types.js';
2
+import app from './src/index.js';
7
3
8
-const app = new Hono();
4
+const port = parseInt(process.env.PORT || '8787', 10);
9
5
10
-// Middleware
11
-app.use('/*', logger());
12
-app.use('/*', cors());
13
-
14
-// Health check endpoint
15
-app.get('/health', (c) => {
16
- return c.json({
17
- status: 'healthy',
18
- timestamp: new Date().toISOString(),
19
- environment: 'nodejs',
20
- version: '2.0.0',
21
- });
22
-});
23
-
24
-// List available providers
25
-app.get('/providers', (c) => {
26
- return c.json({
27
- providers: PROVIDERS,
28
- });
29
-});
30
-
31
-// Get models for a provider
32
-app.post('/models', async (c) => {
33
- try {
34
- const { provider } = await c.req.json().catch(() => ({}));
35
- const providerConfig = PROVIDERS.find((p) => p.id === provider);
36
- return c.json({
37
- provider,
38
- models: providerConfig?.models || [],
39
- });
40
- } catch (error) {
41
- console.error('Failed to fetch models:', error);
42
- return c.json({ models: [] });
43
- }
44
-});
45
-
46
-// Main analysis endpoint - rule-based only (fast and reliable)
47
-app.post('/analyze', async (c) => {
48
- const startTime = Date.now();
49
-
50
- try {
51
- const body = await c.req.json();
52
- const { text, dictionary } = body;
53
-
54
- // Validate input
55
- if (!text || typeof text !== 'string') {
56
- return c.json({ error: 'Invalid request: text is required' }, 400);
57
- }
58
-
59
- if (text.length > 50000) {
60
- return c.json({ error: 'Text exceeds maximum length of 50,000 characters' }, 413);
61
- }
62
-
63
- console.log(`[Analyze] Text length: ${text.length}`);
64
-
65
- // Run rule-based analysis
66
- const issues = RuleBasedAnalyzer.analyze(text, {
67
- dictionary: dictionary || [],
68
- });
69
-
70
- const duration = Date.now() - startTime;
71
-
72
- console.log(`[Analyze] Found ${issues.length} issues in ${duration}ms`);
73
-
74
- return c.json({
75
- issues,
76
- metadata: {
77
- textLength: text.length,
78
- issuesCount: issues.length,
79
- processingTimeMs: duration,
80
- },
81
- });
82
- } catch (error) {
83
- console.error('Analysis error:', error);
84
- return c.json(
85
- {
86
- error: 'Failed to analyze text',
87
- message: error instanceof Error ? error.message : 'Unknown error',
88
- issues: [],
89
- },
90
- 500,
91
- );
92
- }
93
-});
94
-
95
-// Tone rewriting endpoint (requires API key)
96
-app.post('/rewrite', async (c) => {
97
- try {
98
- const { text, tone, apiKey } = await c.req.json();
99
-
100
- if (!text || !tone) {
101
- return c.json({ error: 'Text and tone are required' }, 400);
102
- }
103
-
104
- if (!apiKey) {
105
- return c.json({
106
- error: 'API key required for rewriting',
107
- original: text,
108
- rewritten: text,
109
- tone,
110
- });
111
- }
112
-
113
- // LLM rewriting would go here
114
- return c.json({
115
- original: text,
116
- rewritten: text,
117
- tone,
118
- });
119
- } catch (error) {
120
- console.error('Rewrite error:', error);
121
- return c.json(
122
- {
123
- error: 'Failed to rewrite text',
124
- message: error instanceof Error ? error.message : 'Unknown error',
125
- },
126
- 500,
127
- );
128
- }
129
-});
130
-
131
-// Error handler
132
-app.onError((err, c) => {
133
- console.error('Unhandled error:', err);
134
- return c.json(
135
- {
136
- error: 'Internal server error',
137
- message: err.message,
138
- },
139
- 500,
140
- );
141
-});
142
-
143
-// 404 handler
144
-app.notFound((c) => {
145
- return c.json({ error: 'Not found' }, 404);
146
-});
147
-
148
-const port = 8787;
149
6
console.log(`\n🪶 OpenGrammar Backend starting on http://localhost:${port}\n`);
150
7
151
8
serve(
152
9
▼opengrammar/backend/src/analyzer.ts
+6-14
103
103
]);
104
104
105
105
export class RuleBasedAnalyzer {
106
- private static dictionary: Set<string> = new Set();
107
- private static customRules: CustomRule[] = [];
108
-
109
106
static analyze(
110
107
text: string,
111
108
options?: {
117
114
): Issue[] {
118
115
const issues: Issue[] = [];
119
116
120
- if (options?.dictionary) {
121
- RuleBasedAnalyzer.dictionary = new Set(options.dictionary.map((w) => w.toLowerCase()));
122
- }
123
-
124
- if (options?.customRules) {
125
- RuleBasedAnalyzer.customRules = options.customRules;
126
- }
117
+ const userDictionary = options?.dictionary ? new Set(options.dictionary.map((w) => w.toLowerCase())) : undefined;
118
+ const customRules = options?.customRules || [];
127
119
128
120
// Dictionary-based spell checking (unless spelling module is disabled)
129
121
const isSpellingDisabled = options?.disabledModules?.some(
130
122
(m) => m.toLowerCase() === 'spelling',
131
123
);
132
124
if (!isSpellingDisabled) {
133
- issues.push(...checkSpelling(text, RuleBasedAnalyzer.dictionary));
125
+ issues.push(...checkSpelling(text, userDictionary));
134
126
}
135
127
136
128
// Initialize NLP Engine for Syntax Checks
164
156
}
165
157
166
158
// Custom Rules (Runtime injections)
167
- issues.push(...RuleBasedAnalyzer.checkCustomRules(text));
159
+ issues.push(...RuleBasedAnalyzer.checkCustomRules(text, customRules));
168
160
169
161
// Deduplicate: when multiple rules flag the same text span,
170
162
// keep the highest-priority match (grammar > spelling > clarity > style)
253
245
return false;
254
246
}
255
247
256
- private static checkCustomRules(text: string): Issue[] {
248
+ private static checkCustomRules(text: string, customRules: CustomRule[]): Issue[] {
257
249
const issues: Issue[] = [];
258
- for (const rule of RuleBasedAnalyzer.customRules) {
250
+ for (const rule of customRules) {
259
251
try {
260
252
const regex = new RegExp(rule.pattern, 'gi');
261
253
let match: RegExpExecArray | null;
262
254
▼opengrammar/backend/src/rules/categories/grammar-advanced.ts
+14-13
231
231
];
232
232
233
233
for (const word of introWords) {
234
- const regex = new RegExp(`(?:^|[.!?]\\s+)${word}\\s+(?!,)([A-Za-z])`, 'gi');
234
+ const regex = new RegExp(`(?:^|[.!?]\\s+)(${word})\\s+(?!,)([A-Za-z])`, 'gi');
235
235
let match: RegExpExecArray | null;
236
236
while ((match = regex.exec(text)) !== null) {
237
- const wordStart = text.indexOf(word, match.index);
238
- if (wordStart >= 0) {
239
- issues.push({
240
- id: `intro-comma-${wordStart}`,
241
- type: 'grammar',
242
- original: `${word} ${match[1]}`,
243
- suggestion: `${word}, ${match[1]}`,
244
- reason: `Add a comma after the introductory word "${word}".`,
245
- offset: wordStart,
246
- length: word.length + 2,
247
- });
248
- }
237
+ const matchedWord = match[1]!;
238
+ const nextChar = match[2]!;
239
+ const wordStart = match.index + match[0].indexOf(matchedWord);
240
+
241
+ issues.push({
242
+ id: `intro-comma-${wordStart}`,
243
+ type: 'grammar',
244
+ original: `${matchedWord} ${nextChar}`,
245
+ suggestion: `${matchedWord}, ${nextChar}`,
246
+ reason: `Add a comma after the introductory word "${matchedWord}".`,
247
+ offset: wordStart,
248
+ length: matchedWord.length + 1 + nextChar.length,
249
+ });
249
250
}
250
251
}
251
252
return issues;
252
253
▼opengrammar/backend/src/rules/categories/readability.ts
+34-27
230
230
suggestion: 'Consider breaking this into shorter sentences.',
231
231
check: (text: string): Issue[] => {
232
232
const issues: Issue[] = [];
233
- const sentences = text.match(/[^.!?]+[.!?]+/g) || [];
234
- let offset = 0;
235
- for (const sentence of sentences) {
236
- const wordCount = sentence.trim().split(/\s+/).length;
233
+ const regex = /[^.!?]+[.!?]+/g;
234
+ let match;
235
+ while ((match = regex.exec(text)) !== null) {
236
+ const sentence = match[0];
237
+ const trimmedSentence = sentence.trimStart();
238
+ const trimDiff = sentence.length - trimmedSentence.length;
239
+ const wordCount = trimmedSentence.trimEnd().split(/\s+/).length;
240
+
237
241
if (wordCount > 40) {
238
242
issues.push({
239
- id: `RD_long_sentence-${offset}`,
243
+ id: `RD_long_sentence-${match.index + trimDiff}`,
240
244
type: 'clarity',
241
- original: sentence.trim().substring(0, 60) + '...',
245
+ original: trimmedSentence.substring(0, 60) + '...',
242
246
suggestion: 'Consider breaking this into shorter sentences.',
243
247
reason: `This sentence has ${wordCount} words. Sentences over 40 words are difficult to read.`,
244
- offset,
245
- length: sentence.length,
248
+ offset: match.index + trimDiff,
249
+ length: trimmedSentence.trimEnd().length,
246
250
});
247
251
}
248
- offset += sentence.length;
249
252
}
250
253
return issues;
251
254
},
261
264
suggestion: 'Vary your sentence openings.',
262
265
check: (text: string): Issue[] => {
263
266
const issues: Issue[] = [];
264
- const sentences = text.match(/[^.!?]+[.!?]+/g) || [];
267
+ const regex = /[^.!?]+[.!?]+/g;
268
+ const sentences: { text: string; offset: number; length: number }[] = [];
269
+ let match;
270
+ while ((match = regex.exec(text)) !== null) {
271
+ sentences.push({ text: match[0], offset: match.index, length: match[0].length });
272
+ }
273
+
265
274
if (sentences.length < 3) return issues;
266
275
267
- let offset = 0;
268
276
for (let i = 2; i < sentences.length; i++) {
269
277
const getStart = (s: string) => (s.trim().split(/\s+/)[0] || '').toLowerCase();
270
- const w1 = getStart(sentences[i - 2] || '');
271
- const w2 = getStart(sentences[i - 1] || '');
272
- const w3 = getStart(sentences[i] || '');
278
+ const w1 = getStart(sentences[i - 2]!.text);
279
+ const w2 = getStart(sentences[i - 1]!.text);
280
+ const w3 = getStart(sentences[i]!.text);
273
281
274
282
if (w1 && w1 === w2 && w2 === w3 && !['the', 'a', 'an', 'i'].includes(w1)) {
275
- const sentenceOffset = text.indexOf((sentences[i] || '').trim(), offset);
276
- if (sentenceOffset >= 0) {
277
- issues.push({
278
- id: `RD_repeated_starts-${sentenceOffset}`,
279
- type: 'style',
280
- original: (sentences[i] || '').trim().substring(0, 40) + '...',
281
- suggestion: 'Vary your sentence openings for better flow.',
282
- reason: `Three consecutive sentences start with "${w1}". Vary your sentence beginnings.`,
283
- offset: sentenceOffset,
284
- length: (sentences[i] || '').trim().length,
285
- });
286
- }
283
+ const trimmedSentence = sentences[i]!.text.trimStart();
284
+ const trimDiff = sentences[i]!.text.length - trimmedSentence.length;
285
+
286
+ issues.push({
287
+ id: `RD_repeated_starts-${sentences[i]!.offset}`,
288
+ type: 'style',
289
+ original: trimmedSentence.substring(0, 40) + '...',
290
+ suggestion: 'Vary your sentence openings for better flow.',
291
+ reason: `Three consecutive sentences start with "${w1}". Vary your sentence beginnings.`,
292
+ offset: sentences[i]!.offset + trimDiff,
293
+ length: trimmedSentence.trimEnd().length,
294
+ });
287
295
}
288
- offset += (sentences[i - 2] || '').length;
289
296
}
290
297
return issues;
291
298
},
292
299
▼opengrammar/backend/src/rules/categories/style.ts
+4-2
30
30
const matches = doc.match('#Noun #Noun #Noun #Noun+');
31
31
matches.forEach((m: any) => {
32
32
const str = m.text();
33
- const offset = text.indexOf(str);
33
+ const json = m.json({ offset: true });
34
+ const offset = json[0]?.offset?.start ?? text.indexOf(str);
34
35
if (offset !== -1) {
35
36
issues.push({
36
37
id: `noun-string-${offset}`,
59
60
const matches = doc.match('^#Conjunction');
60
61
matches.forEach((m: any) => {
61
62
const str = m.text();
62
- const offset = text.indexOf(str);
63
+ const json = m.json({ offset: true });
64
+ const offset = json[0]?.offset?.start ?? text.indexOf(str);
63
65
if (offset !== -1 && (str.toLowerCase() === 'and ' || str.toLowerCase() === 'but ')) {
64
66
issues.push({
65
67
id: `conjunction-start-${offset}`,
66
68
▼opengrammar/backend/src/shared-types.ts
+1-152
1
-export interface Issue {
2
- type: 'grammar' | 'spelling' | 'clarity' | 'style';
3
- original: string;
4
- suggestion: string;
5
- reason: string;
6
- offset: number;
7
- length: number;
8
- id?: string;
9
- ignored?: boolean;
10
- confidence?: number;
11
- priority?: number;
12
- source?: 'rule' | 'llm' | 'context';
13
-}
14
-
15
-export interface AnalysisContext {
16
- domain?: string;
17
- editorType?: string;
18
- activeSentence?: string;
19
- previousText?: string;
20
- nextText?: string;
21
- fullTextExcerpt?: string;
22
-}
23
-
24
-export interface AnalyzeRequest {
25
- text: string;
26
- apiKey?: string;
27
- model?: string;
28
- provider?: LLMProvider;
29
- baseUrl?: string;
30
- ignoredIssues?: string[];
31
- customRules?: CustomRule[];
32
- dictionary?: string[];
33
- context?: AnalysisContext;
34
- disabledModules?: string[];
35
-}
36
-
37
-export interface CustomRule {
38
- id: string;
39
- pattern: string;
40
- replacement: string;
41
- description: string;
42
- type: 'grammar' | 'spelling' | 'clarity' | 'style';
43
-}
44
-
45
-export interface AnalysisMetadata {
46
- textLength: number;
47
- issuesCount: number;
48
- processingTimeMs: number;
49
- contextUsed?: boolean;
50
- model?: string;
51
- provider?: string;
52
-}
53
-
54
-export interface AnalyzeResponse {
55
- issues: Issue[];
56
- metadata?: AnalysisMetadata;
57
- error?: string;
58
- message?: string;
59
-}
60
-
61
-export interface AutocompleteRequest {
62
- text: string;
63
- cursor: number;
64
- apiKey?: string;
65
- model?: string;
66
- provider?: LLMProvider;
67
- baseUrl?: string;
68
- context?: AnalysisContext;
69
-}
70
-
71
-export interface AutocompleteResponse {
72
- suggestion: string;
73
- confidence: number;
74
- replaceStart: number;
75
- replaceEnd: number;
76
- source: 'heuristic' | 'llm';
77
- error?: string;
78
-}
79
-
80
-export type LLMProvider = 'openai' | 'openrouter' | 'groq' | 'together' | 'ollama' | 'custom';
81
-
82
-export interface ProviderConfig {
83
- id: LLMProvider;
84
- name: string;
85
- baseUrl: string;
86
- models: string[];
87
- requiresApiKey: boolean;
88
- description: string;
89
-}
90
-
91
-export const PROVIDERS: ProviderConfig[] = [
92
- {
93
- id: 'openai',
94
- name: 'OpenAI',
95
- baseUrl: 'https://api.openai.com/v1',
96
- models: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
97
- requiresApiKey: true,
98
- description: 'Official OpenAI API',
99
- },
100
- {
101
- id: 'openrouter',
102
- name: 'OpenRouter',
103
- baseUrl: 'https://openrouter.ai/api/v1',
104
- models: [
105
- 'meta-llama/llama-3-70b-instruct',
106
- 'anthropic/claude-3.5-sonnet',
107
- 'google/gemma-2-27b-it',
108
- ],
109
- requiresApiKey: true,
110
- description: 'Access 100+ models with one API',
111
- },
112
- {
113
- id: 'groq',
114
- name: 'Groq',
115
- baseUrl: 'https://api.groq.com/openai/v1',
116
- models: [
117
- 'llama-3.1-70b-versatile',
118
- 'llama-3.1-8b-instant',
119
- 'gemma2-9b-it',
120
- 'mixtral-8x7b-32768',
121
- ],
122
- requiresApiKey: true,
123
- description: 'Blazing fast inference',
124
- },
125
- {
126
- id: 'together',
127
- name: 'Together AI',
128
- baseUrl: 'https://api.together.xyz/v1',
129
- models: [
130
- 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo',
131
- 'mistralai/Mixtral-8x7B-Instruct-v0.1',
132
- ],
133
- requiresApiKey: true,
134
- description: 'Open source models at scale',
135
- },
136
- {
137
- id: 'ollama',
138
- name: 'Ollama (Local)',
139
- baseUrl: 'http://localhost:11434/v1',
140
- models: ['qwen2.5:0.5b', 'qwen2.5:1.5b', 'phi4-mini:3.8b', 'llama3.2:3b', 'mistral:7b'],
141
- requiresApiKey: false,
142
- description: 'Run models locally on your machine',
143
- },
144
- {
145
- id: 'custom',
146
- name: 'Custom Endpoint',
147
- baseUrl: '',
148
- models: [],
149
- requiresApiKey: true,
150
- description: 'Your own OpenAI-compatible API',
151
- },
152
-];
1
+export * from '../../shared/src/types.js';
153
2
▼opengrammar/backend/src/spellchecker.ts
+57-34
1
1
import { getEnglishDictionary } from './dictionary.js';
2
2
import type { Issue } from './shared-types.js';
3
3
4
+const v0 = new Uint8Array(64);
5
+const v1 = new Uint8Array(64);
6
+const v2 = new Uint8Array(64);
7
+
4
8
/**
5
9
* Levenshtein distance between two strings.
6
10
* Used to find the closest dictionary words for a misspelled word.
9
13
const la = a.length;
10
14
const lb = b.length;
11
15
12
- // Early exits
13
16
if (la === 0) return lb;
14
17
if (lb === 0) return la;
15
18
if (a === b) return 0;
17
20
// Fast path: if lengths differ by more than max edit distance, skip
18
21
if (Math.abs(la - lb) > 2) return Math.abs(la - lb);
19
22
20
- const matrix: number[][] = [];
21
-
22
- for (let i = 0; i <= la; i++) {
23
- matrix[i] = [i];
24
- }
25
- for (let j = 0; j <= lb; j++) {
26
- matrix[0]![j] = j;
27
- }
23
+ for (let i = 0; i <= lb; i++) v0[i] = i;
28
24
29
- for (let i = 1; i <= la; i++) {
30
- for (let j = 1; j <= lb; j++) {
31
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
32
- matrix[i]![j] = Math.min(
33
- matrix[i - 1]![j]! + 1, // deletion
34
- matrix[i]![j - 1]! + 1, // insertion
35
- matrix[i - 1]![j - 1]! + cost, // substitution
36
- );
25
+ for (let i = 0; i < la; i++) {
26
+ v1[0] = i + 1;
27
+ for (let j = 0; j < lb; j++) {
28
+ const cost = a[i] === b[j] ? 0 : 1;
29
+ let min = v1[j]! + 1; // insertion
30
+ if (v0[j + 1]! + 1 < min) min = v0[j + 1]! + 1; // deletion
31
+ if (v0[j]! + cost < min) min = v0[j]! + cost; // substitution
37
32
38
33
// Transposition (Damerau extension)
39
- if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
40
- matrix[i]![j] = Math.min(matrix[i]![j]!, matrix[i - 2]![j - 2]! + cost);
34
+ if (i > 0 && j > 0 && a[i] === b[j - 1] && a[i - 1] === b[j]) {
35
+ if (v2[j - 1]! + cost < min) min = v2[j - 1]! + cost;
41
36
}
37
+
38
+ v1[j + 1] = min;
39
+ }
40
+ for (let j = 0; j <= lb; j++) {
41
+ v2[j] = v0[j]!;
42
+ v0[j] = v1[j]!;
42
43
}
43
44
}
44
45
45
- return matrix[la]![lb]!;
46
+ return v1[lb]!;
46
47
}
47
48
48
49
/**
296
297
return _soundexCache;
297
298
}
298
299
300
+let _lengthIndexCache: Map<number, string[]> | null = null;
301
+
302
+function getLengthIndex(dictionary: Set<string>): Map<number, string[]> {
303
+ if (!_lengthIndexCache) {
304
+ _lengthIndexCache = new Map();
305
+ for (const word of dictionary) {
306
+ const len = word.length;
307
+ let list = _lengthIndexCache.get(len);
308
+ if (!list) {
309
+ list = [];
310
+ _lengthIndexCache.set(len, list);
311
+ }
312
+ list.push(word);
313
+ }
314
+ }
315
+ return _lengthIndexCache;
316
+}
317
+
299
318
/**
300
319
* Find the best spelling suggestions for a misspelled word.
301
320
* Uses Levenshtein distance + Soundex phonetic matching.
309
328
const len = lower.length;
310
329
const wordSoundex = soundex(lower);
311
330
const soundexMap = getSoundexCache(dictionary);
331
+ const lengthIndex = getLengthIndex(dictionary);
312
332
313
333
// Candidates: words with similar length and either similar sound or close edit distance
314
334
const candidates: Array<{ word: string; distance: number; score: number }> = [];
315
335
316
- for (const dictWord of dictionary) {
317
- // Quick length filter: skip words with very different lengths
318
- if (Math.abs(dictWord.length - len) > 2) continue;
319
-
320
- // Prioritize words that start with the same letter
321
- const sameStart = dictWord[0] === lower[0];
322
- if (!sameStart && Math.abs(dictWord.length - len) > 1) continue;
323
-
324
- const dist = levenshtein(lower, dictWord);
325
- if (dist <= 2) {
326
- // Phonetic bonus
327
- const phoneticMatch = soundexMap.get(dictWord) === wordSoundex;
328
- const score = dist - (phoneticMatch ? 0.5 : 0) - (sameStart ? 0.3 : 0);
329
- candidates.push({ word: dictWord, distance: dist, score });
336
+ for (let l = Math.max(1, len - 2); l <= len + 2; l++) {
337
+ const list = lengthIndex.get(l);
338
+ if (!list) continue;
339
+
340
+ for (let i = 0; i < list.length; i++) {
341
+ const dictWord = list[i]!;
342
+
343
+ const sameStart = dictWord[0] === lower[0];
344
+ if (!sameStart && Math.abs(l - len) > 1) continue;
345
+
346
+ const dist = levenshtein(lower, dictWord);
347
+ if (dist <= 2) {
348
+ // Phonetic bonus
349
+ const phoneticMatch = soundexMap.get(dictWord) === wordSoundex;
350
+ const score = dist - (phoneticMatch ? 0.5 : 0) - (sameStart ? 0.3 : 0);
351
+ candidates.push({ word: dictWord, distance: dist, score });
352
+ }
330
353
}
331
354
}
332
355
333
356
▼opengrammar/extension/src/background/index.ts
+10-2
447
447
const data = await response.json();
448
448
return data.providers || [];
449
449
} catch (error) {
450
- console.error('Failed to fetch providers:', error);
450
+ if (error instanceof TypeError && error.message === 'Failed to fetch') {
451
+ console.debug(`[OpenGrammar] Backend is offline or unreachable at the configured URL.`);
452
+ } else {
453
+ console.error('Failed to fetch providers:', error);
454
+ }
451
455
return [];
452
456
}
453
457
}
464
468
const data = await response.json();
465
469
return data.models || [];
466
470
} catch (error) {
467
- console.error('Failed to fetch models:', error);
471
+ if (error instanceof TypeError && error.message === 'Failed to fetch') {
472
+ console.debug(`[OpenGrammar] Backend is offline or unreachable at the configured URL.`);
473
+ } else {
474
+ console.error('Failed to fetch models:', error);
475
+ }
468
476
return [];
469
477
}
470
478
}
471
479
▼opengrammar/extension/src/content/index.ts
+52-1
396
396
*/
397
397
function handleGrammarSuccess(element: HTMLElement, text: string, issues: Issue[]) {
398
398
const editableElement = activeElements.get(element);
399
+
400
+ // Ignore stale results if the text has changed since the request was made
401
+ if (extractText(element) !== text) {
402
+ return;
403
+ }
404
+
399
405
if (issues && issues.length > 0) {
400
406
if (editableElement) editableElement.lastIssues = issues;
401
407
void syncActiveContext(text, issues);
408
+ if (editableElement && editableElement.observer) {
409
+ editableElement.observer.disconnect();
410
+ }
411
+
412
+ // Save caret position before modifying the DOM
413
+ const savedCaret = getCaretPosition(element);
414
+
402
415
highlightIssues(element, issues);
403
416
417
+ // Restore caret position after highlighting
418
+ if (element.tagName !== 'INPUT' && element.tagName !== 'TEXTAREA') {
419
+ setCaretPosition(element, savedCaret);
420
+ }
421
+
422
+ if (editableElement && editableElement.observer) {
423
+ editableElement.lastText = extractText(element);
424
+ editableElement.observer.observe(element, {
425
+ childList: true,
426
+ characterData: true,
427
+ subtree: true,
428
+ });
429
+ }
430
+
404
431
const statsPerType = { grammar: 0, spelling: 0, clarity: 0, style: 0 };
405
432
for (const issue of issues) {
406
433
if (issue.type in statsPerType) statsPerType[issue.type as keyof typeof statsPerType]++;
428
455
payload: { wordsChecked: wordCount, issuesFound: issues.length, issuesFixed: 0, writingScore, errorTypes },
429
456
});
430
457
} else {
431
- if (editableElement) editableElement.lastIssues = [];
458
+ if (editableElement) {
459
+ editableElement.lastIssues = [];
460
+ if (editableElement.observer) editableElement.observer.disconnect();
461
+ }
462
+
463
+ // Save caret position before clearing highlights (DOM mutation)
464
+ const savedCaret = getCaretPosition(element);
465
+
466
+ clearHighlights();
467
+
468
+ // Restore caret position after clearing highlights
469
+ if (element.tagName !== 'INPUT' && element.tagName !== 'TEXTAREA') {
470
+ setCaretPosition(element, savedCaret);
471
+ }
472
+
473
+ if (editableElement && editableElement.observer) {
474
+ editableElement.lastText = extractText(element);
475
+ editableElement.observer.observe(element, { childList: true, characterData: true, subtree: true });
476
+ }
432
477
void syncActiveContext(text, []);
433
478
void chrome.runtime.sendMessage({ type: 'UPDATE_BADGE_COUNT', count: 0 });
434
479
}
444
489
445
490
const text = extractText(element);
446
491
if (!text || text.trim().length < 5) {
492
+ const editableElement = activeElements.get(element);
493
+ if (editableElement?.observer) editableElement.observer.disconnect();
447
494
clearHighlights();
495
+ if (editableElement?.observer) {
496
+ editableElement.lastText = extractText(element);
497
+ editableElement.observer.observe(element, { childList: true, characterData: true, subtree: true });
498
+ }
448
499
return;
449
500
}
450
501
451
502
▼opengrammar/extension/src/types.ts
+1-146
1
-export type LLMProvider = 'openai' | 'openrouter' | 'groq' | 'together' | 'ollama' | 'custom';
2
-
3
-export interface ProviderConfig {
4
- id: LLMProvider;
5
- name: string;
6
- baseUrl: string;
7
- models: string[];
8
- requiresApiKey: boolean;
9
- description: string;
10
-}
11
-
12
-export interface Issue {
13
- type: 'grammar' | 'spelling' | 'clarity' | 'style';
14
- original: string;
15
- suggestion: string;
16
- reason: string;
17
- offset: number;
18
- length: number;
19
- id?: string;
20
- ignored?: boolean;
21
- confidence?: number;
22
- priority?: number;
23
- source?: 'rule' | 'llm' | 'context';
24
-}
25
-
26
-export interface IgnoredIssue {
27
- id: string;
28
- type: Issue['type'];
29
- original: string;
30
- suggestion: string;
31
- ignoredAt: number;
32
-}
33
-
34
-export interface AnalyzeRequest {
35
- text: string;
36
- apiKey?: string;
37
- model?: string;
38
- provider?: LLMProvider;
39
- baseUrl?: string;
40
- ignoredIssues?: string[];
41
- dictionary?: string[];
42
- context?: AnalysisContext;
43
- disabledModules?: string[];
44
-}
45
-
46
-export interface AnalysisContext {
47
- domain?: string;
48
- editorType?: string;
49
- activeSentence?: string;
50
- previousText?: string;
51
- nextText?: string;
52
- fullTextExcerpt?: string;
53
-}
54
-
55
-export interface AnalyzeResponse {
56
- issues: Issue[];
57
- metadata?: {
58
- textLength: number;
59
- issuesCount: number;
60
- processingTimeMs: number;
61
- contextUsed?: boolean;
62
- model?: string;
63
- provider?: string;
64
- };
65
- error?: string;
66
- message?: string;
67
-}
68
-
69
-export interface HighlightData {
70
- issue: Issue;
71
- element: HTMLElement;
72
- range: Range;
73
-}
74
-
75
-export interface RewriteRequest {
76
- text: string;
77
- tone:
78
- | 'formal'
79
- | 'casual'
80
- | 'professional'
81
- | 'friendly'
82
- | 'concise'
83
- | 'detailed'
84
- | 'persuasive'
85
- | 'neutral';
86
- apiKey?: string;
87
- model?: string;
88
- provider?: LLMProvider;
89
- baseUrl?: string;
90
-}
91
-
92
-export interface RewriteResponse {
93
- original: string;
94
- rewritten: string;
95
- tone: string;
96
- error?: string;
97
-}
98
-
99
-export interface AutocompleteRequest {
100
- text: string;
101
- cursor: number;
102
- apiKey?: string;
103
- model?: string;
104
- provider?: LLMProvider;
105
- baseUrl?: string;
106
- context?: AnalysisContext;
107
-}
108
-
109
-export interface AutocompleteResponse {
110
- suggestion: string;
111
- confidence: number;
112
- replaceStart: number;
113
- replaceEnd: number;
114
- source: 'heuristic' | 'llm';
115
- error?: string;
116
-}
117
-
118
-export interface EditorContext {
119
- text: string;
120
- issues: Issue[];
121
- sourceTabId?: number;
122
- capturedAt: number;
123
-}
124
-
125
-export interface RewriteContext {
126
- selectedText: string;
127
- sourceTabId?: number;
128
- capturedAt: number;
129
-}
130
-
131
-export type AnalyticsEventType =
132
- | 'analysis_runs'
133
- | 'issues_found'
134
- | 'suggestions_applied'
135
- | 'suggestions_ignored'
136
- | 'autocomplete_shown'
137
- | 'autocomplete_accepted'
138
- | 'rewrite_opened'
139
- | 'rewrite_applied';
140
-
141
-export interface AnalyticsSummary {
142
- totals: Record<AnalyticsEventType, number>;
143
- domains: Record<string, number>;
144
- providers: Record<string, number>;
145
- lastUpdatedAt?: number;
146
-}
1
+export * from '../../shared/src/types';
147
2
▼opengrammar/shared/package.json
+3-3
2
2
"name": "shared",
3
3
"version": "1.0.0",
4
4
"description": "",
5
- "main": "index.js",
5
+ "main": "src/types.js",
6
6
"scripts": {
7
7
"test": "echo \"Error: no test specified\" && exit 1"
8
8
},
9
9
"keywords": [],
10
10
"author": "",
11
11
"license": "ISC",
12
- "type": "commonjs",
12
+ "type": "module",
13
13
"devDependencies": {
14
14
"typescript": "^5.9.3"
15
15
}
16
-}
16
+}
17
17
▼opengrammar/shared/src/types.ts
+187-6
1
+export type LLMProvider = 'openai' | 'openrouter' | 'groq' | 'together' | 'ollama' | 'custom';
2
+
3
+export interface ProviderConfig {
4
+ id: LLMProvider;
5
+ name: string;
6
+ baseUrl: string;
7
+ models: string[];
8
+ requiresApiKey: boolean;
9
+ description: string;
10
+}
11
+
12
+export const PROVIDERS: ProviderConfig[] = [
13
+ {
14
+ id: 'openai',
15
+ name: 'OpenAI',
16
+ baseUrl: 'https://api.openai.com/v1',
17
+ models: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
18
+ requiresApiKey: true,
19
+ description: 'Official OpenAI API',
20
+ },
21
+ {
22
+ id: 'openrouter',
23
+ name: 'OpenRouter',
24
+ baseUrl: 'https://openrouter.ai/api/v1',
25
+ models: [
26
+ 'meta-llama/llama-3-70b-instruct',
27
+ 'anthropic/claude-3.5-sonnet',
28
+ 'google/gemma-2-27b-it',
29
+ ],
30
+ requiresApiKey: true,
31
+ description: 'Access 100+ models with one API',
32
+ },
33
+ {
34
+ id: 'groq',
35
+ name: 'Groq',
36
+ baseUrl: 'https://api.groq.com/openai/v1',
37
+ models: [
38
+ 'llama-3.1-70b-versatile',
39
+ 'llama-3.1-8b-instant',
40
+ 'gemma2-9b-it',
41
+ 'mixtral-8x7b-32768',
42
+ ],
43
+ requiresApiKey: true,
44
+ description: 'Blazing fast inference',
45
+ },
46
+ {
47
+ id: 'together',
48
+ name: 'Together AI',
49
+ baseUrl: 'https://api.together.xyz/v1',
50
+ models: [
51
+ 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo',
52
+ 'mistralai/Mixtral-8x7B-Instruct-v0.1',
53
+ ],
54
+ requiresApiKey: true,
55
+ description: 'Open source models at scale',
56
+ },
57
+ {
58
+ id: 'ollama',
59
+ name: 'Ollama (Local)',
60
+ baseUrl: 'http://localhost:11434/v1',
61
+ models: ['qwen2.5:0.5b', 'qwen2.5:1.5b', 'phi4-mini:3.8b', 'llama3.2:3b', 'mistral:7b'],
62
+ requiresApiKey: false,
63
+ description: 'Run models locally on your machine',
64
+ },
65
+ {
66
+ id: 'custom',
67
+ name: 'Custom Endpoint',
68
+ baseUrl: '',
69
+ models: [],
70
+ requiresApiKey: true,
71
+ description: 'Your own OpenAI-compatible API',
72
+ },
73
+];
74
+
1
75
export interface Issue {
2
76
type: 'grammar' | 'spelling' | 'clarity' | 'style';
3
77
original: string;
5
79
reason: string;
6
80
offset: number;
7
81
length: number;
8
- // Optional fields for enhanced functionality
9
82
id?: string;
10
83
ignored?: boolean;
84
+ confidence?: number;
85
+ priority?: number;
86
+ source?: 'rule' | 'llm' | 'context';
11
87
}
12
88
13
-export interface AnalyzeRequest {
14
- text: string;
15
- apiKey?: string;
16
- model?: string;
17
- ignoredIssues?: string[];
89
+export interface IgnoredIssue {
90
+ id: string;
91
+ type: Issue['type'];
92
+ original: string;
93
+ suggestion: string;
94
+ ignoredAt: number;
95
+}
96
+
97
+export interface AnalysisContext {
98
+ domain?: string;
99
+ editorType?: string;
100
+ activeSentence?: string;
101
+ previousText?: string;
102
+ nextText?: string;
103
+ fullTextExcerpt?: string;
18
104
}
19
105
20
106
export interface CustomRule {
25
111
type: 'grammar' | 'spelling' | 'clarity' | 'style';
26
112
}
27
113
114
+export interface AnalyzeRequest {
115
+ text: string;
116
+ apiKey?: string;
117
+ model?: string;
118
+ provider?: LLMProvider;
119
+ baseUrl?: string;
120
+ ignoredIssues?: string[];
121
+ customRules?: CustomRule[];
122
+ dictionary?: string[];
123
+ context?: AnalysisContext;
124
+ disabledModules?: string[];
125
+}
126
+
28
127
export interface AnalysisMetadata {
29
128
textLength: number;
30
129
issuesCount: number;
31
130
processingTimeMs: number;
131
+ contextUsed?: boolean;
132
+ model?: string;
133
+ provider?: string;
32
134
}
33
135
34
136
export interface AnalyzeResponse {
37
139
error?: string;
38
140
message?: string;
39
141
}
142
+
143
+export interface HighlightData {
144
+ issue: Issue;
145
+ element: HTMLElement;
146
+ range: Range;
147
+}
148
+
149
+export interface RewriteRequest {
150
+ text: string;
151
+ tone:
152
+ | 'formal'
153
+ | 'casual'
154
+ | 'professional'
155
+ | 'friendly'
156
+ | 'concise'
157
+ | 'detailed'
158
+ | 'persuasive'
159
+ | 'neutral';
160
+ apiKey?: string;
161
+ model?: string;
162
+ provider?: LLMProvider;
163
+ baseUrl?: string;
164
+}
165
+
166
+export interface RewriteResponse {
167
+ original: string;
168
+ rewritten: string;
169
+ tone: string;
170
+ error?: string;
171
+}
172
+
173
+export interface AutocompleteRequest {
174
+ text: string;
175
+ cursor: number;
176
+ apiKey?: string;
177
+ model?: string;
178
+ provider?: LLMProvider;
179
+ baseUrl?: string;
180
+ context?: AnalysisContext;
181
+}
182
+
183
+export interface AutocompleteResponse {
184
+ suggestion: string;
185
+ confidence: number;
186
+ replaceStart: number;
187
+ replaceEnd: number;
188
+ source: 'heuristic' | 'llm';
189
+ error?: string;
190
+}
191
+
192
+export interface EditorContext {
193
+ text: string;
194
+ issues: Issue[];
195
+ sourceTabId?: number;
196
+ capturedAt: number;
197
+}
198
+
199
+export interface RewriteContext {
200
+ selectedText: string;
201
+ sourceTabId?: number;
202
+ capturedAt: number;
203
+}
204
+
205
+export type AnalyticsEventType =
206
+ | 'analysis_runs'
207
+ | 'issues_found'
208
+ | 'suggestions_applied'
209
+ | 'suggestions_ignored'
210
+ | 'autocomplete_shown'
211
+ | 'autocomplete_accepted'
212
+ | 'rewrite_opened'
213
+ | 'rewrite_applied';
214
+
215
+export interface AnalyticsSummary {
216
+ totals: Record<AnalyticsEventType, number>;
217
+ domains: Record<string, number>;
218
+ providers: Record<string, number>;
219
+ lastUpdatedAt?: number;
220
+}
40
221