feat(core): Update opengrammar/extension/src/background/index.ts (Commit #9)
Swadhin Biswas committed 7 months ago
164aa5c
1 parent 6b0eb30
commit 164aa5c
1 file changed
+383 -18
Showing 1 changed file+383-18
▼opengrammar/extension/src/background/index.ts
+383-18
| @@ -1,10 +1,42 @@ | ||
1 | -import type { AnalyzeRequest, AnalyzeResponse, RewriteRequest, RewriteResponse, LLMProvider } from '../types'; | |
1 | +import type { | |
2 | + AnalyzeRequest, | |
3 | + AnalyzeResponse, | |
4 | + AnalyticsEventType, | |
5 | + AnalyticsSummary, | |
6 | + AutocompleteRequest, | |
7 | + AutocompleteResponse, | |
8 | + AnalysisContext, | |
9 | + EditorContext, | |
10 | + LLMProvider, | |
11 | + RewriteContext, | |
12 | + RewriteRequest, | |
13 | + RewriteResponse, | |
14 | +} from '../types'; | |
2 | 15 | |
3 | 16 | // Default backend URL |
4 | 17 | const DEFAULT_BACKEND_URL = 'http://localhost:8787'; |
18 | +const REWRITE_PAGE_PATH = 'src/rewrite/index.html'; | |
19 | +const STATS_PAGE_PATH = 'src/stats/index.html'; | |
20 | +const DEFAULT_PROVIDER = 'openai'; | |
21 | +const DEFAULT_MODEL = 'gpt-4o-mini'; | |
22 | +const DEFAULT_ANALYTICS: AnalyticsSummary = { | |
23 | + totals: { | |
24 | + analysis_runs: 0, | |
25 | + issues_found: 0, | |
26 | + suggestions_applied: 0, | |
27 | + suggestions_ignored: 0, | |
28 | + autocomplete_shown: 0, | |
29 | + autocomplete_accepted: 0, | |
30 | + rewrite_opened: 0, | |
31 | + rewrite_applied: 0, | |
32 | + }, | |
33 | + domains: {}, | |
34 | + providers: {}, | |
35 | +}; | |
5 | 36 | |
6 | 37 | // Initialize context menus |
7 | 38 | chrome.runtime.onInstalled.addListener(() => { |
39 | + void initializeStoredDefaults(); | |
8 | 40 | chrome.contextMenus.create({ |
9 | 41 | id: 'opengrammar-rewrite', |
10 | 42 | title: 'Rewrite with OpenGrammar', |
| @@ -12,9 +44,11 @@ chrome.runtime.onInstalled.addListener(() => { | ||
12 | 44 | }); |
13 | 45 | }); |
14 | 46 | |
47 | +void initializeStoredDefaults(); | |
48 | + | |
15 | 49 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { |
16 | 50 | if (request.type === 'CHECK_GRAMMAR') { |
17 | - handleGrammarCheck(request.text, sendResponse); | |
51 | + handleGrammarCheck(request.text, request.context, sendResponse); | |
18 | 52 | return true; |
19 | 53 | } |
20 | 54 | |
| @@ -37,6 +71,74 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { | ||
37 | 71 | }); |
38 | 72 | return true; |
39 | 73 | } |
74 | + | |
75 | + if (request.type === 'SYNC_ACTIVE_CONTEXT') { | |
76 | + const tabId = sender.tab?.id; | |
77 | + if (!tabId || typeof request.text !== 'string') { | |
78 | + sendResponse({ success: false }); | |
79 | + return false; | |
80 | + } | |
81 | + | |
82 | + void storeActiveContext(tabId, request.text, request.issues || []).then(() => { | |
83 | + sendResponse({ success: true }); | |
84 | + }); | |
85 | + return true; | |
86 | + } | |
87 | + | |
88 | + if (request.type === 'GET_ACTIVE_TEXT') { | |
89 | + void getActiveEditorContext().then((context) => { | |
90 | + sendResponse(context || { text: '', issues: [] }); | |
91 | + }); | |
92 | + return true; | |
93 | + } | |
94 | + | |
95 | + if (request.type === 'AUTOCOMPLETE_TEXT') { | |
96 | + void handleAutocomplete(request, sendResponse); | |
97 | + return true; | |
98 | + } | |
99 | + | |
100 | + if (request.type === 'OPEN_REWRITE_PAGE') { | |
101 | + void openRewritePage().then(() => sendResponse({ success: true })); | |
102 | + return true; | |
103 | + } | |
104 | + | |
105 | + if (request.type === 'GET_REWRITE_CONTEXT') { | |
106 | + void getRewriteContext().then((context) => { | |
107 | + sendResponse(context || { selectedText: '' }); | |
108 | + }); | |
109 | + return true; | |
110 | + } | |
111 | + | |
112 | + if (request.type === 'APPLY_REWRITE_TO_SOURCE') { | |
113 | + void applyRewriteToSource(request.original, request.rewritten).then((result) => { | |
114 | + sendResponse(result); | |
115 | + }); | |
116 | + return true; | |
117 | + } | |
118 | + | |
119 | + if (request.type === 'OPEN_STATS_PAGE') { | |
120 | + void openStatsPage().then(() => sendResponse({ success: true })); | |
121 | + return true; | |
122 | + } | |
123 | + | |
124 | + if (request.type === 'TRACK_ANALYTICS_EVENT') { | |
125 | + void trackAnalyticsEvent(request.eventType, request.payload).then(() => { | |
126 | + sendResponse({ success: true }); | |
127 | + }); | |
128 | + return true; | |
129 | + } | |
130 | + | |
131 | + if (request.type === 'GET_ANALYTICS_SUMMARY') { | |
132 | + void getAnalyticsSummary().then((summary) => sendResponse(summary)); | |
133 | + return true; | |
134 | + } | |
135 | + | |
136 | + if (request.type === 'CLEAR_ANALYTICS') { | |
137 | + void chrome.storage.sync.set({ analyticsSummary: DEFAULT_ANALYTICS }).then(() => { | |
138 | + sendResponse({ success: true }); | |
139 | + }); | |
140 | + return true; | |
141 | + } | |
40 | 142 | |
41 | 143 | if (request.type === 'GET_PROVIDERS') { |
42 | 144 | getProviders().then(providers => sendResponse({ providers })); |
| @@ -64,29 +166,29 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { | ||
64 | 166 | // Handle context menu clicks |
65 | 167 | chrome.contextMenus.onClicked.addListener((info, tab) => { |
66 | 168 | if (info.menuItemId === 'opengrammar-rewrite' && info.selectionText) { |
67 | - // Open rewrite popup with selected text | |
68 | - if (tab?.id) { | |
69 | - chrome.tabs.sendMessage(tab.id, { | |
70 | - type: 'OPEN_REWRITE', | |
71 | - text: info.selectionText, | |
72 | - }); | |
73 | - } | |
169 | + void storeRewriteContext(tab?.id, info.selectionText).then(() => { | |
170 | + chrome.tabs.create({ url: chrome.runtime.getURL(REWRITE_PAGE_PATH) }); | |
171 | + }); | |
74 | 172 | } |
75 | 173 | }); |
76 | 174 | |
77 | 175 | // Handle keyboard shortcuts |
78 | 176 | chrome.commands.onCommand.addListener((command) => { |
79 | 177 | if (command === 'rewrite-text') { |
80 | - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { | |
81 | - const tab = tabs[0]; | |
82 | - if (tab?.id) { | |
83 | - chrome.tabs.sendMessage(tab.id, { type: 'OPEN_REWRITE' }); | |
84 | - } | |
85 | - }); | |
178 | + void openRewritePage(); | |
179 | + return; | |
180 | + } | |
181 | + | |
182 | + if (command === 'open-stats') { | |
183 | + void openStatsPage(); | |
86 | 184 | } |
87 | 185 | }); |
88 | 186 | |
89 | -async function handleGrammarCheck(text: string, sendResponse: (response: any) => void) { | |
187 | +async function handleGrammarCheck( | |
188 | + text: string, | |
189 | + context: AnalysisContext | undefined, | |
190 | + sendResponse: (response: any) => void, | |
191 | +) { | |
90 | 192 | try { |
91 | 193 | const { |
92 | 194 | apiKey, |
| @@ -125,6 +227,8 @@ async function handleGrammarCheck(text: string, sendResponse: (response: any) => | ||
125 | 227 | provider: provider as LLMProvider, |
126 | 228 | baseUrl: customBaseUrl, |
127 | 229 | ignoredIssues: ignoredIssues || [], |
230 | + dictionary: dictionary || [], | |
231 | + context, | |
128 | 232 | }; |
129 | 233 | |
130 | 234 | const response = await fetch(`${baseUrl}/analyze`, { |
| @@ -143,10 +247,12 @@ async function handleGrammarCheck(text: string, sendResponse: (response: any) => | ||
143 | 247 | const data: AnalyzeResponse = await response.json(); |
144 | 248 | |
145 | 249 | // Filter out ignored issues |
146 | - if (data.issues && ignoredIssues) { | |
250 | + const normalizedIgnored = normalizeIgnoredIssues(ignoredIssues); | |
251 | + | |
252 | + if (data.issues && normalizedIgnored.length > 0) { | |
147 | 253 | data.issues = data.issues.filter(issue => { |
148 | 254 | const issueId = issue.id || `${issue.type}-${issue.offset}-${issue.original}`; |
149 | - return !ignoredIssues.includes(issueId); | |
255 | + return !normalizedIgnored.includes(issueId); | |
150 | 256 | }); |
151 | 257 | } |
152 | 258 | |
| @@ -158,6 +264,20 @@ async function handleGrammarCheck(text: string, sendResponse: (response: any) => | ||
158 | 264 | return !dictionary.includes(word); |
159 | 265 | }); |
160 | 266 | } |
267 | + | |
268 | + await trackAnalyticsEvent('analysis_runs', { | |
269 | + count: 1, | |
270 | + domain: context?.domain, | |
271 | + provider: provider || 'rule-only', | |
272 | + }); | |
273 | + | |
274 | + if (data.issues.length > 0) { | |
275 | + await trackAnalyticsEvent('issues_found', { | |
276 | + count: data.issues.length, | |
277 | + domain: context?.domain, | |
278 | + provider: provider || 'rule-only', | |
279 | + }); | |
280 | + } | |
161 | 281 | |
162 | 282 | sendResponse(data); |
163 | 283 | } catch (error) { |
| @@ -170,6 +290,72 @@ async function handleGrammarCheck(text: string, sendResponse: (response: any) => | ||
170 | 290 | } |
171 | 291 | } |
172 | 292 | |
293 | +async function handleAutocomplete( | |
294 | + request: AutocompleteRequest & { type: string }, | |
295 | + sendResponse: (response: AutocompleteResponse) => void, | |
296 | +) { | |
297 | + try { | |
298 | + const { | |
299 | + apiKey, | |
300 | + model, | |
301 | + backendUrl, | |
302 | + provider, | |
303 | + customBaseUrl, | |
304 | + autocompleteEnabled, | |
305 | + } = await chrome.storage.sync.get([ | |
306 | + 'apiKey', | |
307 | + 'model', | |
308 | + 'backendUrl', | |
309 | + 'provider', | |
310 | + 'customBaseUrl', | |
311 | + 'autocompleteEnabled', | |
312 | + ]); | |
313 | + | |
314 | + if (autocompleteEnabled === false) { | |
315 | + sendResponse({ suggestion: '', confidence: 0, replaceStart: request.cursor, replaceEnd: request.cursor, source: 'heuristic' }); | |
316 | + return; | |
317 | + } | |
318 | + | |
319 | + const baseUrl = backendUrl || DEFAULT_BACKEND_URL; | |
320 | + const requestBody: AutocompleteRequest = { | |
321 | + text: request.text, | |
322 | + cursor: request.cursor, | |
323 | + apiKey, | |
324 | + model, | |
325 | + provider: (provider || 'openai') as LLMProvider, | |
326 | + baseUrl: customBaseUrl, | |
327 | + context: request.context, | |
328 | + }; | |
329 | + | |
330 | + const response = await fetch(`${baseUrl}/autocomplete`, { | |
331 | + method: 'POST', | |
332 | + headers: { 'Content-Type': 'application/json' }, | |
333 | + body: JSON.stringify(requestBody), | |
334 | + }); | |
335 | + | |
336 | + const data: AutocompleteResponse = await response.json(); | |
337 | + | |
338 | + if (data.suggestion) { | |
339 | + await trackAnalyticsEvent('autocomplete_shown', { | |
340 | + count: 1, | |
341 | + domain: request.context?.domain, | |
342 | + provider: provider || 'heuristic', | |
343 | + }); | |
344 | + } | |
345 | + | |
346 | + sendResponse(data); | |
347 | + } catch (error) { | |
348 | + sendResponse({ | |
349 | + suggestion: '', | |
350 | + confidence: 0, | |
351 | + replaceStart: request.cursor, | |
352 | + replaceEnd: request.cursor, | |
353 | + source: 'heuristic', | |
354 | + error: error instanceof Error ? error.message : 'Autocomplete failed', | |
355 | + }); | |
356 | + } | |
357 | +} | |
358 | + | |
173 | 359 | async function handleRewrite( |
174 | 360 | text: string, |
175 | 361 | tone: string, |
| @@ -260,6 +446,185 @@ async function getBackendUrl(): Promise<string> { | ||
260 | 446 | return result.backendUrl || DEFAULT_BACKEND_URL; |
261 | 447 | } |
262 | 448 | |
449 | +async function initializeStoredDefaults(): Promise<void> { | |
450 | + const existing = await chrome.storage.sync.get([ | |
451 | + 'enabled', | |
452 | + 'provider', | |
453 | + 'model', | |
454 | + 'backendUrl', | |
455 | + 'backendAutoDetected', | |
456 | + ]); | |
457 | + | |
458 | + await chrome.storage.sync.set({ | |
459 | + enabled: existing.enabled !== false, | |
460 | + provider: existing.provider || DEFAULT_PROVIDER, | |
461 | + model: existing.model || DEFAULT_MODEL, | |
462 | + backendUrl: existing.backendUrl || DEFAULT_BACKEND_URL, | |
463 | + backendAutoDetected: existing.backendAutoDetected !== false, | |
464 | + }); | |
465 | +} | |
466 | + | |
467 | +function normalizeIgnoredIssues(value: unknown): string[] { | |
468 | + if (!Array.isArray(value)) { | |
469 | + return []; | |
470 | + } | |
471 | + | |
472 | + return value | |
473 | + .map((entry) => { | |
474 | + if (typeof entry === 'string') { | |
475 | + return entry; | |
476 | + } | |
477 | + | |
478 | + if (entry && typeof entry === 'object' && 'id' in entry && typeof entry.id === 'string') { | |
479 | + return entry.id; | |
480 | + } | |
481 | + | |
482 | + return null; | |
483 | + }) | |
484 | + .filter((entry): entry is string => Boolean(entry)); | |
485 | +} | |
486 | + | |
487 | +async function getActiveTab(): Promise<chrome.tabs.Tab | undefined> { | |
488 | + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); | |
489 | + return tabs[0]; | |
490 | +} | |
491 | + | |
492 | +async function requestSelectedText(tabId: number): Promise<string> { | |
493 | + try { | |
494 | + const response = await chrome.tabs.sendMessage(tabId, { type: 'GET_SELECTED_TEXT' }); | |
495 | + return response?.text || ''; | |
496 | + } catch { | |
497 | + return ''; | |
498 | + } | |
499 | +} | |
500 | + | |
501 | +async function storeRewriteContext(sourceTabId?: number, selectedText: string = ''): Promise<void> { | |
502 | + const payload: RewriteContext = { | |
503 | + selectedText, | |
504 | + sourceTabId, | |
505 | + capturedAt: Date.now(), | |
506 | + }; | |
507 | + | |
508 | + await chrome.storage.local.set({ rewriteContext: payload }); | |
509 | +} | |
510 | + | |
511 | +async function getRewriteContext(): Promise<RewriteContext | null> { | |
512 | + const result = await chrome.storage.local.get('rewriteContext'); | |
513 | + return (result.rewriteContext as RewriteContext | undefined) || null; | |
514 | +} | |
515 | + | |
516 | +async function openRewritePage(): Promise<void> { | |
517 | + const activeTab = await getActiveTab(); | |
518 | + const selectedText = activeTab?.id ? await requestSelectedText(activeTab.id) : ''; | |
519 | + await storeRewriteContext(activeTab?.id, selectedText); | |
520 | + await trackAnalyticsEvent('rewrite_opened', { | |
521 | + count: 1, | |
522 | + domain: getDomainFromUrl(activeTab?.url), | |
523 | + }); | |
524 | + await chrome.tabs.create({ url: chrome.runtime.getURL(REWRITE_PAGE_PATH) }); | |
525 | +} | |
526 | + | |
527 | +async function openStatsPage(): Promise<void> { | |
528 | + await chrome.tabs.create({ url: chrome.runtime.getURL(STATS_PAGE_PATH) }); | |
529 | +} | |
530 | + | |
531 | +async function applyRewriteToSource(original: string, rewritten: string): Promise<{ success: boolean; error?: string }> { | |
532 | + const context = await getRewriteContext(); | |
533 | + if (!context?.sourceTabId) { | |
534 | + return { success: false, error: 'No source tab available for rewrite.' }; | |
535 | + } | |
536 | + | |
537 | + try { | |
538 | + const response = await chrome.tabs.sendMessage(context.sourceTabId, { | |
539 | + type: 'APPLY_REWRITE', | |
540 | + original, | |
541 | + rewritten, | |
542 | + }); | |
543 | + | |
544 | + return response?.success | |
545 | + ? await trackRewriteApplyAndReturn(context.sourceTabId) | |
546 | + : { success: false, error: response?.error || 'Failed to apply rewrite.' }; | |
547 | + } catch (error) { | |
548 | + return { | |
549 | + success: false, | |
550 | + error: error instanceof Error ? error.message : 'Failed to contact source tab.', | |
551 | + }; | |
552 | + } | |
553 | +} | |
554 | + | |
555 | +async function trackRewriteApplyAndReturn(sourceTabId?: number) { | |
556 | + let domain: string | undefined; | |
557 | + if (sourceTabId) { | |
558 | + const tab = await chrome.tabs.get(sourceTabId).catch(() => undefined); | |
559 | + domain = getDomainFromUrl(tab?.url); | |
560 | + } | |
561 | + await trackAnalyticsEvent('rewrite_applied', { count: 1, domain }); | |
562 | + return { success: true }; | |
563 | +} | |
564 | + | |
565 | +async function storeActiveContext(sourceTabId: number, text: string, issues: AnalyzeResponse['issues'] = []): Promise<void> { | |
566 | + const payload: EditorContext = { | |
567 | + text, | |
568 | + issues, | |
569 | + sourceTabId, | |
570 | + capturedAt: Date.now(), | |
571 | + }; | |
572 | + | |
573 | + await chrome.storage.local.set({ lastEditorContext: payload }); | |
574 | +} | |
575 | + | |
576 | +async function getActiveEditorContext(): Promise<EditorContext | null> { | |
577 | + const result = await chrome.storage.local.get('lastEditorContext'); | |
578 | + return (result.lastEditorContext as EditorContext | undefined) || null; | |
579 | +} | |
580 | + | |
581 | +async function getAnalyticsSummary(): Promise<AnalyticsSummary> { | |
582 | + const result = await chrome.storage.sync.get('analyticsSummary'); | |
583 | + return { | |
584 | + ...DEFAULT_ANALYTICS, | |
585 | + ...(result.analyticsSummary as AnalyticsSummary | undefined), | |
586 | + totals: { | |
587 | + ...DEFAULT_ANALYTICS.totals, | |
588 | + ...((result.analyticsSummary as AnalyticsSummary | undefined)?.totals || {}), | |
589 | + }, | |
590 | + domains: { | |
591 | + ...((result.analyticsSummary as AnalyticsSummary | undefined)?.domains || {}), | |
592 | + }, | |
593 | + providers: { | |
594 | + ...((result.analyticsSummary as AnalyticsSummary | undefined)?.providers || {}), | |
595 | + }, | |
596 | + }; | |
597 | +} | |
598 | + | |
599 | +async function trackAnalyticsEvent( | |
600 | + eventType: AnalyticsEventType, | |
601 | + payload?: { count?: number; domain?: string; provider?: string }, | |
602 | +): Promise<void> { | |
603 | + const summary = await getAnalyticsSummary(); | |
604 | + const count = payload?.count || 1; | |
605 | + summary.totals[eventType] = (summary.totals[eventType] || 0) + count; | |
606 | + | |
607 | + if (payload?.domain) { | |
608 | + summary.domains[payload.domain] = (summary.domains[payload.domain] || 0) + count; | |
609 | + } | |
610 | + | |
611 | + if (payload?.provider) { | |
612 | + summary.providers[payload.provider] = (summary.providers[payload.provider] || 0) + count; | |
613 | + } | |
614 | + | |
615 | + summary.lastUpdatedAt = Date.now(); | |
616 | + await chrome.storage.sync.set({ analyticsSummary: summary }); | |
617 | +} | |
618 | + | |
619 | +function getDomainFromUrl(url?: string): string | undefined { | |
620 | + if (!url) return undefined; | |
621 | + try { | |
622 | + return new URL(url).hostname; | |
623 | + } catch { | |
624 | + return undefined; | |
625 | + } | |
626 | +} | |
627 | + | |
263 | 628 | // Health check on extension load |
264 | 629 | async function checkBackendHealth() { |
265 | 630 | try { |
266 | 631 | |