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.
- package/README.npm.md +95 -0
- package/bin/opengrammar-server.js +111 -0
- package/dist/server.js +48639 -0
- package/package.json +80 -0
- package/server-node.ts +159 -0
- package/server.ts +15 -0
- package/src/analyzer.ts +542 -0
- package/src/dictionary.ts +1973 -0
- package/src/index.ts +978 -0
- package/src/nlp/nlp-engine.ts +17 -0
- package/src/nlp/tone-analyzer.ts +269 -0
- package/src/rephraser.ts +146 -0
- package/src/rules/categories/academic-writing.ts +182 -0
- package/src/rules/categories/adjectives-adverbs.ts +152 -0
- package/src/rules/categories/articles.ts +160 -0
- package/src/rules/categories/business-writing.ts +250 -0
- package/src/rules/categories/capitalization.ts +79 -0
- package/src/rules/categories/clarity.ts +117 -0
- package/src/rules/categories/common-errors.ts +601 -0
- package/src/rules/categories/confused-words.ts +219 -0
- package/src/rules/categories/conjunctions.ts +176 -0
- package/src/rules/categories/dangling-modifiers.ts +123 -0
- package/src/rules/categories/formality.ts +274 -0
- package/src/rules/categories/formatting-idioms.ts +323 -0
- package/src/rules/categories/gerund-infinitive.ts +274 -0
- package/src/rules/categories/grammar-advanced.ts +294 -0
- package/src/rules/categories/grammar.ts +286 -0
- package/src/rules/categories/inclusive-language.ts +280 -0
- package/src/rules/categories/nouns-pronouns.ts +233 -0
- package/src/rules/categories/prepositions-extended.ts +217 -0
- package/src/rules/categories/prepositions.ts +159 -0
- package/src/rules/categories/punctuation.ts +347 -0
- package/src/rules/categories/quantity-agreement.ts +200 -0
- package/src/rules/categories/readability.ts +293 -0
- package/src/rules/categories/sentence-structure.ts +100 -0
- package/src/rules/categories/spelling-advanced.ts +164 -0
- package/src/rules/categories/spelling.ts +119 -0
- package/src/rules/categories/style-tone.ts +511 -0
- package/src/rules/categories/style.ts +78 -0
- package/src/rules/categories/subject-verb-agreement.ts +201 -0
- package/src/rules/categories/tone-rules.ts +206 -0
- package/src/rules/categories/verb-tense.ts +582 -0
- package/src/rules/context-filter.ts +446 -0
- package/src/rules/index.ts +96 -0
- package/src/rules/ruleset-part1-cj-pu-sp.json +657 -0
- package/src/rules/ruleset-part1-np-ad-aa-pr.json +831 -0
- package/src/rules/ruleset-part1-ss-vt.json +907 -0
- package/src/rules/ruleset-part2-cw-st-nf.json +318 -0
- package/src/rules/ruleset-part3-aw-bw-il-rd.json +161 -0
- package/src/rules/types.ts +79 -0
- package/src/shared-types.ts +152 -0
- package/src/spellchecker.ts +418 -0
- 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;
|