17189d8
commit 17189d8
13 files changed
+404 -550
Showing 13 changed files+404-550
opengrammar/backend/package-lock.jsonopengrammar/backend/server-node.tsopengrammar/backend/src/analyzer.tsopengrammar/backend/src/rules/categories/grammar-advanced.tsopengrammar/backend/src/rules/categories/readability.tsopengrammar/backend/src/rules/categories/style.tsopengrammar/backend/src/shared-types.tsopengrammar/backend/src/spellchecker.tsopengrammar/extension/src/background/index.tsopengrammar/extension/src/content/index.tsopengrammar/extension/src/types.tsopengrammar/shared/package.jsonopengrammar/shared/src/types.ts
▼opengrammar/backend/package-lock.json
+33-5
| @@ -1,13 +1,13 @@ | ||
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,8 +17,13 @@ | ||
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,6 +1126,29 @@ | ||
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,151 +1,8 @@ | ||
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,9 +103,6 @@ const ADJECTIVE_PARTICIPLES = new Set([ | ||
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,20 +114,15 @@ export class RuleBasedAnalyzer { | ||
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,7 +156,7 @@ export class RuleBasedAnalyzer { | ||
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,9 +245,9 @@ export class RuleBasedAnalyzer { | ||
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,21 +231,22 @@ export const advancedGrammarRules: Rule[] = [ | ||
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,22 +230,25 @@ export const readabilityRules: Rule[] = [ | ||
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,31 +264,35 @@ export const readabilityRules: Rule[] = [ | ||
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,7 +30,8 @@ export const styleRules: Rule[] = [ | ||
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,7 +60,8 @@ export const styleRules: Rule[] = [ | ||
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,152 +1 @@ | ||
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,6 +1,10 @@ | ||
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,7 +13,6 @@ function levenshtein(a: string, b: string): number { | ||
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,32 +20,30 @@ function levenshtein(a: string, b: string): number { | ||
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,6 +297,24 @@ function getSoundexCache(dictionary: Set<string>): Map<string, string> { | ||
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,24 +328,28 @@ function findSuggestions( | ||
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,7 +447,11 @@ async function getProviders() { | ||
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,7 +468,11 @@ async function getModels(provider: string, apiKey?: string, baseUrl?: string) { | ||
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,11 +396,38 @@ function handleKeyDown(event: Event) { | ||
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,7 +455,25 @@ function handleGrammarSuccess(element: HTMLElement, text: string, issues: Issue[ | ||
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,7 +489,13 @@ const checkGrammar = async (element: HTMLElement) => { | ||
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,146 +1 @@ | ||
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,15 +2,15 @@ | ||
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,3 +1,77 @@ | ||
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,16 +79,28 @@ export interface Issue { | ||
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,10 +111,26 @@ 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,3 +139,82 @@ 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 | |