opengrammar-server 2.0.615350

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.npm.md +95 -0
  2. package/bin/opengrammar-server.js +111 -0
  3. package/dist/server.js +48639 -0
  4. package/package.json +80 -0
  5. package/server-node.ts +159 -0
  6. package/server.ts +15 -0
  7. package/src/analyzer.ts +542 -0
  8. package/src/dictionary.ts +1973 -0
  9. package/src/index.ts +978 -0
  10. package/src/nlp/nlp-engine.ts +17 -0
  11. package/src/nlp/tone-analyzer.ts +269 -0
  12. package/src/rephraser.ts +146 -0
  13. package/src/rules/categories/academic-writing.ts +182 -0
  14. package/src/rules/categories/adjectives-adverbs.ts +152 -0
  15. package/src/rules/categories/articles.ts +160 -0
  16. package/src/rules/categories/business-writing.ts +250 -0
  17. package/src/rules/categories/capitalization.ts +79 -0
  18. package/src/rules/categories/clarity.ts +117 -0
  19. package/src/rules/categories/common-errors.ts +601 -0
  20. package/src/rules/categories/confused-words.ts +219 -0
  21. package/src/rules/categories/conjunctions.ts +176 -0
  22. package/src/rules/categories/dangling-modifiers.ts +123 -0
  23. package/src/rules/categories/formality.ts +274 -0
  24. package/src/rules/categories/formatting-idioms.ts +323 -0
  25. package/src/rules/categories/gerund-infinitive.ts +274 -0
  26. package/src/rules/categories/grammar-advanced.ts +294 -0
  27. package/src/rules/categories/grammar.ts +286 -0
  28. package/src/rules/categories/inclusive-language.ts +280 -0
  29. package/src/rules/categories/nouns-pronouns.ts +233 -0
  30. package/src/rules/categories/prepositions-extended.ts +217 -0
  31. package/src/rules/categories/prepositions.ts +159 -0
  32. package/src/rules/categories/punctuation.ts +347 -0
  33. package/src/rules/categories/quantity-agreement.ts +200 -0
  34. package/src/rules/categories/readability.ts +293 -0
  35. package/src/rules/categories/sentence-structure.ts +100 -0
  36. package/src/rules/categories/spelling-advanced.ts +164 -0
  37. package/src/rules/categories/spelling.ts +119 -0
  38. package/src/rules/categories/style-tone.ts +511 -0
  39. package/src/rules/categories/style.ts +78 -0
  40. package/src/rules/categories/subject-verb-agreement.ts +201 -0
  41. package/src/rules/categories/tone-rules.ts +206 -0
  42. package/src/rules/categories/verb-tense.ts +582 -0
  43. package/src/rules/context-filter.ts +446 -0
  44. package/src/rules/index.ts +96 -0
  45. package/src/rules/ruleset-part1-cj-pu-sp.json +657 -0
  46. package/src/rules/ruleset-part1-np-ad-aa-pr.json +831 -0
  47. package/src/rules/ruleset-part1-ss-vt.json +907 -0
  48. package/src/rules/ruleset-part2-cw-st-nf.json +318 -0
  49. package/src/rules/ruleset-part3-aw-bw-il-rd.json +161 -0
  50. package/src/rules/types.ts +79 -0
  51. package/src/shared-types.ts +152 -0
  52. package/src/spellchecker.ts +418 -0
  53. package/tsconfig.json +25 -0
