d6233bb
commit d6233bb
4 files changed
+319 -463
Showing 4 changed files+319-463
▼opengrammar/backend/src/nlp/tone-analyzer.ts
+84-83
| @@ -105,16 +105,7 @@ function countMatches(text: string, patterns: RegExp[]): number { | ||
105 | 105 | return patterns.filter((p) => p.test(text)).length; |
106 | 106 | } |
107 | 107 | |
108 | -/** | |
109 | - * Analyze the tone of text based on rule signals. | |
110 | - * Returns a ToneResult with detected signals and recommendations. | |
111 | - */ | |
112 | -export function analyzeTone(text: string, context?: WritingContext): ToneResult { | |
113 | - const lowerText = text.toLowerCase(); | |
114 | - const signals: ToneSignal[] = []; | |
115 | - const tips: string[] = []; | |
116 | - | |
117 | - // ─── Count hedging ─── | |
108 | +function checkHedging(lowerText: string, signals: ToneSignal[], tips: string[]): number { | |
118 | 109 | const hedgeCount = HEDGING_WORDS.filter((w) => lowerText.includes(w)).length; |
119 | 110 | if (hedgeCount >= 2) { |
120 | 111 | const firstHedge = HEDGING_WORDS.find((w) => lowerText.includes(w)) || ''; |
| @@ -126,59 +117,90 @@ export function analyzeTone(text: string, context?: WritingContext): ToneResult | ||
126 | 117 | suggestion: `Replace hedging language with direct statements. Instead of "${firstHedge}", use assertive phrasing.`, |
127 | 118 | severity: hedgeCount >= 4 ? 'warning' : 'info', |
128 | 119 | }); |
129 | - tips.push( | |
130 | - `Your text contains ${hedgeCount} hedging expressions. Try replacing "I think/maybe/perhaps" with direct statements for a more confident tone.`, | |
131 | - ); | |
120 | + tips.push(`Your text contains ${hedgeCount} hedging expressions. Try replacing "I think/maybe/perhaps" with direct statements for a more confident tone.`); | |
132 | 121 | } |
122 | + return hedgeCount; | |
123 | +} | |
133 | 124 | |
134 | - // ─── Passive aggression ─── | |
135 | - for (const pattern of PASSIVE_AGGRESSION_PATTERNS) { | |
136 | - const found = findInText(text, pattern); | |
137 | - if (found) { | |
138 | - signals.push({ | |
139 | - type: 'passive_aggression', | |
140 | - phrase: found.phrase, | |
141 | - offset: found.offset, | |
142 | - suggestion: `"${found.phrase}" can sound passive-aggressive. Consider a more direct alternative.`, | |
143 | - severity: 'warning', | |
144 | - }); | |
145 | - tips.push('Avoid phrases that may sound passive-aggressive (e.g., "as I mentioned", "per my last email"). Be direct instead.'); | |
146 | - break; // one tip per category | |
125 | +function checkPatterns( | |
126 | + text: string, | |
127 | + patterns: RegExp[], | |
128 | + type: ToneSignal['type'], | |
129 | + severity: ToneSignal['severity'], | |
130 | + suggestionFn: (phrase: string) => string, | |
131 | + tipMsg?: string | |
132 | +): number { | |
133 | + let count = 0; | |
134 | + let firstFound: { phrase: string; offset: number } | null = null; | |
135 | + for (const pattern of patterns) { | |
136 | + if (pattern.test(text)) { | |
137 | + count++; | |
138 | + if (!firstFound) { | |
139 | + firstFound = findInText(text, pattern); | |
140 | + } | |
147 | 141 | } |
148 | 142 | } |
143 | + return count; | |
144 | +} | |
149 | 145 | |
150 | - // ─── Apology fillers ─── | |
151 | - const apologyCount = countMatches(text, APOLOGY_FILLER_PATTERNS); | |
152 | - if (apologyCount >= 1) { | |
153 | - const found = APOLOGY_FILLER_PATTERNS.map((p) => findInText(text, p)).find(Boolean); | |
146 | +function processSignals( | |
147 | + text: string, | |
148 | + patterns: RegExp[], | |
149 | + type: ToneSignal['type'], | |
150 | + severity: ToneSignal['severity'], | |
151 | + suggestionFn: (phrase: string) => string, | |
152 | + signals: ToneSignal[], | |
153 | + tips: string[], | |
154 | + tipMsg?: string | |
155 | +): number { | |
156 | + const count = countMatches(text, patterns); | |
157 | + if (count >= 1) { | |
158 | + const found = patterns.map((p) => findInText(text, p)).find(Boolean); | |
154 | 159 | if (found) { |
155 | 160 | signals.push({ |
156 | - type: 'apology_filler', | |
161 | + type, | |
157 | 162 | phrase: found.phrase, |
158 | 163 | offset: found.offset, |
159 | - suggestion: `Remove "${found.phrase}" — it weakens your message unnecessarily.`, | |
160 | - severity: 'info', | |
164 | + suggestion: suggestionFn(found.phrase), | |
165 | + severity, | |
161 | 166 | }); |
162 | - tips.push('Apology fillers like "just wanted to", "sorry to bother", or "if that\'s okay" undermine your confidence. Remove them for a stronger message.'); | |
167 | + if (tipMsg) tips.push(tipMsg); | |
163 | 168 | } |
164 | 169 | } |
170 | + return count; | |
171 | +} | |
165 | 172 | |
166 | - // ─── Confidence killers ─── | |
167 | - const confKillCount = countMatches(text, CONFIDENCE_KILLER_PATTERNS); | |
168 | - if (confKillCount >= 1) { | |
169 | - const found = CONFIDENCE_KILLER_PATTERNS.map((p) => findInText(text, p)).find(Boolean); | |
170 | - if (found) { | |
171 | - signals.push({ | |
172 | - type: 'confidence_killer', | |
173 | - phrase: found.phrase, | |
174 | - offset: found.offset, | |
175 | - suggestion: `"${found.phrase}" undermines your authority. State your point directly.`, | |
176 | - severity: 'info', | |
177 | - }); | |
178 | - } | |
179 | - } | |
173 | +/** | |
174 | + * Analyze the tone of text based on rule signals. | |
175 | + * Returns a ToneResult with detected signals and recommendations. | |
176 | + */ | |
177 | +export function analyzeTone(text: string, context?: WritingContext): ToneResult { | |
178 | + const lowerText = text.toLowerCase(); | |
179 | + const signals: ToneSignal[] = []; | |
180 | + const tips: string[] = []; | |
181 | + | |
182 | + const hedgeCount = checkHedging(lowerText, signals, tips); | |
183 | + | |
184 | + processSignals( | |
185 | + text, PASSIVE_AGGRESSION_PATTERNS, 'passive_aggression', 'warning', | |
186 | + (phrase) => `"${phrase}" can sound passive-aggressive. Consider a more direct alternative.`, | |
187 | + signals, tips, | |
188 | + 'Avoid phrases that may sound passive-aggressive (e.g., "as I mentioned", "per my last email"). Be direct instead.' | |
189 | + ); | |
190 | + | |
191 | + processSignals( | |
192 | + text, APOLOGY_FILLER_PATTERNS, 'apology_filler', 'info', | |
193 | + (phrase) => `Remove "${phrase}" — it weakens your message unnecessarily.`, | |
194 | + signals, tips, | |
195 | + 'Apology fillers like "just wanted to" or "if that\'s okay" undermine your confidence. Remove them for a stronger message.' | |
196 | + ); | |
197 | + | |
198 | + processSignals( | |
199 | + text, CONFIDENCE_KILLER_PATTERNS, 'confidence_killer', 'info', | |
200 | + (phrase) => `"${phrase}" undermines your authority. State your point directly.`, | |
201 | + signals, tips | |
202 | + ); | |
180 | 203 | |
181 | - // ─── Negativity overload ─── | |
182 | 204 | const negatives = (text.match(/\b(can'?t|won'?t|never|not|don'?t|doesn'?t|didn'?t|couldn'?t|wouldn'?t|shouldn'?t|haven'?t|hasn'?t|hadn'?t|no\s+one|nobody|nothing|nowhere|neither|nor)\b/gi) || []).length; |
183 | 205 | if (negatives >= 4) { |
184 | 206 | signals.push({ |
| @@ -188,47 +210,26 @@ export function analyzeTone(text: string, context?: WritingContext): ToneResult | ||
188 | 210 | suggestion: 'Consider reframing some negative statements as positive alternatives.', |
189 | 211 | severity: negatives >= 7 ? 'warning' : 'info', |
190 | 212 | }); |
191 | - tips.push(`Your text has ${negatives} negative expressions. Reframing some as positive ("...will work if..." instead of "...won't work unless...") creates a more constructive tone.`); | |
213 | + tips.push(`Your text has ${negatives} negative expressions. Reframing some as positive creates a more constructive tone.`); | |
192 | 214 | } |
193 | 215 | |
194 | - // ─── Formality mismatch (only flag in email/document context) ─── | |
195 | 216 | if (context === 'email' || context === 'document' || !context) { |
196 | - const informalCount = countMatches(text, INFORMAL_PATTERNS); | |
197 | - if (informalCount >= 1) { | |
198 | - const found = INFORMAL_PATTERNS.map((p) => findInText(text, p)).find(Boolean); | |
199 | - if (found) { | |
200 | - signals.push({ | |
201 | - type: 'formality_mismatch', | |
202 | - phrase: found.phrase, | |
203 | - offset: found.offset, | |
204 | - suggestion: `"${found.phrase}" is too informal for ${context === 'email' ? 'email' : 'formal writing'}. Use more formal language.`, | |
205 | - severity: 'warning', | |
206 | - }); | |
207 | - tips.push('Informal words or abbreviations detected. Consider using formal language in this context.'); | |
208 | - } | |
209 | - } | |
217 | + processSignals( | |
218 | + text, INFORMAL_PATTERNS, 'formality_mismatch', 'warning', | |
219 | + (phrase) => `"${phrase}" is too informal for formal writing. Use more formal language.`, | |
220 | + signals, tips, | |
221 | + 'Informal words or abbreviations detected. Consider using formal language in this context.' | |
222 | + ); | |
210 | 223 | } |
211 | 224 | |
212 | - // ─── Aggressive tone ─── | |
213 | - const aggressiveCount = countMatches(text, AGGRESSIVE_PATTERNS); | |
214 | - if (aggressiveCount >= 1) { | |
215 | - const found = AGGRESSIVE_PATTERNS.map((p) => findInText(text, p)).find(Boolean); | |
216 | - if (found) { | |
217 | - signals.push({ | |
218 | - type: 'aggressive', | |
219 | - phrase: found.phrase, | |
220 | - offset: found.offset, | |
221 | - suggestion: `"${found.phrase}" sounds aggressive. Try a more constructive phrasing.`, | |
222 | - severity: 'error', | |
223 | - }); | |
224 | - tips.push('Your text may sound aggressive. Focus on the issue, not the person, and use collaborative language.'); | |
225 | - } | |
226 | - } | |
225 | + const aggressiveCount = processSignals( | |
226 | + text, AGGRESSIVE_PATTERNS, 'aggressive', 'error', | |
227 | + (phrase) => `"${phrase}" sounds aggressive. Try a more constructive phrasing.`, | |
228 | + signals, tips, | |
229 | + 'Your text may sound aggressive. Focus on the issue, not the person, and use collaborative language.' | |
230 | + ); | |
227 | 231 | |
228 | - // ─── Determine dominant tone ─── | |
229 | 232 | const dominant = determineDominantTone(signals, hedgeCount, negatives, aggressiveCount); |
230 | - | |
231 | - // ─── Compute clarity score ─── | |
232 | 233 | const score = computeScore(signals, hedgeCount, negatives, aggressiveCount); |
233 | 234 | |
234 | 235 | return { dominant, score, signals, tips }; |
235 | 236 | |
▼opengrammar/extension/src/background/index.ts
+25-18
| @@ -11,6 +11,7 @@ import type { | ||
11 | 11 | RewriteContext, |
12 | 12 | RewriteRequest, |
13 | 13 | RewriteResponse, |
14 | + Issue, | |
14 | 15 | } from '../types'; |
15 | 16 | |
16 | 17 | // Default backend URL |
| @@ -208,6 +209,29 @@ chrome.commands.onCommand.addListener((command) => { | ||
208 | 209 | } |
209 | 210 | }); |
210 | 211 | |
212 | +function filterIssues(issues: Issue[] | undefined, ignoredIssues: string[], dictionary: string[]): Issue[] { | |
213 | + if (!issues || issues.length === 0) return []; | |
214 | + let filtered = issues; | |
215 | + | |
216 | + const normalizedIgnored = normalizeIgnoredIssues(ignoredIssues); | |
217 | + if (normalizedIgnored.length > 0) { | |
218 | + filtered = filtered.filter((issue) => { | |
219 | + const issueId = issue.id || `${issue.type}-${issue.offset}-${issue.original}`; | |
220 | + return !normalizedIgnored.includes(issueId); | |
221 | + }); | |
222 | + } | |
223 | + | |
224 | + if (dictionary && dictionary.length > 0) { | |
225 | + filtered = filtered.filter((issue) => { | |
226 | + if (issue.type !== 'spelling') return true; | |
227 | + const word = issue.original.toLowerCase().trim(); | |
228 | + return !dictionary.includes(word); | |
229 | + }); | |
230 | + } | |
231 | + | |
232 | + return filtered; | |
233 | +} | |
234 | + | |
211 | 235 | async function handleGrammarCheck( |
212 | 236 | text: string, |
213 | 237 | context: AnalysisContext | undefined, |
| @@ -273,24 +297,7 @@ async function handleGrammarCheck( | ||
273 | 297 | |
274 | 298 | const data: AnalyzeResponse = await response.json(); |
275 | 299 | |
276 | - // Filter out ignored issues | |
277 | - const normalizedIgnored = normalizeIgnoredIssues(ignoredIssues); | |
278 | - | |
279 | - if (data.issues && normalizedIgnored.length > 0) { | |
280 | - data.issues = data.issues.filter((issue) => { | |
281 | - const issueId = issue.id || `${issue.type}-${issue.offset}-${issue.original}`; | |
282 | - return !normalizedIgnored.includes(issueId); | |
283 | - }); | |
284 | - } | |
285 | - | |
286 | - // Filter out dictionary words | |
287 | - if (data.issues && dictionary && dictionary.length > 0) { | |
288 | - data.issues = data.issues.filter((issue) => { | |
289 | - if (issue.type !== 'spelling') return true; | |
290 | - const word = issue.original.toLowerCase().trim(); | |
291 | - return !dictionary.includes(word); | |
292 | - }); | |
293 | - } | |
300 | + data.issues = filterIssues(data.issues, ignoredIssues || [], dictionary || []); | |
294 | 301 | |
295 | 302 | await trackAnalyticsEvent('analysis_runs', { |
296 | 303 | count: 1, |
297 | 304 | |
▼opengrammar/extension/src/content/index.ts
+41-77
| @@ -394,6 +394,46 @@ function handleKeyDown(event: Event) { | ||
394 | 394 | /** |
395 | 395 | * Check grammar for an element |
396 | 396 | */ |
397 | +function handleGrammarSuccess(element: HTMLElement, text: string, issues: Issue[]) { | |
398 | + const editableElement = activeElements.get(element); | |
399 | + if (issues && issues.length > 0) { | |
400 | + if (editableElement) editableElement.lastIssues = issues; | |
401 | + void syncActiveContext(text, issues); | |
402 | + highlightIssues(element, issues); | |
403 | + | |
404 | + const statsPerType = { grammar: 0, spelling: 0, clarity: 0, style: 0 }; | |
405 | + for (const issue of issues) { | |
406 | + if (issue.type in statsPerType) statsPerType[issue.type as keyof typeof statsPerType]++; | |
407 | + } | |
408 | + void chrome.storage.local.set({ | |
409 | + lastIssueStats: { | |
410 | + grammar: statsPerType.grammar + statsPerType.spelling, | |
411 | + style: statsPerType.style, | |
412 | + clarity: statsPerType.clarity, | |
413 | + total: issues.length, | |
414 | + }, | |
415 | + }); | |
416 | + | |
417 | + void chrome.runtime.sendMessage({ type: 'UPDATE_BADGE_COUNT', count: issues.length }); | |
418 | + | |
419 | + const wordCount = text.trim().split(/\s+/).filter((w: string) => w.length > 0).length; | |
420 | + const errorTypes: Record<string, number> = {}; | |
421 | + for (const issue of issues) { | |
422 | + errorTypes[issue.type] = (errorTypes[issue.type] || 0) + 1; | |
423 | + } | |
424 | + const issuesPerHundred = wordCount > 0 ? (issues.length / wordCount) * 100 : 0; | |
425 | + const writingScore = Math.round(Math.max(0, Math.min(100, 100 - issuesPerHundred * 10))); | |
426 | + void chrome.runtime.sendMessage({ | |
427 | + type: 'SAVE_WRITING_SESSION', | |
428 | + payload: { wordsChecked: wordCount, issuesFound: issues.length, issuesFixed: 0, writingScore, errorTypes }, | |
429 | + }); | |
430 | + } else { | |
431 | + if (editableElement) editableElement.lastIssues = []; | |
432 | + void syncActiveContext(text, []); | |
433 | + void chrome.runtime.sendMessage({ type: 'UPDATE_BADGE_COUNT', count: 0 }); | |
434 | + } | |
435 | +} | |
436 | + | |
397 | 437 | const checkGrammar = async (element: HTMLElement) => { |
398 | 438 | if (!checkContext()) return; |
399 | 439 | |
| @@ -403,12 +443,7 @@ const checkGrammar = async (element: HTMLElement) => { | ||
403 | 443 | } |
404 | 444 | |
405 | 445 | const text = extractText(element); |
406 | - | |
407 | - console.log('[OpenGrammar] Checking grammar for text:', text.substring(0, 100)); | |
408 | - | |
409 | - // Skip very short text | |
410 | 446 | if (!text || text.trim().length < 5) { |
411 | - console.log('[OpenGrammar] Text too short, skipping'); | |
412 | 447 | clearHighlights(); |
413 | 448 | return; |
414 | 449 | } |
| @@ -421,83 +456,12 @@ const checkGrammar = async (element: HTMLElement) => { | ||
421 | 456 | context: buildAnalysisContext(element, text), |
422 | 457 | }); |
423 | 458 | |
424 | - const duration = Date.now() - startTime; | |
425 | - console.log('[OpenGrammar] Grammar check took:', duration, 'ms'); | |
426 | - | |
427 | 459 | if (response?.error) { |
428 | - console.warn('[OpenGrammar] Grammar check error:', response.error); | |
429 | 460 | showNotification('Grammar check failed: ' + response.error, 'error'); |
430 | 461 | return; |
431 | 462 | } |
432 | 463 | |
433 | - if (response?.issues) { | |
434 | - console.log('[OpenGrammar] Found', response.issues.length, 'issues'); | |
435 | - if (response.issues.length > 0) { | |
436 | - console.log('[OpenGrammar] First issue:', response.issues[0]); | |
437 | - } | |
438 | - const editableElement = activeElements.get(element); | |
439 | - if (editableElement) { | |
440 | - editableElement.lastIssues = response.issues; | |
441 | - } | |
442 | - void syncActiveContext(text, response.issues); | |
443 | - highlightIssues(element, response.issues); | |
444 | - | |
445 | - // Save issue stats for popup score ring | |
446 | - const statsPerType = { grammar: 0, spelling: 0, clarity: 0, style: 0 }; | |
447 | - for (const issue of response.issues) { | |
448 | - if (issue.type in statsPerType) statsPerType[issue.type as keyof typeof statsPerType]++; | |
449 | - } | |
450 | - void chrome.storage.local.set({ | |
451 | - lastIssueStats: { | |
452 | - grammar: statsPerType.grammar + statsPerType.spelling, | |
453 | - style: statsPerType.style, | |
454 | - clarity: statsPerType.clarity, | |
455 | - total: response.issues.length, | |
456 | - }, | |
457 | - }); | |
458 | - | |
459 | - // D1: Update badge count | |
460 | - void chrome.runtime.sendMessage({ | |
461 | - type: 'UPDATE_BADGE_COUNT', | |
462 | - count: response.issues.length, | |
463 | - }); | |
464 | - | |
465 | - // D4: Save writing session data | |
466 | - const wordCount = text | |
467 | - .trim() | |
468 | - .split(/\s+/) | |
469 | - .filter((w: string) => w.length > 0).length; | |
470 | - const errorTypes: Record<string, number> = {}; | |
471 | - for (const issue of response.issues) { | |
472 | - errorTypes[issue.type] = (errorTypes[issue.type] || 0) + 1; | |
473 | - } | |
474 | - // Simple writing score: correctness component (scale 0-100) | |
475 | - const issuesPerHundred = wordCount > 0 ? (response.issues.length / wordCount) * 100 : 0; | |
476 | - const writingScore = Math.round(Math.max(0, Math.min(100, 100 - issuesPerHundred * 10))); | |
477 | - void chrome.runtime.sendMessage({ | |
478 | - type: 'SAVE_WRITING_SESSION', | |
479 | - payload: { | |
480 | - wordsChecked: wordCount, | |
481 | - issuesFound: response.issues.length, | |
482 | - issuesFixed: 0, | |
483 | - writingScore, | |
484 | - errorTypes, | |
485 | - }, | |
486 | - }); | |
487 | - } else { | |
488 | - console.log('[OpenGrammar] No issues found or empty response'); | |
489 | - const editableElement = activeElements.get(element); | |
490 | - if (editableElement) { | |
491 | - editableElement.lastIssues = []; | |
492 | - } | |
493 | - void syncActiveContext(text, []); | |
494 | - | |
495 | - // D1: Clear badge when no issues | |
496 | - void chrome.runtime.sendMessage({ | |
497 | - type: 'UPDATE_BADGE_COUNT', | |
498 | - count: 0, | |
499 | - }); | |
500 | - } | |
464 | + handleGrammarSuccess(element, text, response?.issues || []); | |
501 | 465 | } catch (err) { |
502 | 466 | if (err instanceof Error && err.message.includes('context invalidated')) { |
503 | 467 | isContextInvalidated = true; |
504 | 468 | |
▼opengrammar/extension/src/popup/popup.tsx
+169-285
| @@ -77,16 +77,144 @@ const ChevronIcon = () => ( | ||
77 | 77 | </svg> |
78 | 78 | ); |
79 | 79 | |
80 | +/* ─── Score and Info Section ─── */ | |
81 | +const ScoreSection = ({ issueStats, writingScore }: { issueStats: any, writingScore: number }) => ( | |
82 | + <> | |
83 | + <div className="score-section"> | |
84 | + <ScoreRing score={writingScore} /> | |
85 | + <div className="score-info"> | |
86 | + <h2> | |
87 | + {writingScore >= 88 ? 'Excellent writing!' : | |
88 | + writingScore >= 70 ? 'Good writing' : | |
89 | + writingScore >= 50 ? 'Needs some work' : | |
90 | + 'Issues detected'} | |
91 | + </h2> | |
92 | + <p> | |
93 | + {issueStats.total === 0 | |
94 | + ? 'No issues found on this page.' | |
95 | + : `${issueStats.total} issue${issueStats.total !== 1 ? 's' : ''} detected — click underlined text to fix.`} | |
96 | + </p> | |
97 | + </div> | |
98 | + </div> | |
99 | + <div className="issue-chips"> | |
100 | + {issueStats.total === 0 ? ( | |
101 | + <span className="chip ok"><span className="chip-dot" />All clear</span> | |
102 | + ) : ( | |
103 | + <> | |
104 | + {issueStats.grammar > 0 && <span className="chip grammar"><span className="chip-dot" />{issueStats.grammar} grammar</span>} | |
105 | + {issueStats.clarity > 0 && <span className="chip clarity"><span className="chip-dot" />{issueStats.clarity} clarity</span>} | |
106 | + {issueStats.style > 0 && <span className="chip style"><span className="chip-dot" />{issueStats.style} style</span>} | |
107 | + </> | |
108 | + )} | |
109 | + </div> | |
110 | + </> | |
111 | +); | |
112 | + | |
113 | +/* ─── Settings Panel ─── */ | |
114 | +const SettingsPanel = ({ | |
115 | + settings, | |
116 | + saveSettings, | |
117 | + providers, | |
118 | + selectedProvider, | |
119 | + modelList, | |
120 | + fetchingModels, | |
121 | + handleProviderChange, | |
122 | + showApiKey, | |
123 | + setShowApiKey, | |
124 | + showAdvanced, | |
125 | + setAdvanced, | |
126 | + advancedRef, | |
127 | +}: any) => ( | |
128 | + <div className={`settings-area ${!settings.enabled ? 'muted' : ''}`}> | |
129 | + <div className="field-group"> | |
130 | + <label className="toggle-label"> | |
131 | + <div className="toggle-text"> | |
132 | + <strong>Enable OpenGrammar</strong> | |
133 | + <span>Check grammar on every page</span> | |
134 | + </div> | |
135 | + <input | |
136 | + type="checkbox" | |
137 | + checked={settings.enabled} | |
138 | + onChange={(e) => saveSettings({ enabled: e.target.checked })} | |
139 | + className="toggle-input" | |
140 | + /> | |
141 | + <span className="toggle-track" /> | |
142 | + </label> | |
143 | + </div> | |
144 | + | |
145 | + <div className="field-group"> | |
146 | + <label className="field-label">AI Provider</label> | |
147 | + <select value={settings.provider} onChange={handleProviderChange} className="select-input"> | |
148 | + {providers.map((p: any) => ( | |
149 | + <option key={p.id} value={p.id}>{p.name} {p.requiresApiKey ? '🔑' : '✅ Free'}</option> | |
150 | + ))} | |
151 | + </select> | |
152 | + {selectedProvider && <p className="field-hint">{selectedProvider.description}</p>} | |
153 | + </div> | |
154 | + | |
155 | + {selectedProvider?.requiresApiKey && ( | |
156 | + <div className="field-group"> | |
157 | + <label className="field-label">API Key <span style={{ textTransform: 'none', letterSpacing: 0, fontWeight: 400, color: '#aeaeb2' }}>— stored locally</span></label> | |
158 | + <div className="input-row"> | |
159 | + <input | |
160 | + type={showApiKey ? 'text' : 'password'} | |
161 | + value={settings.apiKey} | |
162 | + onChange={(e) => saveSettings({ apiKey: e.target.value })} | |
163 | + placeholder={settings.provider === 'ollama' ? 'Not required' : 'sk-…'} | |
164 | + className="text-input" | |
165 | + disabled={settings.provider === 'ollama'} | |
166 | + /> | |
167 | + <button type="button" onClick={() => setShowApiKey((v: boolean) => !v)} className="icon-btn" title={showApiKey ? 'Hide' : 'Show'}> | |
168 | + <EyeIcon show={showApiKey} /> | |
169 | + </button> | |
170 | + </div> | |
171 | + </div> | |
172 | + )} | |
173 | + | |
174 | + <div className="field-group"> | |
175 | + <label className="field-label">Model</label> | |
176 | + <select | |
177 | + value={settings.model} | |
178 | + onChange={(e) => saveSettings({ model: e.target.value })} | |
179 | + className="select-input" | |
180 | + disabled={fetchingModels} | |
181 | + > | |
182 | + {fetchingModels ? <option>Loading…</option> : modelList.map((m: string) => <option key={m} value={m}>{m}</option>)} | |
183 | + </select> | |
184 | + </div> | |
185 | + | |
186 | + <button type="button" className={`advanced-toggle ${showAdvanced ? 'open' : ''}`} onClick={() => setAdvanced((v: boolean) => !v)}> | |
187 | + <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}><SettingsIcon /> Advanced settings</span> | |
188 | + <ChevronIcon /> | |
189 | + </button> | |
190 | + | |
191 | + {showAdvanced && ( | |
192 | + <div className="advanced-panel" ref={advancedRef}> | |
193 | + {settings.provider === 'custom' && ( | |
194 | + <div className="field-group"> | |
195 | + <label className="field-label">Custom Base URL</label> | |
196 | + <input type="url" value={settings.customBaseUrl} onChange={(e) => saveSettings({ customBaseUrl: e.target.value })} placeholder="https://your-api.com/v1" className="text-input" /> | |
197 | + </div> | |
198 | + )} | |
199 | + <div className="field-group"> | |
200 | + <label className="field-label">Backend URL</label> | |
201 | + <p className="field-hint" style={{ marginBottom: 5 }}>Default: http://localhost:8787</p> | |
202 | + <input type="url" value={settings.backendUrl} onChange={(e) => saveSettings({ backendUrl: e.target.value })} placeholder="http://localhost:8787" className="text-input" /> | |
203 | + <div className="backend-status-row"> | |
204 | + <span className={`backend-dot ${settings.backendHealthy ? 'ok' : 'err'}`} /> | |
205 | + <span className="status-text">{settings.backendHealthy ? 'Connected' : 'Not connected'}</span> | |
206 | + </div> | |
207 | + </div> | |
208 | + </div> | |
209 | + )} | |
210 | + </div> | |
211 | +); | |
212 | + | |
80 | 213 | /* ─── Main Popup Component ─── */ |
81 | 214 | const Popup = () => { |
82 | 215 | const [settings, setSettings] = useState<Settings>({ |
83 | - enabled: true, | |
84 | - apiKey: '', | |
85 | - model: 'gpt-4o-mini', | |
86 | - backendUrl: 'http://localhost:8787', | |
87 | - provider: 'openai', | |
88 | - customBaseUrl: '', | |
89 | - backendHealthy: true, | |
216 | + enabled: true, apiKey: '', model: 'gpt-4o-mini', backendUrl: 'http://localhost:8787', | |
217 | + provider: 'openai', customBaseUrl: '', backendHealthy: true, | |
90 | 218 | }); |
91 | 219 | const [providerModelMemory, setProviderModelMemory] = useState<Record<string, string>>({}); |
92 | 220 | const [showApiKey, setShowApiKey] = useState(false); |
| @@ -98,326 +226,82 @@ const Popup = () => { | ||
98 | 226 | const [issueStats, setIssueStats] = useState({ grammar: 0, style: 0, clarity: 0, total: 0 }); |
99 | 227 | const advancedRef = useRef<HTMLDivElement>(null); |
100 | 228 | |
101 | - useEffect(() => { | |
102 | - loadSettings(); | |
103 | - loadProviders(); | |
104 | - loadIssueStats(); | |
105 | - }, []); | |
106 | - | |
107 | - useEffect(() => { | |
108 | - if (settings.provider) loadModels(); | |
109 | - }, [settings.provider]); | |
229 | + useEffect(() => { loadSettings(); loadProviders(); loadIssueStats(); }, []); | |
230 | + useEffect(() => { if (settings.provider) loadModels(); }, [settings.provider]); | |
110 | 231 | |
111 | 232 | const loadSettings = () => { |
112 | - chrome.storage.sync.get( | |
113 | - ['enabled', 'apiKey', 'model', 'backendUrl', 'provider', 'customBaseUrl', 'backendHealthy', 'providerModelMemory'], | |
114 | - (result) => { | |
115 | - const memory = (result.providerModelMemory || {}) as Record<string, string>; | |
116 | - const selectedProvider = result.provider || 'openai'; | |
117 | - const rememberedModel = memory[selectedProvider]; | |
118 | - setSettings({ | |
119 | - enabled: result.enabled !== false, | |
120 | - apiKey: result.apiKey || '', | |
121 | - model: rememberedModel || result.model || 'gpt-4o-mini', | |
122 | - backendUrl: result.backendUrl || 'http://localhost:8787', | |
123 | - provider: selectedProvider, | |
124 | - customBaseUrl: result.customBaseUrl || '', | |
125 | - backendHealthy: result.backendHealthy !== false, | |
126 | - }); | |
127 | - setProviderModelMemory(memory); | |
128 | - setLoading(false); | |
129 | - }, | |
130 | - ); | |
233 | + chrome.storage.sync.get(['enabled', 'apiKey', 'model', 'backendUrl', 'provider', 'customBaseUrl', 'backendHealthy', 'providerModelMemory'], (result) => { | |
234 | + const memory = (result.providerModelMemory || {}) as Record<string, string>; | |
235 | + const selectedProvider = result.provider || 'openai'; | |
236 | + setSettings({ | |
237 | + enabled: result.enabled !== false, apiKey: result.apiKey || '', model: memory[selectedProvider] || result.model || 'gpt-4o-mini', | |
238 | + backendUrl: result.backendUrl || 'http://localhost:8787', provider: selectedProvider, customBaseUrl: result.customBaseUrl || '', | |
239 | + backendHealthy: result.backendHealthy !== false, | |
240 | + }); | |
241 | + setProviderModelMemory(memory); | |
242 | + setLoading(false); | |
243 | + }); | |
131 | 244 | }; |
132 | 245 | |
133 | - const loadProviders = async () => { | |
134 | - try { | |
135 | - const r = await chrome.runtime.sendMessage({ type: 'GET_PROVIDERS' }); | |
136 | - if (r.providers) setProviders(r.providers); | |
137 | - } catch {} | |
138 | - }; | |
246 | + const loadProviders = async () => { try { const r = await chrome.runtime.sendMessage({ type: 'GET_PROVIDERS' }); if (r.providers) setProviders(r.providers); } catch {} }; | |
139 | 247 | |
140 | 248 | const loadModels = async () => { |
141 | 249 | setFetching(true); |
142 | - try { | |
143 | - const r = await chrome.runtime.sendMessage({ | |
144 | - type: 'GET_MODELS', | |
145 | - provider: settings.provider, | |
146 | - apiKey: settings.apiKey, | |
147 | - baseUrl: settings.provider === 'custom' ? settings.customBaseUrl : undefined, | |
148 | - }); | |
149 | - if (r.models) setModels(r.models); | |
150 | - } catch {} | |
151 | - finally { setFetching(false); } | |
250 | + try { const r = await chrome.runtime.sendMessage({ type: 'GET_MODELS', provider: settings.provider, apiKey: settings.apiKey, baseUrl: settings.provider === 'custom' ? settings.customBaseUrl : undefined }); if (r.models) setModels(r.models); } catch {} finally { setFetching(false); } | |
152 | 251 | }; |
153 | 252 | |
154 | - /** Pull last-session issue stats from storage */ | |
155 | - const loadIssueStats = () => { | |
156 | - chrome.storage.local.get(['lastIssueStats'], (r) => { | |
157 | - if (r.lastIssueStats) setIssueStats(r.lastIssueStats as typeof issueStats); | |
158 | - }); | |
159 | - }; | |
253 | + const loadIssueStats = () => { chrome.storage.local.get(['lastIssueStats'], (r) => { if (r.lastIssueStats) setIssueStats(r.lastIssueStats as typeof issueStats); }); }; | |
160 | 254 | |
161 | 255 | const saveSettings = (updates: Partial<Settings>) => { |
162 | 256 | const next = { ...settings, ...updates }; |
163 | 257 | setSettings(next); |
164 | 258 | const mem = { ...providerModelMemory, ...(next.provider ? { [next.provider]: next.model } : {}) }; |
165 | 259 | setProviderModelMemory(mem); |
166 | - chrome.storage.sync.set({ | |
167 | - enabled: next.enabled, apiKey: next.apiKey, model: next.model, | |
168 | - backendUrl: next.backendUrl, provider: next.provider, | |
169 | - customBaseUrl: next.customBaseUrl, providerModelMemory: mem, | |
170 | - }); | |
260 | + chrome.storage.sync.set({ ...next, providerModelMemory: mem }); | |
171 | 261 | }; |
172 | 262 | |
173 | 263 | const handleProviderChange = (e: React.ChangeEvent<HTMLSelectElement>) => { |
174 | 264 | const p = e.target.value; |
175 | - const remembered = providerModelMemory[p]; | |
176 | - const def = providers.find((x) => x.id === p)?.models[0] || 'gpt-4o-mini'; | |
177 | - saveSettings({ provider: p, model: remembered || def }); | |
265 | + saveSettings({ provider: p, model: providerModelMemory[p] || providers.find((x) => x.id === p)?.models[0] || 'gpt-4o-mini' }); | |
178 | 266 | }; |
179 | 267 | |
180 | - /** Rough writing score based on issue counts */ | |
181 | - const writingScore = Math.max( | |
182 | - 10, | |
183 | - 100 - issueStats.grammar * 12 - issueStats.style * 6 - issueStats.clarity * 4, | |
184 | - ); | |
185 | - | |
268 | + const writingScore = Math.max(10, 100 - issueStats.grammar * 12 - issueStats.style * 6 - issueStats.clarity * 4); | |
186 | 269 | const selectedProvider = providers.find((p) => p.id === settings.provider); |
187 | - const modelList = availableModels.length > 0 ? availableModels : selectedProvider?.models || []; | |
270 | + const modelList = availableModels.length > 0 ? availableModels : selectedProvider?.models || []; | |
188 | 271 | |
189 | - if (loading) { | |
190 | - return ( | |
191 | - <div className="load-screen"> | |
192 | - <div className="spinner" /> | |
193 | - <p>Loading…</p> | |
194 | - </div> | |
195 | - ); | |
196 | - } | |
272 | + if (loading) return <div className="load-screen"><div className="spinner" /><p>Loading…</p></div>; | |
197 | 273 | |
198 | 274 | return ( |
199 | 275 | <div> |
200 | - {/* ── Header ── */} | |
201 | 276 | <header className="popup-header"> |
202 | 277 | <div className="brand"> |
203 | - {/* Indigo-violet quill logo */} | |
204 | - <div className="brand-icon"> | |
205 | - <svg width="20" height="20" viewBox="0 0 24 24" fill="none"> | |
206 | - {/* Quill pen — represents writing & language */} | |
207 | - <path d="M20 4C15 4 10 7 8 12L4 20l8-4c5-2 8-7 8-12z" | |
208 | - fill="white" fill-opacity="0.92"/> | |
209 | - <path d="M8 12 L4 20" stroke="rgba(255,255,255,0.6)" strokeWidth="1.3" strokeLinecap="round"/> | |
210 | - {/* Checkmark nib */} | |
211 | - <path d="M5 18l2 2 4-5" stroke="white" strokeWidth="1.6" | |
212 | - strokeLinecap="round" strokeLinejoin="round" fill="none"/> | |
213 | - </svg> | |
214 | - </div> | |
278 | + <div className="brand-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M20 4C15 4 10 7 8 12L4 20l8-4c5-2 8-7 8-12z" fill="white" fillOpacity="0.92"/><path d="M8 12 L4 20" stroke="rgba(255,255,255,0.6)" strokeWidth="1.3" strokeLinecap="round"/><path d="M5 18l2 2 4-5" stroke="white" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" fill="none"/></svg></div> | |
215 | 279 | <span className="brand-name">Open<span>Grammar</span></span> |
216 | 280 | </div> |
217 | - <button | |
218 | - className={`status-pill ${settings.enabled ? 'active' : 'paused'}`} | |
219 | - onClick={() => saveSettings({ enabled: !settings.enabled })} | |
220 | - title={settings.enabled ? 'Click to pause' : 'Click to enable'} | |
221 | - > | |
222 | - <span className="dot" /> | |
223 | - {settings.enabled ? 'Active' : 'Paused'} | |
281 | + <button className={`status-pill ${settings.enabled ? 'active' : 'paused'}`} onClick={() => saveSettings({ enabled: !settings.enabled })} title={settings.enabled ? 'Click to pause' : 'Click to enable'}> | |
282 | + <span className="dot" />{settings.enabled ? 'Active' : 'Paused'} | |
224 | 283 | </button> |
225 | 284 | </header> |
226 | 285 | |
227 | - {/* ── Writing score ring ── */} | |
228 | - <div className="score-section"> | |
229 | - <ScoreRing score={writingScore} /> | |
230 | - <div className="score-info"> | |
231 | - <h2> | |
232 | - {writingScore >= 88 ? 'Excellent writing!' : | |
233 | - writingScore >= 70 ? 'Good writing' : | |
234 | - writingScore >= 50 ? 'Needs some work' : | |
235 | - 'Issues detected'} | |
236 | - </h2> | |
237 | - <p> | |
238 | - {issueStats.total === 0 | |
239 | - ? 'No issues found on this page.' | |
240 | - : `${issueStats.total} issue${issueStats.total !== 1 ? 's' : ''} detected — click underlined text to fix.`} | |
241 | - </p> | |
242 | - </div> | |
243 | - </div> | |
286 | + <ScoreSection issueStats={issueStats} writingScore={writingScore} /> | |
244 | 287 | |
245 | - {/* ── Issue category chips ── */} | |
246 | - <div className="issue-chips"> | |
247 | - {issueStats.total === 0 ? ( | |
248 | - <span className="chip ok"> | |
249 | - <span className="chip-dot" /> | |
250 | - All clear | |
251 | - </span> | |
252 | - ) : ( | |
253 | - <> | |
254 | - {issueStats.grammar > 0 && ( | |
255 | - <span className="chip grammar"> | |
256 | - <span className="chip-dot" /> | |
257 | - {issueStats.grammar} grammar | |
258 | - </span> | |
259 | - )} | |
260 | - {issueStats.clarity > 0 && ( | |
261 | - <span className="chip clarity"> | |
262 | - <span className="chip-dot" /> | |
263 | - {issueStats.clarity} clarity | |
264 | - </span> | |
265 | - )} | |
266 | - {issueStats.style > 0 && ( | |
267 | - <span className="chip style"> | |
268 | - <span className="chip-dot" /> | |
269 | - {issueStats.style} style | |
270 | - </span> | |
271 | - )} | |
272 | - </> | |
273 | - )} | |
274 | - </div> | |
275 | - | |
276 | - {/* ── Quick actions ── */} | |
277 | 288 | <div className="quick-actions"> |
278 | - <button | |
279 | - className="action-btn" | |
280 | - onClick={() => chrome.runtime.sendMessage({ type: 'OPEN_REWRITE_PAGE' })} | |
281 | - > | |
282 | - <RewriteIcon /> | |
283 | - Rephrase | |
284 | - </button> | |
285 | - <button | |
286 | - className="action-btn" | |
287 | - onClick={() => chrome.runtime.sendMessage({ type: 'OPEN_STATS_PAGE' })} | |
288 | - > | |
289 | - <StatsIcon /> | |
290 | - Stats | |
291 | - </button> | |
289 | + <button className="action-btn" onClick={() => chrome.runtime.sendMessage({ type: 'OPEN_REWRITE_PAGE' })}><RewriteIcon />Rephrase</button> | |
290 | + <button className="action-btn" onClick={() => chrome.runtime.sendMessage({ type: 'OPEN_STATS_PAGE' })}><StatsIcon />Stats</button> | |
292 | 291 | </div> |
293 | 292 | |
294 | - {/* ── AI provider info card ── */} | |
295 | 293 | <div className="ai-card"> |
296 | - <div className="ai-card-left"> | |
297 | - <strong>AI Engine</strong> | |
298 | - <span>{selectedProvider?.name || 'OpenAI'} · {settings.model}</span> | |
299 | - </div> | |
294 | + <div className="ai-card-left"><strong>AI Engine</strong><span>{selectedProvider?.name || 'OpenAI'} · {settings.model}</span></div> | |
300 | 295 | <span className={`backend-dot ${settings.backendHealthy ? 'ok' : 'err'}`} title={settings.backendHealthy ? 'Backend connected' : 'Backend offline'} /> |
301 | 296 | </div> |
302 | 297 | |
303 | 298 | <hr className="divider" /> |
304 | - | |
305 | - {/* ── Settings ── */} | |
306 | - <div className={`settings-area ${!settings.enabled ? 'muted' : ''}`}> | |
307 | - | |
308 | - {/* Enable toggle */} | |
309 | - <div className="field-group"> | |
310 | - <label className="toggle-label"> | |
311 | - <div className="toggle-text"> | |
312 | - <strong>Enable OpenGrammar</strong> | |
313 | - <span>Check grammar on every page</span> | |
314 | - </div> | |
315 | - <input | |
316 | - type="checkbox" | |
317 | - checked={settings.enabled} | |
318 | - onChange={(e) => saveSettings({ enabled: e.target.checked })} | |
319 | - className="toggle-input" | |
320 | - /> | |
321 | - <span className="toggle-track" /> | |
322 | - </label> | |
323 | - </div> | |
324 | - | |
325 | - {/* Provider selector */} | |
326 | - <div className="field-group"> | |
327 | - <label className="field-label">AI Provider</label> | |
328 | - <select value={settings.provider} onChange={handleProviderChange} className="select-input"> | |
329 | - {providers.map((p) => ( | |
330 | - <option key={p.id} value={p.id}> | |
331 | - {p.name} {p.requiresApiKey ? '🔑' : '✅ Free'} | |
332 | - </option> | |
333 | - ))} | |
334 | - </select> | |
335 | - {selectedProvider && <p className="field-hint">{selectedProvider.description}</p>} | |
336 | - </div> | |
337 | - | |
338 | - {/* API key */} | |
339 | - {selectedProvider?.requiresApiKey && ( | |
340 | - <div className="field-group"> | |
341 | - <label className="field-label">API Key <span style={{ textTransform: 'none', letterSpacing: 0, fontWeight: 400, color: '#aeaeb2' }}>— stored locally</span></label> | |
342 | - <div className="input-row"> | |
343 | - <input | |
344 | - type={showApiKey ? 'text' : 'password'} | |
345 | - value={settings.apiKey} | |
346 | - onChange={(e) => saveSettings({ apiKey: e.target.value })} | |
347 | - placeholder={settings.provider === 'ollama' ? 'Not required' : 'sk-…'} | |
348 | - className="text-input" | |
349 | - disabled={settings.provider === 'ollama'} | |
350 | - /> | |
351 | - <button type="button" onClick={() => setShowApiKey((v) => !v)} className="icon-btn" title={showApiKey ? 'Hide' : 'Show'}> | |
352 | - <EyeIcon show={showApiKey} /> | |
353 | - </button> | |
354 | - </div> | |
355 | - </div> | |
356 | - )} | |
357 | - | |
358 | - {/* Model selector */} | |
359 | - <div className="field-group"> | |
360 | - <label className="field-label">Model</label> | |
361 | - <select | |
362 | - value={settings.model} | |
363 | - onChange={(e) => saveSettings({ model: e.target.value })} | |
364 | - className="select-input" | |
365 | - disabled={fetchingModels} | |
366 | - > | |
367 | - {fetchingModels | |
368 | - ? <option>Loading…</option> | |
369 | - : modelList.map((m) => <option key={m} value={m}>{m}</option>) | |
370 | - } | |
371 | - </select> | |
372 | - </div> | |
373 | - | |
374 | - {/* Advanced toggle */} | |
375 | - <button | |
376 | - type="button" | |
377 | - className={`advanced-toggle ${showAdvanced ? 'open' : ''}`} | |
378 | - onClick={() => setAdvanced((v) => !v)} | |
379 | - > | |
380 | - <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}> | |
381 | - <SettingsIcon /> | |
382 | - Advanced settings | |
383 | - </span> | |
384 | - <ChevronIcon /> | |
385 | - </button> | |
386 | - | |
387 | - {showAdvanced && ( | |
388 | - <div className="advanced-panel" ref={advancedRef}> | |
389 | - {settings.provider === 'custom' && ( | |
390 | - <div className="field-group"> | |
391 | - <label className="field-label">Custom Base URL</label> | |
392 | - <input | |
393 | - type="url" | |
394 | - value={settings.customBaseUrl} | |
395 | - onChange={(e) => saveSettings({ customBaseUrl: e.target.value })} | |
396 | - placeholder="https://your-api.com/v1" | |
397 | - className="text-input" | |
398 | - /> | |
399 | - </div> | |
400 | - )} | |
401 | - <div className="field-group"> | |
402 | - <label className="field-label">Backend URL</label> | |
403 | - <p className="field-hint" style={{ marginBottom: 5 }}>Default: http://localhost:8787</p> | |
404 | - <input | |
405 | - type="url" | |
406 | - value={settings.backendUrl} | |
407 | - onChange={(e) => saveSettings({ backendUrl: e.target.value })} | |
408 | - placeholder="http://localhost:8787" | |
409 | - className="text-input" | |
410 | - /> | |
411 | - <div className="backend-status-row"> | |
412 | - <span className={`backend-dot ${settings.backendHealthy ? 'ok' : 'err'}`} /> | |
413 | - <span className="status-text"> | |
414 | - {settings.backendHealthy ? 'Connected' : 'Not connected'} | |
415 | - </span> | |
416 | - </div> | |
417 | - </div> | |
418 | - </div> | |
419 | - )} | |
420 | - </div> | |
299 | + | |
300 | + <SettingsPanel | |
301 | + settings={settings} saveSettings={saveSettings} providers={providers} selectedProvider={selectedProvider} modelList={modelList} | |
302 | + fetchingModels={fetchingModels} handleProviderChange={handleProviderChange} showApiKey={showApiKey} setShowApiKey={setShowApiKey} | |
303 | + showAdvanced={showAdvanced} setAdvanced={setAdvanced} advancedRef={advancedRef} | |
304 | + /> | |
421 | 305 | |
422 | 306 | {/* ── Footer ── */} |
423 | 307 | <footer className="popup-footer"> |
424 | 308 | |