Swadhin Biswas

[email protected]
2026-03-28 20:05:10 +0600 committed NaN years ago
d6233bb
commit d6233bb
4 files changed
+319 -463
▼opengrammar/backend/src/nlp/tone-analyzer.ts
+84-83
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
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
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
11
RewriteContext,
12
12
RewriteRequest,
13
13
RewriteResponse,
14
+ Issue,
14
15
} from '../types';
15
16
16
17
// Default backend URL
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
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
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
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
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
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
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