package/src/index.ts ADDED
@@ -0,0 +1,978 @@
1
+ import { Hono } from 'hono';
2
+ import { cors } from 'hono/cors';
3
+ import { logger } from 'hono/logger';
4
+ import OpenAI from 'openai';
5
+ import { LLMAnalyzer, RuleBasedAnalyzer } from './analyzer.js';
6
+ import { analyzeTone } from './nlp/tone-analyzer.js';
7
+ import { Rephraser, type RephraseGoal } from './rephraser.js';
8
+ import { detectWritingContext } from './rules/context-filter.js';
9
+ import type {
10
+ AnalysisContext,
11
+ AnalyzeRequest,
12
+ AnalyzeResponse,
13
+ AutocompleteRequest,
14
+ Issue,
15
+ LLMProvider,
16
+ } from './shared-types.js';
17
+ import { PROVIDERS } from './shared-types.js';
18
+
19
+ const app = new Hono();
20
+
21
+ // Middleware
22
+ app.use('/*', logger());
23
+ app.use('/*', cors());
24
+
25
+ // Root Landing Page
26
+ app.get('/', (c) => {
27
+ return c.html(`<!DOCTYPE html>
28
+ <html lang="en">
29
+ <head>
30
+ <meta charset="UTF-8">
31
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
32
+ <title>OpenGrammar API</title>
33
+ <meta name="description" content="OpenGrammar — open-source, privacy-first grammar intelligence engine running on the Edge.">
34
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
35
+ <style>
36
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
37
+
38
+ :root {
39
+ --bg: #09090b;
40
+ --surface: rgba(24, 24, 27, 0.8);
41
+ --border: rgba(255,255,255,0.07);
42
+ --border-hover: rgba(255,255,255,0.14);
43
+ --text: #fafafa;
44
+ --muted: #71717a;
45
+ --green: #22c55e;
46
+ --green-bg: rgba(34,197,94,0.08);
47
+ --green-border: rgba(34,197,94,0.2);
48
+ --amber: #f59e0b;
49
+ --amber-bg: rgba(245,158,11,0.08);
50
+ --amber-border: rgba(245,158,11,0.2);
51
+ --red: #ef4444;
52
+ --red-bg: rgba(239,68,68,0.08);
53
+ --red-border: rgba(239,68,68,0.2);
54
+ --blue: #3b82f6;
55
+ }
56
+
57
+ html, body {
58
+ height: 100%;
59
+ font-family: 'Inter', system-ui, sans-serif;
60
+ background: var(--bg);
61
+ color: var(--text);
62
+ line-height: 1.5;
63
+ overflow-x: hidden;
64
+ }
65
+
66
+ body {
67
+ min-height: 100vh;
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ padding: 40px 20px;
72
+ }
73
+
74
+ /* Ambient background orbs */
75
+ .orb {
76
+ position: fixed;
77
+ border-radius: 50%;
78
+ filter: blur(120px);
79
+ pointer-events: none;
80
+ z-index: 0;
81
+ }
82
+ .orb-1 {
83
+ width: 500px; height: 500px;
84
+ background: radial-gradient(circle, rgba(59,130,246,0.18) 0%, transparent 70%);
85
+ top: -150px; left: -100px;
86
+ }
87
+ .orb-2 {
88
+ width: 450px; height: 450px;
89
+ background: radial-gradient(circle, rgba(139,92,246,0.15) 0%, transparent 70%);
90
+ bottom: -120px; right: -80px;
91
+ }
92
+
93
+ .page {
94
+ position: relative;
95
+ z-index: 1;
96
+ width: 100%;
97
+ max-width: 680px;
98
+ }
99
+
100
+ /* Card */
101
+ .card {
102
+ background: var(--surface);
103
+ border: 1px solid var(--border);
104
+ border-radius: 20px;
105
+ padding: 48px 40px;
106
+ backdrop-filter: blur(20px);
107
+ -webkit-backdrop-filter: blur(20px);
108
+ box-shadow: 0 0 0 1px rgba(255,255,255,0.03), 0 32px 64px rgba(0,0,0,0.5);
109
+ }
110
+
111
+ /* Hero */
112
+ .hero { text-align: center; margin-bottom: 36px; }
113
+
114
+ .logo-ring {
115
+ display: inline-flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ width: 56px; height: 56px;
119
+ border-radius: 16px;
120
+ background: linear-gradient(135deg, #3b82f6, #8b5cf6);
121
+ margin-bottom: 20px;
122
+ box-shadow: 0 8px 24px rgba(59,130,246,0.35);
123
+ }
124
+
125
+ h1 {
126
+ font-size: 2.5rem;
127
+ font-weight: 800;
128
+ letter-spacing: -0.04em;
129
+ background: linear-gradient(135deg, #e2e8f0 30%, #94a3b8);
130
+ -webkit-background-clip: text;
131
+ -webkit-text-fill-color: transparent;
132
+ background-clip: text;
133
+ margin-bottom: 12px;
134
+ }
135
+
136
+ .hero p {
137
+ color: var(--muted);
138
+ font-size: 1rem;
139
+ max-width: 440px;
140
+ margin: 0 auto 24px;
141
+ }
142
+
143
+ /* Status pill */
144
+ .status-pill {
145
+ display: inline-flex;
146
+ align-items: center;
147
+ gap: 7px;
148
+ background: var(--green-bg);
149
+ border: 1px solid var(--green-border);
150
+ color: var(--green);
151
+ padding: 6px 14px;
152
+ border-radius: 999px;
153
+ font-size: 0.82rem;
154
+ font-weight: 600;
155
+ }
156
+ .dot {
157
+ width: 7px; height: 7px;
158
+ border-radius: 50%;
159
+ background: var(--green);
160
+ box-shadow: 0 0 8px var(--green);
161
+ animation: pulse 2.5s ease-in-out infinite;
162
+ }
163
+ @keyframes pulse {
164
+ 0%, 100% { opacity: 1; transform: scale(1); }
165
+ 50% { opacity: 0.5; transform: scale(0.8); }
166
+ }
167
+
168
+ /* Endpoints */
169
+ .endpoints {
170
+ display: flex;
171
+ gap: 10px;
172
+ justify-content: center;
173
+ flex-wrap: wrap;
174
+ margin: 24px 0;
175
+ }
176
+ .ep-tag {
177
+ font-family: 'Menlo', 'Monaco', monospace;
178
+ font-size: 0.8rem;
179
+ color: #94a3b8;
180
+ background: rgba(0,0,0,0.35);
181
+ border: 1px solid var(--border);
182
+ border-radius: 8px;
183
+ padding: 7px 14px;
184
+ letter-spacing: 0.02em;
185
+ }
186
+ .ep-tag span {
187
+ color: #60a5fa;
188
+ margin-right: 6px;
189
+ font-weight: 600;
190
+ }
191
+
192
+ /* Buttons */
193
+ .btn-row {
194
+ display: flex;
195
+ gap: 10px;
196
+ justify-content: center;
197
+ flex-wrap: wrap;
198
+ margin-bottom: 36px;
199
+ }
200
+ .btn {
201
+ display: inline-flex;
202
+ align-items: center;
203
+ gap: 8px;
204
+ padding: 10px 20px;
205
+ border-radius: 10px;
206
+ font-size: 0.875rem;
207
+ font-weight: 600;
208
+ text-decoration: none;
209
+ border: 1px solid var(--border);
210
+ color: var(--text);
211
+ background: rgba(255,255,255,0.04);
212
+ transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
213
+ }
214
+ .btn:hover {
215
+ background: rgba(255,255,255,0.08);
216
+ border-color: var(--border-hover);
217
+ transform: translateY(-1px);
218
+ box-shadow: 0 6px 16px rgba(0,0,0,0.3);
219
+ }
220
+ .btn.primary {
221
+ background: rgba(59,130,246,0.15);
222
+ border-color: rgba(59,130,246,0.35);
223
+ color: #93c5fd;
224
+ }
225
+ .btn.primary:hover {
226
+ background: rgba(59,130,246,0.25);
227
+ border-color: rgba(59,130,246,0.55);
228
+ }
229
+
230
+ /* Divider */
231
+ .divider {
232
+ height: 1px;
233
+ background: var(--border);
234
+ margin: 0 0 28px;
235
+ }
236
+
237
+ /* Section heading */
238
+ .section-head {
239
+ display: flex;
240
+ align-items: center;
241
+ justify-content: space-between;
242
+ margin-bottom: 16px;
243
+ }
244
+ .section-title {
245
+ font-size: 0.82rem;
246
+ font-weight: 600;
247
+ text-transform: uppercase;
248
+ letter-spacing: 0.08em;
249
+ color: var(--muted);
250
+ }
251
+ .refresh-hint {
252
+ font-size: 0.75rem;
253
+ color: var(--muted);
254
+ opacity: 0.6;
255
+ }
256
+
257
+ /* Server grid */
258
+ .server-grid {
259
+ display: grid;
260
+ grid-template-columns: repeat(3, 1fr);
261
+ gap: 12px;
262
+ }
263
+ @media (max-width: 560px) {
264
+ .server-grid { grid-template-columns: 1fr; }
265
+ .card { padding: 32px 24px; }
266
+ h1 { font-size: 2rem; }
267
+ }
268
+
269
+ .server-card {
270
+ background: rgba(0,0,0,0.25);
271
+ border: 1px solid var(--border);
272
+ border-radius: 14px;
273
+ padding: 14px 16px;
274
+ transition: border-color 0.15s, transform 0.15s;
275
+ }
276
+ .server-card:hover {
277
+ border-color: var(--border-hover);
278
+ transform: translateY(-1px);
279
+ }
280
+ .sc-header {
281
+ display: flex;
282
+ align-items: center;
283
+ justify-content: space-between;
284
+ margin-bottom: 6px;
285
+ }
286
+ .sc-name {
287
+ font-size: 0.85rem;
288
+ font-weight: 600;
289
+ color: var(--text);
290
+ }
291
+ .sc-meta {
292
+ font-size: 0.76rem;
293
+ color: var(--muted);
294
+ }
295
+
296
+ /* Status badges */
297
+ .badge {
298
+ display: inline-flex;
299
+ align-items: center;
300
+ gap: 5px;
301
+ padding: 3px 9px;
302
+ border-radius: 999px;
303
+ font-size: 0.72rem;
304
+ font-weight: 600;
305
+ border: 1px solid transparent;
306
+ }
307
+ .badge-dot { width: 5px; height: 5px; border-radius: 50%; }
308
+
309
+ .badge.active { background: var(--green-bg); color: var(--green); border-color: var(--green-border); }
310
+ .badge-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); animation: pulse 2.5s infinite; }
311
+
312
+ .badge.standby { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
313
+ .badge-dot.standby { background: var(--amber); }
314
+
315
+ .badge.offline { background: var(--red-bg); color: var(--red); border-color: var(--red-border); }
316
+ .badge-dot.offline { background: var(--red); }
317
+
318
+ .badge.checking { background: rgba(100,116,139,0.1); color: #64748b; border-color: rgba(100,116,139,0.2); }
319
+ .badge-dot.checking { background: #64748b; animation: pulse 1s infinite; }
320
+
321
+ /* Footer */
322
+ .footer {
323
+ text-align: center;
324
+ margin-top: 20px;
325
+ font-size: 0.78rem;
326
+ color: var(--muted);
327
+ opacity: 0.5;
328
+ }
329
+
330
+ @keyframes float {
331
+ 0% { transform: translate(0,0); }
332
+ 100% { transform: translate(30px, 20px); }
333
+ }
334
+ </style>
335
+ </head>
336
+ <body>
337
+ <div class="orb orb-1"></div>
338
+ <div class="orb orb-2"></div>
339
+
340
+ <div class="page">
341
+ <div class="card">
342
+
343
+ <!-- Hero -->
344
+ <div class="hero">
345
+ <div class="logo-ring">
346
+ <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
347
+ <path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
348
+ </svg>
349
+ </div>
350
+ <h1>OpenGrammar</h1>
351
+ <p>Privacy-first, open-source grammar intelligence engine — running live on the global Edge network.</p>
352
+ <div class="status-pill">
353
+ <div class="dot"></div>
354
+ API Operational
355
+ </div>
356
+ </div>
357
+
358
+ <!-- Endpoints -->
359
+ <div class="endpoints">
360
+ <div class="ep-tag"><span>POST</span>/analyze</div>
361
+ <div class="ep-tag"><span>POST</span>/autocomplete</div>
362
+ <div class="ep-tag"><span>GET</span>/health</div>
363
+ <div class="ep-tag"><span>GET</span>/providers</div>
364
+ </div>
365
+
366
+ <!-- Buttons -->
367
+ <div class="btn-row">
368
+ <a href="https://opengrammer.eu.cc/" target="_blank" class="btn primary">
369
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
370
+ <circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
371
+ <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
372
+ </svg>
373
+ Official Website
374
+ </a>
375
+ <a href="https://github.com/swadhinbiswas/opengrammar" target="_blank" class="btn">
376
+ <svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor">
377
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
378
+ </svg>
379
+ GitHub
380
+ </a>
381
+ <a href="/health" class="btn">
382
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
383
+ <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
384
+ </svg>
385
+ Health
386
+ </a>
387
+ </div>
388
+
389
+ <!-- Divider -->
390
+ <div class="divider"></div>
391
+
392
+ <!-- Network Status -->
393
+ <div class="section-head">
394
+ <span class="section-title">Global Network</span>
395
+ <span class="refresh-hint" id="last-updated">Checking…</span>
396
+ </div>
397
+
398
+ <div class="server-grid" id="server-grid">
399
+ <div class="server-card">
400
+ <div class="sc-header">
401
+ <span class="sc-name">Cloudflare Edge</span>
402
+ <span class="badge checking"><span class="badge-dot checking"></span>Checking</span>
403
+ </div>
404
+ <div class="sc-meta">Global CDN</div>
405
+ </div>
406
+ <div class="server-card">
407
+ <div class="sc-header">
408
+ <span class="sc-name">Vercel</span>
409
+ <span class="badge checking"><span class="badge-dot checking"></span>Checking</span>
410
+ </div>
411
+ <div class="sc-meta">US East</div>
412
+ </div>
413
+ <div class="server-card">
414
+ <div class="sc-header">
415
+ <span class="sc-name">Render</span>
416
+ <span class="badge checking"><span class="badge-dot checking"></span>Checking</span>
417
+ </div>
418
+ <div class="sc-meta">Frankfurt</div>
419
+ </div>
420
+ </div>
421
+
422
+ </div>
423
+ <div class="footer">OpenGrammar v2.0 · Cloudflare Workers · MIT License</div>
424
+ </div>
425
+
426
+ <script>
427
+ const SERVERS = [
428
+ { id: 'cf', name: 'Cloudflare Edge', url: '/health', role: 'primary', loc: 'Global CDN' },
429
+ { id: 'vercel', name: 'Vercel', url: 'https://opengrammar-backend-psi.vercel.app/health', role: 'standby', loc: 'US East' },
430
+ { id: 'render', name: 'Netlify', url: 'https://clinquant-sherbet-151cc5.netlify.app/health', role: 'standby', loc: 'US East' }
431
+ ];
432
+
433
+ function badgeHTML(state, label) {
434
+ return '<span class="badge ' + state + '"><span class="badge-dot ' + state + '"></span>' + label + '</span>';
435
+ }
436
+
437
+ function cardHTML(s, state, ping) {
438
+ const label = state === 'active' ? (s.role === 'primary' ? 'Primary' : 'Active')
439
+ : state === 'standby' ? 'Standby'
440
+ : state === 'offline' ? 'Offline'
441
+ : 'Checking';
442
+ const metaColor = state === 'offline' ? 'color:var(--red)' : 'color:var(--muted)';
443
+ const pingStr = (state === 'active' && ping != null) ? ' · ' + ping + 'ms' : (state === 'offline' ? ' · Unreachable' : '');
444
+ return '<div class="server-card"><div class="sc-header"><span class="sc-name">' + s.name + '</span>' + badgeHTML(state, label) + '</div><div class="sc-meta" style="' + metaColor + '">' + s.loc + pingStr + '</div></div>';
445
+ }
446
+
447
+ async function ping(server) {
448
+ try {
449
+ const ctrl = new AbortController();
450
+ const t = setTimeout(() => ctrl.abort(), 5000);
451
+ const t0 = performance.now();
452
+ const res = await fetch(server.url, { signal: ctrl.signal, cache: 'no-store' });
453
+ clearTimeout(t);
454
+ const ms = Math.round(performance.now() - t0);
455
+ if (res.ok) return { state: 'active', ping: ms };
456
+ return { state: 'offline', ping: null };
457
+ } catch { return { state: 'offline', ping: null }; }
458
+ }
459
+
460
+ async function refresh() {
461
+ const results = await Promise.all(SERVERS.map(s => ping(s)));
462
+ const grid = document.getElementById('server-grid');
463
+ if (grid) grid.innerHTML = SERVERS.map((s, i) => cardHTML(s, results[i].state, results[i].ping)).join('');
464
+ const el = document.getElementById('last-updated');
465
+ if (el) {
466
+ const now = new Date();
467
+ el.textContent = 'Updated ' + now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
468
+ }
469
+ }
470
+
471
+ refresh();
472
+ setInterval(refresh, 15000);
473
+ </script>
474
+ </body>
475
+ </html>`);
476
+ });
477
+
478
+ // Health check endpoint
479
+ app.get('/health', (c) => {
480
+ return c.json({
481
+ status: 'healthy',
482
+ timestamp: new Date().toISOString(),
483
+ environment: (c.env as any)?.ENV || 'unknown',
484
+ version: '2.0.0',
485
+ });
486
+ });
487
+
488
+ // List available providers
489
+ app.get('/providers', (c) => {
490
+ return c.json({
491
+ providers: PROVIDERS,
492
+ });
493
+ });
494
+
495
+ // Get models for a specific provider
496
+ app.post('/models', async (c) => {
497
+ try {
498
+ const { provider, apiKey, baseUrl } = await c.req.json();
499
+
500
+ if (!provider) {
501
+ return c.json({ error: 'Provider is required' }, 400);
502
+ }
503
+
504
+ // Get default models from config
505
+ const providerConfig = PROVIDERS.find((p) => p.id === provider);
506
+ const defaultModels = providerConfig?.models || [];
507
+
508
+ // Try to fetch live models, but don't fail if it doesn't work
509
+ let models = defaultModels;
510
+
511
+ // Only try to fetch live models if API key is provided (except for Ollama)
512
+ if ((apiKey && apiKey !== 'ollama') || provider === 'ollama') {
513
+ try {
514
+ const liveModels = await LLMAnalyzer.getModels(provider, apiKey, baseUrl);
515
+ if (liveModels.length > 0) {
516
+ models = liveModels;
517
+ }
518
+ } catch (fetchError) {
519
+ // Silently use default models if fetch fails
520
+ console.debug(`Using default models for ${provider}`);
521
+ }
522
+ }
523
+
524
+ return c.json({
525
+ provider,
526
+ models: models,
527
+ });
528
+ } catch (error) {
529
+ console.error('Failed to fetch models:', error);
530
+ // Return default models for the provider
531
+ const { provider } = await c.req.json().catch(() => ({}));
532
+ const providerConfig = PROVIDERS.find((p) => p.id === provider);
533
+ return c.json({
534
+ provider,
535
+ models: providerConfig?.models || [],
536
+ });
537
+ }
538
+ });
539
+
540
+ // Main analysis endpoint
541
+ app.post('/analyze', async (c) => {
542
+ const startTime = Date.now();
543
+
544
+ try {
545
+ const body: AnalyzeRequest = await c.req.json();
546
+ const { text, apiKey, model, provider, baseUrl, context, disabledModules } = body;
547
+
548
+ // Validate input
549
+ if (!text || typeof text !== 'string') {
550
+ return c.json({ error: 'Invalid request: text is required' }, 400);
551
+ }
552
+
553
+ if (text.length > 50000) {
554
+ return c.json({ error: 'Text exceeds maximum length of 50,000 characters' }, 413);
555
+ }
556
+
557
+ // Detect writing context for smart rule filtering
558
+ const writingContext = detectWritingContext(context?.domain);
559
+
560
+ // Run rule-based analysis with context-aware filtering
561
+ const ruleIssues = RuleBasedAnalyzer.analyze(text, { writingContext, disabledModules });
562
+ let issues = enrichIssues(ruleIssues, 'rule', text, context);
563
+
564
+ // Run LLM analysis if API key provided or using local Ollama
565
+ if (apiKey || provider === 'ollama') {
566
+ try {
567
+ const llmProvider = (provider || 'openai') as LLMProvider;
568
+ const llmIssues = await LLMAnalyzer.analyze(
569
+ text,
570
+ apiKey || '',
571
+ model || 'gpt-3.5-turbo',
572
+ llmProvider,
573
+ baseUrl,
574
+ context,
575
+ ruleIssues,
576
+ );
577
+ issues = [...issues, ...enrichIssues(llmIssues, 'llm', text, context)];
578
+ } catch (llmError) {
579
+ console.error('LLM analysis failed:', llmError);
580
+ // Continue with rule-based results only
581
+ }
582
+ }
583
+
584
+ issues = dedupeAndRankIssues(issues, text, context);
585
+
586
+ const duration = Date.now() - startTime;
587
+
588
+ const response: AnalyzeResponse = {
589
+ issues,
590
+ metadata: {
591
+ textLength: text.length,
592
+ issuesCount: issues.length,
593
+ processingTimeMs: duration,
594
+ ...(context && { contextUsed: true }),
595
+ ...(model && { model }),
596
+ ...(provider && { provider }),
597
+ },
598
+ };
599
+
600
+ return c.json(response);
601
+ } catch (error) {
602
+ console.error('Analysis error:', error);
603
+ return c.json(
604
+ {
605
+ error: 'Failed to analyze text',
606
+ message: error instanceof Error ? error.message : 'Unknown error',
607
+ },
608
+ 500,
609
+ );
610
+ }
611
+ });
612
+
613
+ // ─── POST /tone — rule-based tone analysis ───────────────────────────────────
614
+ app.post('/tone', async (c) => {
615
+ try {
616
+ const body = await c.req.json();
617
+ const { text, context } = body as { text: string; context?: { domain?: string } };
618
+ if (!text || typeof text !== 'string') {
619
+ return c.json({ error: 'text is required' }, 400);
620
+ }
621
+ const writingContext = context?.domain
622
+ ? detectWritingContext(context.domain)
623
+ : undefined;
624
+ const result = analyzeTone(text, writingContext);
625
+ return c.json({
626
+ dominant: result.dominant,
627
+ score: result.score,
628
+ signals: result.signals,
629
+ tips: result.tips,
630
+ });
631
+ } catch (err) {
632
+ console.error('Tone analysis error:', err);
633
+ return c.json({ error: 'Failed to analyze tone' }, 500);
634
+ }
635
+ });
636
+
637
+ // ─── POST /rephrase — AI-powered sentence alternatives ────────────────────────
638
+ app.post('/rephrase', async (c) => {
639
+ try {
640
+ const body = await c.req.json() as {
641
+ sentence: string;
642
+ goal?: RephraseGoal;
643
+ apiKey: string;
644
+ provider?: string;
645
+ model?: string;
646
+ baseUrl?: string;
647
+ };
648
+ const { sentence, goal = 'clarity', apiKey, provider = 'groq', model, baseUrl } = body;
649
+
650
+ if (!sentence || typeof sentence !== 'string') {
651
+ return c.json({ error: 'sentence is required' }, 400);
652
+ }
653
+ if (!apiKey) {
654
+ return c.json({ error: 'apiKey is required for rephrase' }, 400);
655
+ }
656
+
657
+ const result = await Rephraser.rephrase(
658
+ sentence,
659
+ goal,
660
+ apiKey,
661
+ provider as any,
662
+ model,
663
+ baseUrl,
664
+ );
665
+ return c.json(result);
666
+ } catch (err) {
667
+ console.error('Rephrase error:', err);
668
+ return c.json({ error: 'Failed to rephrase sentence' }, 500);
669
+ }
670
+ });
671
+
672
+ // Autocomplete / next-word suggestions
673
+ app.post('/autocomplete', async (c) => {
674
+ try {
675
+ const body: AutocompleteRequest = await c.req.json();
676
+ const { text, cursor, apiKey, model, provider, baseUrl, context } = body;
677
+
678
+ if (typeof text !== 'string') {
679
+ return c.json({ error: 'Text is required' }, 400);
680
+ }
681
+
682
+ const safeCursor = Math.max(0, Math.min(cursor ?? text.length, text.length));
683
+ const providerId = (provider || 'openai') as LLMProvider;
684
+
685
+ if (apiKey || providerId === 'ollama') {
686
+ const completion = await getLlmAutocomplete(
687
+ text,
688
+ safeCursor,
689
+ apiKey || '',
690
+ model,
691
+ providerId,
692
+ baseUrl,
693
+ context,
694
+ );
695
+ return c.json(completion);
696
+ }
697
+
698
+ return c.json(getHeuristicAutocomplete(text, safeCursor));
699
+ } catch (error) {
700
+ console.error('Autocomplete error:', error);
701
+ return c.json(
702
+ {
703
+ suggestion: '',
704
+ confidence: 0,
705
+ replaceStart: 0,
706
+ replaceEnd: 0,
707
+ source: 'heuristic',
708
+ error: error instanceof Error ? error.message : 'Unknown error',
709
+ },
710
+ 500,
711
+ );
712
+ }
713
+ });
714
+
715
+ // Tone rewriting endpoint
716
+ app.post('/rewrite', async (c) => {
717
+ try {
718
+ const { text, tone, apiKey, model, provider, baseUrl } = await c.req.json();
719
+
720
+ if (!text || !tone) {
721
+ return c.json({ error: 'Text and tone are required' }, 400);
722
+ }
723
+
724
+ const providerBaseUrl = baseUrl || getProviderBaseUrl(provider || 'openai');
725
+
726
+ const openai = new (await import('openai')).OpenAI({
727
+ apiKey: apiKey || 'ollama',
728
+ baseURL: providerBaseUrl,
729
+ });
730
+
731
+ const toneInstructions: Record<string, string> = {
732
+ formal: 'Make the text more formal and professional',
733
+ casual: 'Make the text more casual and conversational',
734
+ professional: 'Make the text more professional and business-appropriate',
735
+ friendly: 'Make the text friendlier and warmer',
736
+ concise: 'Make the text more concise and direct',
737
+ detailed: 'Make the text more detailed and elaborate',
738
+ persuasive: 'Make the text more persuasive and compelling',
739
+ neutral: 'Keep the text neutral and objective',
740
+ };
741
+
742
+ const completion = await openai.chat.completions.create({
743
+ messages: [
744
+ {
745
+ role: 'system',
746
+ content: `You are a writing assistant. ${toneInstructions[tone] || 'Improve the writing'}. Return ONLY the rewritten text, no explanations.`,
747
+ },
748
+ { role: 'user', content: text },
749
+ ],
750
+ model: model || 'gpt-3.5-turbo',
751
+ temperature: 0.7,
752
+ max_tokens: 1000,
753
+ });
754
+
755
+ const rewrittenText = completion.choices[0]?.message?.content || text;
756
+
757
+ return c.json({
758
+ original: text,
759
+ rewritten: rewrittenText,
760
+ tone,
761
+ });
762
+ } catch (error) {
763
+ console.error('Rewrite error:', error);
764
+ return c.json(
765
+ {
766
+ error: 'Failed to rewrite text',
767
+ message: error instanceof Error ? error.message : 'Unknown error',
768
+ },
769
+ 500,
770
+ );
771
+ }
772
+ });
773
+
774
+ // Error handler
775
+ app.onError((err, c) => {
776
+ console.error('Unhandled error:', err);
777
+ return c.json(
778
+ {
779
+ error: 'Internal server error',
780
+ message: err.message,
781
+ },
782
+ 500,
783
+ );
784
+ });
785
+
786
+ // 404 handler
787
+ app.notFound((c) => {
788
+ return c.json({ error: 'Not found' }, 404);
789
+ });
790
+
791
+ function getProviderBaseUrl(provider: string): string {
792
+ const urls: Record<string, string> = {
793
+ openai: 'https://api.openai.com/v1',
794
+ openrouter: 'https://openrouter.ai/api/v1',
795
+ groq: 'https://api.groq.com/openai/v1',
796
+ together: 'https://api.together.xyz/v1',
797
+ ollama: 'http://localhost:11434/v1',
798
+ custom: '',
799
+ };
800
+ return urls[provider as string] ?? urls.openai;
801
+ }
802
+
803
+ function enrichIssues(
804
+ issues: Issue[],
805
+ source: Issue['source'],
806
+ text: string,
807
+ context?: AnalysisContext,
808
+ ): Issue[] {
809
+ return issues.map((issue) => {
810
+ const confidence = getConfidence(issue, source, context);
811
+ const priority = getPriority(issue, confidence, text, context);
812
+ return {
813
+ ...issue,
814
+ source,
815
+ confidence,
816
+ priority,
817
+ id: issue.id || `${source}-${issue.type}-${issue.offset}-${issue.original}`,
818
+ };
819
+ });
820
+ }
821
+
822
+ function dedupeAndRankIssues(issues: Issue[], text: string, context?: AnalysisContext): Issue[] {
823
+ const deduped = new Map<string, Issue>();
824
+
825
+ for (const issue of issues) {
826
+ const key = `${issue.type}|${issue.offset}|${issue.original.toLowerCase()}|${issue.suggestion.toLowerCase()}`;
827
+ const existing = deduped.get(key);
828
+ if (!existing || (issue.priority || 0) > (existing.priority || 0)) {
829
+ deduped.set(key, issue);
830
+ }
831
+ }
832
+
833
+ return Array.from(deduped.values()).sort((a, b) => {
834
+ const priorityDiff = (b.priority || 0) - (a.priority || 0);
835
+ if (priorityDiff !== 0) return priorityDiff;
836
+ const confidenceDiff = (b.confidence || 0) - (a.confidence || 0);
837
+ if (confidenceDiff !== 0) return confidenceDiff;
838
+ return a.offset - b.offset;
839
+ });
840
+ }
841
+
842
+ function getConfidence(issue: Issue, source: Issue['source'], context?: AnalysisContext): number {
843
+ const baseByType: Record<Issue['type'], number> = {
844
+ spelling: 0.96,
845
+ grammar: 0.9,
846
+ clarity: 0.82,
847
+ style: 0.78,
848
+ };
849
+
850
+ let confidence = baseByType[issue.type];
851
+
852
+ if (source === 'llm') confidence -= 0.04;
853
+ if (/consider/i.test(issue.suggestion) || /consider/i.test(issue.reason)) confidence -= 0.08;
854
+ if (issue.original.length <= 2) confidence -= 0.05;
855
+ if (context?.activeSentence && context.activeSentence.includes(issue.original))
856
+ confidence += 0.03;
857
+
858
+ return Math.max(0.5, Math.min(0.99, Number(confidence.toFixed(2))));
859
+ }
860
+
861
+ function getPriority(
862
+ issue: Issue,
863
+ confidence: number,
864
+ text: string,
865
+ context?: AnalysisContext,
866
+ ): number {
867
+ const severityWeight: Record<Issue['type'], number> = {
868
+ spelling: 1,
869
+ grammar: 0.95,
870
+ clarity: 0.8,
871
+ style: 0.72,
872
+ };
873
+
874
+ let priority = confidence * 100 * severityWeight[issue.type];
875
+
876
+ const occurrenceCount = issue.original
877
+ ? text.toLowerCase().split(issue.original.toLowerCase()).length - 1
878
+ : 1;
879
+ if (occurrenceCount > 1) priority += Math.min(occurrenceCount * 2, 8);
880
+ if (
881
+ context?.fullTextExcerpt &&
882
+ context.fullTextExcerpt.toLowerCase().includes(issue.original.toLowerCase())
883
+ ) {
884
+ priority += 4;
885
+ }
886
+
887
+ return Number(priority.toFixed(2));
888
+ }
889
+
890
+ async function getLlmAutocomplete(
891
+ text: string,
892
+ cursor: number,
893
+ apiKey: string,
894
+ model: string | undefined,
895
+ provider: LLMProvider,
896
+ baseUrl?: string,
897
+ context?: AnalysisContext,
898
+ ) {
899
+ const openai = new OpenAI({
900
+ apiKey: apiKey || 'ollama',
901
+ baseURL: baseUrl || getProviderBaseUrl(provider),
902
+ });
903
+
904
+ const prefix = text.slice(0, cursor);
905
+ const suffix = text.slice(cursor);
906
+
907
+ const completion = await openai.chat.completions.create({
908
+ messages: [
909
+ {
910
+ role: 'system',
911
+ content:
912
+ 'You are a writing assistant. Predict the next short continuation for the user. Return ONLY JSON with keys suggestion and confidence. Keep suggestion under 12 words and do not repeat the existing text.',
913
+ },
914
+ {
915
+ role: 'user',
916
+ content: JSON.stringify({
917
+ prefix: prefix.slice(-400),
918
+ suffix: suffix.slice(0, 120),
919
+ context,
920
+ }),
921
+ },
922
+ ],
923
+ model: model || 'gpt-4o-mini',
924
+ response_format: { type: 'json_object' },
925
+ max_tokens: 80,
926
+ temperature: 0.5,
927
+ });
928
+
929
+ const content = completion.choices[0]?.message?.content || '{"suggestion":"","confidence":0.5}';
930
+ const parsed = JSON.parse(content.replace(/^```json\s*/, '').replace(/\s*```$/, ''));
931
+
932
+ return {
933
+ suggestion: typeof parsed.suggestion === 'string' ? parsed.suggestion.trim() : '',
934
+ confidence:
935
+ typeof parsed.confidence === 'number' ? Math.max(0, Math.min(1, parsed.confidence)) : 0.72,
936
+ replaceStart: cursor,
937
+ replaceEnd: cursor,
938
+ source: 'llm' as const,
939
+ };
940
+ }
941
+
942
+ function getHeuristicAutocomplete(text: string, cursor: number) {
943
+ const prefix = text.slice(0, cursor);
944
+ const trimmed = prefix.trimEnd();
945
+ const lower = trimmed.toLowerCase();
946
+
947
+ const patternSuggestions: Array<{ pattern: RegExp; suggestion: string }> = [
948
+ { pattern: /thank you for$/i, suggestion: ' your time.' },
949
+ { pattern: /i look forward to$/i, suggestion: ' hearing from you.' },
950
+ { pattern: /please let me know if$/i, suggestion: ' you have any questions.' },
951
+ { pattern: /in conclusion[,]?$/i, suggestion: ' this approach provides a stronger outcome.' },
952
+ { pattern: /for example[,]?$/i, suggestion: ' this can improve clarity and consistency.' },
953
+ { pattern: /i hope you are$/i, suggestion: ' doing well.' },
954
+ ];
955
+
956
+ for (const entry of patternSuggestions) {
957
+ if (entry.pattern.test(lower)) {
958
+ return {
959
+ suggestion: entry.suggestion,
960
+ confidence: 0.66,
961
+ replaceStart: cursor,
962
+ replaceEnd: cursor,
963
+ source: 'heuristic' as const,
964
+ };
965
+ }
966
+ }
967
+
968
+ const endsWithSentence = /[.!?]$/.test(trimmed);
969
+ return {
970
+ suggestion: endsWithSentence ? ' This helps keep the writing clear.' : '',
971
+ confidence: endsWithSentence ? 0.42 : 0,
972
+ replaceStart: cursor,
973
+ replaceEnd: cursor,
974
+ source: 'heuristic' as const,
975
+ };
976
+ }
977
+
978
+ export default app;