From 180c1a528ff71c164d340cd0159a213768c01ccd Mon Sep 17 00:00:00 2001 From: Nathan Redblur Date: Fri, 27 Feb 2026 16:29:21 -0500 Subject: [PATCH 1/4] feat: add API integration for external proofreading service * Introduced API configuration options in the settings, allowing users to connect to an external AI proofreading service. * Implemented functionality to test API connection and fetch available models. * Updated storage management to handle API configuration and model source selection. * Enhanced the background service to support API-based proofreading requests. * Added UI elements for API configuration in the options page. --- manifest.config.ts | 1 + src/background/main.ts | 61 +++++- src/background/proofreader-proxy.ts | 43 +++- src/options/api-config-section.ts | 243 +++++++++++++++++++++ src/options/main.ts | 177 ++++++++++++++-- src/options/style.css | 206 ++++++++++++++++++ src/services/api-proofreader.ts | 316 ++++++++++++++++++++++++++++ src/shared/constants.ts | 12 +- src/shared/messages/issues.ts | 23 +- src/shared/types.ts | 12 ++ src/shared/utils/storage.ts | 6 +- 11 files changed, 1079 insertions(+), 21 deletions(-) create mode 100644 src/options/api-config-section.ts create mode 100644 src/services/api-proofreader.ts diff --git a/manifest.config.ts b/manifest.config.ts index fab7e76..1f981f8 100644 --- a/manifest.config.ts +++ b/manifest.config.ts @@ -36,6 +36,7 @@ export default defineManifest({ }, ], permissions: ['sidePanel', 'tabs', 'storage', 'contextMenus'], + optional_host_permissions: ['https://*/*', 'http://*/*'], side_panel: { default_path: 'src/sidepanel/index.html', }, diff --git a/src/background/main.ts b/src/background/main.ts index 93efa3d..e0291ca 100644 --- a/src/background/main.ts +++ b/src/background/main.ts @@ -1,7 +1,12 @@ import { STORAGE_KEYS } from '../shared/constants.ts'; -import { initializeStorage, onStorageChange } from '../shared/utils/storage.ts'; +import { initializeStorage, onStorageChange, getStorageValue } from '../shared/utils/storage.ts'; import { logger } from '../services/logger.ts'; -import { handleProofreadRequest, resetProofreaderServices } from './proofreader-proxy.ts'; +import { + handleProofreadRequest, + resetProofreaderServices, + updateModelSourceCache, +} from './proofreader-proxy.ts'; +import { getApiProvider } from '../services/api-proofreader.ts'; import type { IssuesUpdatePayload, @@ -13,6 +18,8 @@ import type { ProofreadRequestMessage, ProofreaderBusyStateRequestMessage, ProofreaderBusyStateResponseMessage, + ApiTestConnectionResponse, + ApiFetchModelsResponse, } from '../shared/messages/issues.ts'; import { serializeError } from '../shared/utils/serialize.ts'; import { handleSidepanelToggleEvent } from './sidepanel-button-handler.ts'; @@ -285,6 +292,15 @@ function registerBadgeListeners(): void { void updateActionBadge(); }); + onStorageChange(STORAGE_KEYS.MODEL_SOURCE, (newValue) => { + updateModelSourceCache(newValue); + resetProofreaderServices(); + }); + + onStorageChange(STORAGE_KEYS.API_CONFIG, () => { + resetProofreaderServices(); + }); + badgeListenersRegistered = true; } @@ -293,6 +309,7 @@ void updateActionBadge(); chrome.runtime.onInstalled.addListener(async (details) => { await initializeStorage(); + updateModelSourceCache(await getStorageValue(STORAGE_KEYS.MODEL_SOURCE)); logger.info({ reason: details?.reason }, 'Proofly extension installed and storage initialized'); chrome.contextMenus.create({ @@ -316,6 +333,7 @@ chrome.runtime.onInstalled.addListener(async (details) => { chrome.runtime.onStartup.addListener(async () => { await initializeStorage(); + updateModelSourceCache(await getStorageValue(STORAGE_KEYS.MODEL_SOURCE)); logger.info('Proofly extension started'); registerBadgeListeners(); @@ -390,6 +408,45 @@ chrome.runtime.onMessage.addListener((message: ProoflyMessage, sender, sendRespo return handleSidepanelToggleEvent(sendResponse, sender, message); } + if (message.type === 'proofly:api-test-connection') { + getStorageValue(STORAGE_KEYS.API_CONFIG) + .then((apiConfig) => { + const provider = getApiProvider(apiConfig.type); + return provider.testConnection(apiConfig); + }) + .then((result) => { + sendResponse({ + ok: result.ok, + message: result.message, + } satisfies ApiTestConnectionResponse); + }) + .catch((error) => { + sendResponse({ + ok: false, + message: error instanceof Error ? error.message : 'Unknown error', + } satisfies ApiTestConnectionResponse); + }); + return true; + } + + if (message.type === 'proofly:api-fetch-models') { + getStorageValue(STORAGE_KEYS.API_CONFIG) + .then((apiConfig) => { + const provider = getApiProvider(apiConfig.type); + return provider.fetchModels(apiConfig); + }) + .then((models) => { + sendResponse({ ok: true, models } satisfies ApiFetchModelsResponse); + }) + .catch((error) => { + sendResponse({ + ok: false, + message: error instanceof Error ? error.message : 'Failed to fetch models', + } satisfies ApiFetchModelsResponse); + }); + return true; + } + return false; }); diff --git a/src/background/proofreader-proxy.ts b/src/background/proofreader-proxy.ts index dfb4c8a..7730ab5 100644 --- a/src/background/proofreader-proxy.ts +++ b/src/background/proofreader-proxy.ts @@ -4,18 +4,29 @@ import { createProofreaderAdapter, createProofreadingService, } from '../services/proofreader.ts'; +import { getApiProvider } from '../services/api-proofreader.ts'; import type { ProofreadRequestMessage, ProofreadResponse, ProofreadServiceErrorCode, } from '../shared/messages/issues.ts'; +import { getStorageValues } from '../shared/utils/storage.ts'; +import { STORAGE_KEYS } from '../shared/constants.ts'; import { serializeError } from '../shared/utils/serialize.ts'; +import type { ModelSource } from '../shared/types.ts'; const DEFAULT_FALLBACK_LANGUAGE = 'en'; const proofreaderServices = new Map>(); +let apiProofreaderService: ReturnType | null = null; let activeOperations = 0; +let cachedModelSource: ModelSource = 'local'; + +export function updateModelSourceCache(source: ModelSource): void { + cachedModelSource = source; +} + const getLanguageCacheKey = (language: string): string => language.trim().toLowerCase() || DEFAULT_FALLBACK_LANGUAGE; @@ -40,6 +51,22 @@ const isUnsupportedLanguageError = (error: unknown): boolean => { return message.includes('language options') || message.includes('unsupported language'); }; +async function getOrCreateApiProofreaderService(): Promise< + ReturnType +> { + if (apiProofreaderService) { + return apiProofreaderService; + } + + const { apiConfig } = await getStorageValues([STORAGE_KEYS.API_CONFIG]); + logger.info({ model: apiConfig.selectedModel }, 'Initializing API proofreader service'); + + const provider = getApiProvider(apiConfig.type); + const proofreader = provider.createProofreader(apiConfig); + apiProofreaderService = createProofreadingService(proofreader); + return apiProofreaderService; +} + async function getOrCreateProofreaderServiceForLanguage( language: string, fallbackLanguage: string @@ -75,10 +102,16 @@ export async function handleProofreadRequest( activeOperations += 1; try { - const service = await getOrCreateProofreaderServiceForLanguage( - requestedLanguage, - normalizedFallback - ); + let service: ReturnType; + if (cachedModelSource === 'api') { + service = await getOrCreateApiProofreaderService(); + } else { + service = await getOrCreateProofreaderServiceForLanguage( + requestedLanguage, + normalizedFallback + ); + } + const result = await service.proofread(text); logger.info( { @@ -132,6 +165,8 @@ export function resetProofreaderServices(): void { } proofreaderServices.forEach((service) => service.destroy()); proofreaderServices.clear(); + apiProofreaderService?.destroy(); + apiProofreaderService = null; } export function isProofreaderProxyBusy(): boolean { diff --git a/src/options/api-config-section.ts b/src/options/api-config-section.ts new file mode 100644 index 0000000..2f38b6e --- /dev/null +++ b/src/options/api-config-section.ts @@ -0,0 +1,243 @@ +import { debounce } from '../shared/utils/debounce.ts'; +import { setStorageValue } from '../shared/utils/storage.ts'; +import { STORAGE_KEYS } from '../shared/constants.ts'; +import { requestHostPermission } from '../services/api-proofreader.ts'; +import { logger } from '../services/logger.ts'; +import type { ModelSource, ApiConfig } from '../shared/types.ts'; +import type { + ApiTestConnectionResponse, + ApiFetchModelsResponse, +} from '../shared/messages/issues.ts'; + +export interface ApiConfigSectionOptions { + initialModelSource: ModelSource; + initialApiConfig: ApiConfig; + onModelSourceChange: (source: ModelSource) => void; +} + +export interface ApiConfigSectionControls { + getCurrentModelSource(): ModelSource; + getCurrentApiConfig(): ApiConfig; + destroy(): void; +} + +export function setupApiConfigSection(options: ApiConfigSectionOptions): ApiConfigSectionControls { + let currentModelSource = options.initialModelSource; + let currentApiConfig = structuredClone(options.initialApiConfig); + const cleanups: Array<() => void> = []; + + const saveApiConfig = debounce(async (config: ApiConfig) => { + await setStorageValue(STORAGE_KEYS.API_CONFIG, structuredClone(config)).catch((err) => { + logger.error({ err }, 'Failed to save API config'); + }); + }, 500); + + const apiConfigSection = document.querySelector('#apiConfigSection'); + const localModelStatus = document.querySelector('#localModelStatus'); + const apiUrlInput = document.querySelector('#apiUrl'); + const apiKeyInput = document.querySelector('#apiKey'); + const toggleApiKeyBtn = document.querySelector('#toggleApiKey'); + const testConnectionBtn = document.querySelector('#testConnectionBtn'); + const fetchModelsBtn = document.querySelector('#fetchModelsBtn'); + const connectionStatus = document.querySelector('#connectionStatus'); + const modelSelectField = document.querySelector('#modelSelectField'); + const selectedModelSelect = document.querySelector('#selectedModel'); + + if (apiUrlInput) apiUrlInput.value = currentApiConfig.apiUrl; + if (apiKeyInput) apiKeyInput.value = currentApiConfig.apiKey; + + if (currentApiConfig.selectedModel && selectedModelSelect) { + const label = currentApiConfig.selectedModelDisplayName || currentApiConfig.selectedModel; + selectedModelSelect.innerHTML = ``; + } + + // ── Status display with timer cleanup ────────────────────────────────── + + let statusTimer: ReturnType | null = null; + + const showConnectionStatus = (msg: string, type: 'success' | 'error', autoDismissMs = 4000) => { + if (!connectionStatus) return; + if (statusTimer) clearTimeout(statusTimer); + connectionStatus.textContent = msg; + connectionStatus.className = `connection-status visible ${type}`; + statusTimer = setTimeout(() => { + connectionStatus.className = 'connection-status'; + statusTimer = null; + }, autoDismissMs); + }; + + // ── Source toggle ────────────────────────────────────────────────────── + + const sourceRadios = Array.from( + document.querySelectorAll('input[name="modelSource"]') + ); + + const onSourceChange = async (event: Event) => { + const radio = event.target as HTMLInputElement; + if (!radio.checked) return; + currentModelSource = radio.value as ModelSource; + await setStorageValue(STORAGE_KEYS.MODEL_SOURCE, currentModelSource); + apiConfigSection?.toggleAttribute('hidden', currentModelSource !== 'api'); + localModelStatus?.toggleAttribute('hidden', currentModelSource === 'api'); + options.onModelSourceChange(currentModelSource); + }; + + sourceRadios.forEach((radio) => radio.addEventListener('change', onSourceChange)); + cleanups.push(() => + sourceRadios.forEach((radio) => radio.removeEventListener('change', onSourceChange)) + ); + + // ── Form fields ──────────────────────────────────────────────────────── + + const onApiUrlInput = () => { + if (!apiUrlInput) return; + currentApiConfig = { ...currentApiConfig, apiUrl: apiUrlInput.value }; + void saveApiConfig(currentApiConfig); + }; + + const onApiKeyInput = () => { + if (!apiKeyInput) return; + currentApiConfig = { ...currentApiConfig, apiKey: apiKeyInput.value }; + void saveApiConfig(currentApiConfig); + }; + + const onToggleApiKey = () => { + if (!apiKeyInput) return; + const isPassword = apiKeyInput.type === 'password'; + apiKeyInput.type = isPassword ? 'text' : 'password'; + if (toggleApiKeyBtn) toggleApiKeyBtn.textContent = isPassword ? 'Hide' : 'Show'; + }; + + apiUrlInput?.addEventListener('input', onApiUrlInput); + apiKeyInput?.addEventListener('input', onApiKeyInput); + toggleApiKeyBtn?.addEventListener('click', onToggleApiKey); + cleanups.push(() => { + apiUrlInput?.removeEventListener('input', onApiUrlInput); + apiKeyInput?.removeEventListener('input', onApiKeyInput); + toggleApiKeyBtn?.removeEventListener('click', onToggleApiKey); + }); + + // ── Test connection ──────────────────────────────────────────────────── + + const onTestConnection = async () => { + if (!testConnectionBtn) return; + testConnectionBtn.disabled = true; + testConnectionBtn.textContent = 'Testing…'; + connectionStatus?.classList.remove('visible'); + + try { + const granted = await requestHostPermission(currentApiConfig.apiUrl); + if (!granted) { + showConnectionStatus('Host permission denied by user', 'error'); + return; + } + + const response = await new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type: 'proofly:api-test-connection' }, (res) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(res as ApiTestConnectionResponse); + } + }); + }); + showConnectionStatus( + response.ok ? 'Connected' : response.message, + response.ok ? 'success' : 'error' + ); + } catch (err) { + showConnectionStatus(err instanceof Error ? err.message : 'Connection failed', 'error'); + } finally { + testConnectionBtn.disabled = false; + testConnectionBtn.textContent = 'Test connection'; + } + }; + + testConnectionBtn?.addEventListener('click', onTestConnection); + cleanups.push(() => testConnectionBtn?.removeEventListener('click', onTestConnection)); + + // ── Fetch models ─────────────────────────────────────────────────────── + + const onFetchModels = async () => { + if (!fetchModelsBtn) return; + fetchModelsBtn.disabled = true; + fetchModelsBtn.textContent = 'Fetching…'; + + try { + const granted = await requestHostPermission(currentApiConfig.apiUrl); + if (!granted) { + showConnectionStatus('Host permission denied by user', 'error'); + return; + } + + const response = await new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type: 'proofly:api-fetch-models' }, (res) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(res as ApiFetchModelsResponse); + } + }); + }); + + if (response.ok && response.models && selectedModelSelect && modelSelectField) { + selectedModelSelect.innerHTML = response.models + .map( + (m) => + `` + ) + .join(''); + modelSelectField.removeAttribute('hidden'); + showConnectionStatus(`${response.models.length} models loaded`, 'success'); + + if (!currentApiConfig.selectedModel && response.models[0]) { + const first = response.models[0]; + currentApiConfig = { + ...currentApiConfig, + selectedModel: first.id, + selectedModelDisplayName: first.displayName, + }; + selectedModelSelect.value = first.id; + void saveApiConfig(currentApiConfig); + } + } else if (!response.ok) { + showConnectionStatus(response.message ?? 'Failed to fetch models', 'error'); + } + } catch (err) { + showConnectionStatus(err instanceof Error ? err.message : 'Failed to fetch models', 'error'); + } finally { + fetchModelsBtn.disabled = false; + fetchModelsBtn.textContent = 'Fetch models'; + } + }; + + fetchModelsBtn?.addEventListener('click', onFetchModels); + cleanups.push(() => fetchModelsBtn?.removeEventListener('click', onFetchModels)); + + // ── Model select ─────────────────────────────────────────────────────── + + const onModelChange = () => { + if (!selectedModelSelect) return; + const selectedOption = selectedModelSelect.selectedOptions[0]; + currentApiConfig = { + ...currentApiConfig, + selectedModel: selectedModelSelect.value, + selectedModelDisplayName: selectedOption?.textContent ?? selectedModelSelect.value, + }; + void saveApiConfig(currentApiConfig); + }; + + selectedModelSelect?.addEventListener('change', onModelChange); + cleanups.push(() => selectedModelSelect?.removeEventListener('change', onModelChange)); + + return { + getCurrentModelSource: () => currentModelSource, + getCurrentApiConfig: () => structuredClone(currentApiConfig), + destroy() { + if (statusTimer) clearTimeout(statusTimer); + saveApiConfig.cancel(); + cleanups.forEach((fn) => fn()); + cleanups.length = 0; + }, + }; +} diff --git a/src/options/main.ts b/src/options/main.ts index 873cb69..4f8d975 100644 --- a/src/options/main.ts +++ b/src/options/main.ts @@ -6,6 +6,7 @@ import { debounce } from '../shared/utils/debounce.ts'; import { isModelReady, getStorageValues, + getStorageValue, onStorageChange, setStorageValue, } from '../shared/utils/storage.ts'; @@ -17,6 +18,7 @@ import { createProofreader, createProofreaderAdapter, createProofreadingService, + type IProofreader, } from '../services/proofreader.ts'; import { createProofreadingController, @@ -45,6 +47,7 @@ import { type IssuesUpdateMessage, type IssuesUpdatePayload, } from '../shared/messages/issues.ts'; +import { setupApiConfigSection } from './api-config-section.ts'; const LIVE_TEST_SAMPLE_TEXT = `i love how Proofly help proofread any of my writting at web in a fully privet way, the user-experience is topnotch and immensly helpful.`; @@ -62,12 +65,45 @@ interface LiveTestAreaOptions { isAutoCorrectEnabled: () => boolean; } +function createBackgroundProofreaderAdapter(): IProofreader { + return { + async proofread(text: string) { + const requestId = `options-${Date.now()}-${Math.random().toString(36).slice(2)}`; + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + { + type: 'proofly:proofread-request', + payload: { requestId, text, language: 'en', fallbackLanguage: 'en' }, + }, + (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (response?.ok) { + resolve(response.result); + } else { + reject(new Error(response?.error?.message ?? 'Proofread request failed')); + } + } + ); + }); + }, + destroy() {}, + }; +} + async function initOptions() { const app = document.querySelector('#app')!; - await ensureProofreaderModelReady(); + const modelSource = await getStorageValue(STORAGE_KEYS.MODEL_SOURCE); + const isApiMode = modelSource === 'api'; + + if (!isApiMode) { + await ensureProofreaderModelReady(); + } - const modelReady = await isModelReady(); + const modelReady = isApiMode ? true : await isModelReady(); if (!modelReady) { app.innerHTML = ` @@ -82,11 +118,44 @@ async function initOptions() {
+
+

AI Source

+

Choose how Proofly accesses the AI model for proofreading.

+
+
+ + +
+
+
`; + const welcomeSourceRadios = Array.from( + app.querySelectorAll('input[name="modelSource"]') + ); + welcomeSourceRadios.forEach((radio) => { + radio.addEventListener('change', async () => { + if (!radio.checked || radio.value !== 'api') return; + await setStorageValue(STORAGE_KEYS.MODEL_SOURCE, 'api'); + location.reload(); + }); + }); + const downloader = app.querySelector('proofly-model-downloader'); downloader?.addEventListener('download-complete', () => { location.reload(); @@ -266,8 +335,67 @@ async function initOptions() {
+

AI Source

+

Choose how Proofly accesses the AI model for proofreading.

+
+
+ + +
+
+
+ +
+

API Configuration

+

Connect Proofly to an external AI API.

+
+
+
+ + +
+
+ + + Base URL for the Anthropic API +
+
+ +
+ + +
+
+
+ + + +
+ +
+
+
+ +

Model Status

-

Review the status of AI models.

+

Review the status of the local AI model.

@@ -701,13 +829,33 @@ async function initOptions() { updateShortcutDisplay(); }); + // ── AI Source + API Configuration (delegated) ────────────────────────── + const apiConfig = await getStorageValue(STORAGE_KEYS.API_CONFIG); + setupApiConfigSection({ + initialModelSource: modelSource, + initialApiConfig: apiConfig, + onModelSourceChange: () => {}, + }); + // Setup live test area proofreading + const liveTestProofreader: IProofreader | null = isApiMode + ? createBackgroundProofreaderAdapter() + : await (async () => { + try { + const proofreader = await createProofreader(); + return createProofreaderAdapter(proofreader); + } catch { + return null; + } + })(); + liveTestControls = await setupLiveTestArea( currentEnabledCorrectionTypes, correctionColorConfig, { isAutoCorrectEnabled: () => autoCorrectEnabled, - } + }, + liveTestProofreader ); // Handle apply issue and apply all messages from sidepanel @@ -754,7 +902,8 @@ async function initOptions() { async function setupLiveTestArea( initialEnabledTypes: CorrectionTypeKey[], initialColorConfig: CorrectionColorConfig, - options: LiveTestAreaOptions + options: LiveTestAreaOptions, + proofreaderAdapter: IProofreader | null = null ): Promise { const editor = document.getElementById('liveTestEditor'); if (!editor) return null; @@ -778,13 +927,17 @@ async function setupLiveTestArea( let proofreaderService: ReturnType | null = null; - try { - const proofreader = await createProofreader(); - const adapter = createProofreaderAdapter(proofreader); - proofreaderService = createProofreadingService(adapter); - } catch (error) { - logger.error({ error }, 'Failed to initialize proofreader for live test area'); - return null; + if (proofreaderAdapter) { + proofreaderService = createProofreadingService(proofreaderAdapter); + } else { + try { + const proofreader = await createProofreader(); + const adapter = createProofreaderAdapter(proofreader); + proofreaderService = createProofreadingService(adapter); + } catch (error) { + logger.error({ error }, 'Failed to initialize proofreader for live test area'); + return null; + } } const reportProofreaderBusy = (busy: boolean) => { diff --git a/src/options/style.css b/src/options/style.css index fbbc246..0d7518f 100644 --- a/src/options/style.css +++ b/src/options/style.css @@ -28,6 +28,12 @@ --option-color-text-tertiary: #4b5563; --option-color-focus-border: #4f46e5; --option-color-focus-ring: rgba(79, 70, 229, 0.1); + --option-color-success-bg: #ecfdf5; + --option-color-success-text: #065f46; + --option-color-success-border: #a7f3d0; + --option-color-error-bg: #fef2f2; + --option-color-error-text: #991b1b; + --option-color-error-border: #fecaca; } body { @@ -521,3 +527,203 @@ prfly-checkbox.option-card--single::part(control) { font-size: 14px; line-height: 1.5; } + +/* ─── Model Source Selector ─────────────────────────────────────────────── */ + +.source-selector { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.source-option { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--option-color-card-border); + border-radius: 0.5rem; + background: var(--option-color-card-bg); + cursor: pointer; + transition: + border-color 0.2s, + box-shadow 0.2s; +} + +.source-option:hover, +.source-option:has(input:checked) { + border-color: var(--option-color-button-border); + box-shadow: 0 0 0 3px var(--option-color-card-ring); +} + +.source-option input[type='radio'] { + margin-top: 2px; + accent-color: var(--option-color-button-bg); + flex-shrink: 0; +} + +.source-option-content { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.source-option-content strong { + font-size: 0.9rem; + color: var(--option-color-text-primary); +} + +.source-option-content p { + margin: 0; + font-size: 0.8rem; + color: var(--option-color-text-secondary); +} + +/* ─── API Configuration Form ────────────────────────────────────────────── */ + +.api-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.form-field label { + font-size: 0.85rem; + font-weight: 500; + color: var(--option-color-text-primary); +} + +.form-field .field-hint { + font-size: 0.75rem; + color: var(--option-color-text-secondary); +} + +.form-field input[type='text'], +.form-field input[type='url'], +.form-field input[type='password'], +.form-field select { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--option-color-card-border); + border-radius: 0.375rem; + font-size: 0.875rem; + color: var(--option-color-text-primary); + background: var(--option-color-card-bg); + box-sizing: border-box; + transition: + border-color 0.15s, + box-shadow 0.15s; +} + +.form-field input:focus, +.form-field select:focus { + outline: none; + border-color: var(--option-color-focus-border); + box-shadow: 0 0 0 3px var(--option-color-focus-ring); +} + +.api-key-wrapper { + display: flex; + gap: 0.5rem; +} + +.api-key-wrapper input { + flex: 1; + min-width: 0; +} + +.btn-icon { + padding: 0.5rem 0.75rem; + border: 1px solid var(--option-color-card-border); + border-radius: 0.375rem; + background: var(--option-color-surface-muted); + font-size: 0.8rem; + color: var(--option-color-text-tertiary); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: background 0.15s; +} + +.btn-icon:hover { + background: var(--option-color-neutral-bg); +} + +.api-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.btn-primary { + padding: 0.5rem 1rem; + border: 1px solid var(--option-color-button-border); + border-radius: 0.375rem; + background: var(--option-color-button-bg); + color: var(--option-color-button-text); + font-size: 0.875rem; + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s; +} + +.btn-primary:hover:not(:disabled) { + background: var(--option-color-button-hover-bg); + border-color: var(--option-color-button-hover-border); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-secondary { + padding: 0.5rem 1rem; + border: 1px solid var(--option-color-card-border); + border-radius: 0.375rem; + background: var(--option-color-surface-muted); + color: var(--option-color-text-muted); + font-size: 0.875rem; + cursor: pointer; + transition: background 0.15s; +} + +.btn-secondary:hover:not(:disabled) { + background: var(--option-color-neutral-bg); +} + +.btn-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.connection-status { + font-size: 0.8rem; + padding: 0.3rem 0.6rem; + border-radius: 0.375rem; + display: none; +} + +.connection-status.visible { + display: inline-block; +} + +.connection-status.success { + background: var(--option-color-success-bg); + color: var(--option-color-success-text); + border: 1px solid var(--option-color-success-border); +} + +.connection-status.error { + background: var(--option-color-error-bg); + color: var(--option-color-error-text); + border: 1px solid var(--option-color-error-border); +} diff --git a/src/services/api-proofreader.ts b/src/services/api-proofreader.ts new file mode 100644 index 0000000..d906f87 --- /dev/null +++ b/src/services/api-proofreader.ts @@ -0,0 +1,316 @@ +import type { IProofreader } from './proofreader.ts'; +import type { + ProofreadResult, + ProofreadCorrection, + CorrectionType, + ApiConfig, + ApiType, +} from '../shared/types.ts'; +import { logger } from './logger.ts'; + +// ─── Shared utilities ────────────────────────────────────────────────────── + +const DEFAULT_TIMEOUT_MS = 30_000; + +function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = DEFAULT_TIMEOUT_MS +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer)); +} + +async function ensureHostPermission(apiUrl: string): Promise { + const origin = new URL(apiUrl).origin + '/*'; + const granted = await chrome.permissions.contains({ origins: [origin] }); + if (!granted) { + throw new Error( + `Permission not granted for ${new URL(apiUrl).origin}. Please test the connection in Settings first.` + ); + } +} + +// ─── Provider interface ──────────────────────────────────────────────────── + +export interface ApiTestResult { + ok: boolean; + message: string; +} + +export interface ApiModel { + id: string; + displayName: string; +} + +export interface ApiProvider { + testConnection(config: ApiConfig): Promise; + fetchModels(config: ApiConfig): Promise; + createProofreader(config: ApiConfig): IProofreader; +} + +// ─── Claude provider ─────────────────────────────────────────────────────── + +const ANTHROPIC_API_VERSION = '2023-06-01'; + +const PROOFREAD_TOOL = { + name: 'report_corrections', + description: + 'Report all proofreading corrections found in the text. Each correction must reference the exact original text from the input.', + input_schema: { + type: 'object' as const, + properties: { + corrections: { + type: 'array', + items: { + type: 'object', + properties: { + originalText: { + type: 'string', + description: 'The exact original substring from the input that contains the error', + }, + correctedText: { + type: 'string', + description: 'The corrected replacement text', + }, + type: { + type: 'string', + enum: [ + 'spelling', + 'grammar', + 'punctuation', + 'capitalization', + 'preposition', + 'missing-words', + ], + description: 'The error category', + }, + explanation: { + type: 'string', + description: 'Brief explanation of the correction', + }, + }, + required: ['originalText', 'correctedText'], + }, + }, + }, + required: ['corrections'], + }, +}; + +interface ClaudeRawCorrection { + originalText: string; + correctedText: string; + type?: string; + explanation?: string; +} + +function resolvePositions( + text: string, + claudeCorrections: ClaudeRawCorrection[] +): ProofreadCorrection[] { + const corrections: ProofreadCorrection[] = []; + const usedRanges: Array<[number, number]> = []; + + for (const raw of claudeCorrections) { + const { originalText, correctedText, type, explanation } = raw; + if (!originalText || originalText === correctedText) continue; + + let searchFrom = 0; + let idx = -1; + while ((idx = text.indexOf(originalText, searchFrom)) !== -1) { + const end = idx + originalText.length; + const overlaps = usedRanges.some(([s, e]) => idx < e && end > s); + if (!overlaps) break; + searchFrom = idx + 1; + } + if (idx === -1) continue; + + usedRanges.push([idx, idx + originalText.length]); + corrections.push({ + startIndex: idx, + endIndex: idx + originalText.length, + correction: correctedText, + type: type as CorrectionType | undefined, + explanation, + }); + } + + corrections.sort((a, b) => a.startIndex - b.startIndex); + const filtered: ProofreadCorrection[] = []; + let lastEnd = -1; + for (const c of corrections) { + if (c.startIndex >= lastEnd) { + filtered.push(c); + lastEnd = c.endIndex; + } + } + + return filtered; +} + +function buildClaudeHeaders(apiKey: string): Record { + const headers: Record = { + 'content-type': 'application/json', + 'anthropic-version': ANTHROPIC_API_VERSION, + }; + if (apiKey) { + headers['x-api-key'] = apiKey; + } + return headers; +} + +function parseApiError(response: Response, errorData: unknown): string { + return ( + (errorData as { error?: { message?: string } })?.error?.message ?? + `HTTP ${response.status}: ${response.statusText}` + ); +} + +async function testClaudeConnection(config: ApiConfig): Promise { + const { apiUrl, apiKey } = config; + + try { + const response = await fetchWithTimeout(`${apiUrl}/v1/models`, { + headers: buildClaudeHeaders(apiKey), + }); + + if (response.ok) { + return { ok: true, message: 'Connection successful' }; + } + + const errorData = await response.json().catch(() => ({})); + return { ok: false, message: parseApiError(response, errorData) }; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return { ok: false, message: 'Connection timed out' }; + } + const msg = error instanceof Error ? error.message : 'Connection failed'; + return { ok: false, message: msg }; + } +} + +async function fetchClaudeModels(config: ApiConfig): Promise { + const { apiUrl, apiKey } = config; + + const response = await fetchWithTimeout(`${apiUrl}/v1/models`, { + headers: buildClaudeHeaders(apiKey), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseApiError(response, errorData)); + } + + const data = await response.json(); + const models = (data.data ?? []) as Array<{ + id: string; + display_name?: string; + }>; + return models.map((m) => ({ + id: m.id, + displayName: m.display_name ?? m.id, + })); +} + +function createClaudeApiProofreader(config: ApiConfig): IProofreader { + return { + async proofread(text: string): Promise { + const { apiUrl, apiKey, selectedModel } = config; + + if (!selectedModel) { + throw new Error('No AI model selected. Please choose a model in settings.'); + } + + await ensureHostPermission(apiUrl); + + const response = await fetchWithTimeout(`${apiUrl}/v1/messages`, { + method: 'POST', + headers: buildClaudeHeaders(apiKey), + body: JSON.stringify({ + model: selectedModel, + max_tokens: 2048, + system: + 'You are a precise proofreading assistant. Detect the language of the input text automatically and identify grammar, spelling, punctuation, capitalization, preposition, and missing-word errors. Use the provided tool to report corrections. Only report real errors — not style preferences.', + messages: [ + { + role: 'user', + content: `Proofread the following text and report all errors:\n\n${text}`, + }, + ], + tools: [PROOFREAD_TOOL], + tool_choice: { type: 'any' }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseApiError(response, errorData)); + } + + const data = await response.json(); + + const toolUse = ( + data.content as Array<{ + type: string; + name?: string; + input?: unknown; + }> + )?.find((block) => block.type === 'tool_use' && block.name === 'report_corrections'); + + if (!toolUse) { + return { correctedInput: text, corrections: [] }; + } + + const rawCorrections: ClaudeRawCorrection[] = + (toolUse.input as { corrections?: ClaudeRawCorrection[] })?.corrections ?? []; + + const corrections = resolvePositions(text, rawCorrections); + + let correctedInput = text; + const sorted = [...corrections].sort((a, b) => b.startIndex - a.startIndex); + for (const correction of sorted) { + correctedInput = + correctedInput.slice(0, correction.startIndex) + + correction.correction + + correctedInput.slice(correction.endIndex); + } + + logger.info({ corrections: corrections.length }, 'Claude API proofreading completed'); + + return { correctedInput, corrections }; + }, + + destroy() {}, + }; +} + +const claudeProvider: ApiProvider = { + testConnection: testClaudeConnection, + fetchModels: fetchClaudeModels, + createProofreader: createClaudeApiProofreader, +}; + +// ─── Provider registry ───────────────────────────────────────────────────── + +const providers = new Map([['claude', claudeProvider]]); + +export function getApiProvider(type: ApiType): ApiProvider { + const provider = providers.get(type); + if (!provider) { + throw new Error(`Unsupported API provider: ${type}`); + } + return provider; +} + +export async function requestHostPermission(apiUrl: string): Promise { + try { + const origin = new URL(apiUrl).origin + '/*'; + const has = await chrome.permissions.contains({ origins: [origin] }); + if (has) return true; + return chrome.permissions.request({ origins: [origin] }); + } catch { + return false; + } +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 64cdbf1..acb45cc 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,4 +1,4 @@ -import type { UnderlineStyle } from './types.ts'; +import type { UnderlineStyle, ModelSource, ApiConfig } from './types.ts'; import { ALL_CORRECTION_TYPES, getDefaultCorrectionColorConfig } from './utils/correction-types.ts'; /** @@ -14,6 +14,8 @@ export const STORAGE_KEYS = { CORRECTION_COLORS: 'correctionColors', PROOFREAD_SHORTCUT: 'proofreadShortcut', AUTOFIX_ON_DOUBLE_CLICK: 'autofixOnDoubleClick', + MODEL_SOURCE: 'modelSource', + API_CONFIG: 'apiConfig', } as const; /** @@ -29,4 +31,12 @@ export const STORAGE_DEFAULTS = { [STORAGE_KEYS.CORRECTION_COLORS]: getDefaultCorrectionColorConfig(), [STORAGE_KEYS.PROOFREAD_SHORTCUT]: 'Mod+Shift+P', [STORAGE_KEYS.AUTOFIX_ON_DOUBLE_CLICK]: false, + [STORAGE_KEYS.MODEL_SOURCE]: 'local' as ModelSource, + [STORAGE_KEYS.API_CONFIG]: { + type: 'claude', + apiUrl: 'https://api.anthropic.com', + apiKey: '', + selectedModel: '', + selectedModelDisplayName: '', + } as ApiConfig, } as const; diff --git a/src/shared/messages/issues.ts b/src/shared/messages/issues.ts index 1705a17..fff8636 100644 --- a/src/shared/messages/issues.ts +++ b/src/shared/messages/issues.ts @@ -167,6 +167,25 @@ export interface DevOpenSidepanelMessage { }; } +export interface ApiTestConnectionMessage { + type: 'proofly:api-test-connection'; +} + +export interface ApiTestConnectionResponse { + ok: boolean; + message: string; +} + +export interface ApiFetchModelsMessage { + type: 'proofly:api-fetch-models'; +} + +export interface ApiFetchModelsResponse { + ok: boolean; + models?: Array<{ id: string; displayName: string }>; + message?: string; +} + export type ProoflyMessage = | IssuesUpdateMessage | ApplyIssueMessage @@ -180,7 +199,9 @@ export type ProoflyMessage = | ProofreadRequestMessage | ProofreaderBusyStateRequestMessage | ProofreaderBusyStateResponseMessage - | DevOpenSidepanelMessage; + | DevOpenSidepanelMessage + | ApiTestConnectionMessage + | ApiFetchModelsMessage; export function toSidepanelIssue( elementId: string, diff --git a/src/shared/types.ts b/src/shared/types.ts index 503a89d..5af1ed2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,5 +1,17 @@ export type UnderlineStyle = 'solid' | 'wavy' | 'dotted'; +export type ModelSource = 'local' | 'api'; + +export type ApiType = 'claude' | 'openai-compatible'; + +export interface ApiConfig { + type: ApiType; + apiUrl: string; + apiKey: string; + selectedModel: string; + selectedModelDisplayName: string; +} + export type CorrectionType = | 'spelling' | 'grammar' diff --git a/src/shared/utils/storage.ts b/src/shared/utils/storage.ts index e03fa0d..b4e55b5 100644 --- a/src/shared/utils/storage.ts +++ b/src/shared/utils/storage.ts @@ -7,7 +7,7 @@ */ import { STORAGE_KEYS, STORAGE_DEFAULTS } from '../constants.ts'; -import type { UnderlineStyle } from '../types.ts'; +import type { UnderlineStyle, ModelSource, ApiConfig } from '../types.ts'; import type { CorrectionColorConfig, CorrectionTypeKey } from './correction-types.ts'; export interface StorageData { @@ -20,6 +20,8 @@ export interface StorageData { [STORAGE_KEYS.CORRECTION_COLORS]: CorrectionColorConfig; [STORAGE_KEYS.PROOFREAD_SHORTCUT]: string; [STORAGE_KEYS.AUTOFIX_ON_DOUBLE_CLICK]: boolean; + [STORAGE_KEYS.MODEL_SOURCE]: ModelSource; + [STORAGE_KEYS.API_CONFIG]: ApiConfig; } /** @@ -32,6 +34,8 @@ const SYNC_KEYS = [ STORAGE_KEYS.CORRECTION_COLORS, STORAGE_KEYS.PROOFREAD_SHORTCUT, STORAGE_KEYS.AUTOFIX_ON_DOUBLE_CLICK, + STORAGE_KEYS.MODEL_SOURCE, + // NOTE: API_CONFIG is intentionally in local storage (contains API key) ] as const; /** From e48960871da76a994b59b7989c87cf85a6e9c0e2 Mon Sep 17 00:00:00 2001 From: Nathan Redblur Date: Fri, 27 Feb 2026 19:23:59 -0500 Subject: [PATCH 2/4] feat: expand API integration for additional proofreading services * Added support for Gemini and OpenAI-compatible APIs in the configuration options. * Enhanced the API configuration UI to include new options for selecting API types. * Implemented functionality to handle API-specific connection testing and model fetching. * Updated the proofreading logic to accommodate different API response formats and correction handling. * Refactored utility functions for improved code organization and reusability. --- src/options/api-config-section.ts | 57 +++- src/options/main.ts | 12 +- src/services/api-proofreader.ts | 310 +------------------ src/services/providers/api-provider-utils.ts | 158 ++++++++++ src/services/providers/claude.ts | 151 +++++++++ src/services/providers/gemini.ts | 168 ++++++++++ src/services/providers/openai-compatible.ts | 159 ++++++++++ src/shared/types.ts | 2 +- 8 files changed, 713 insertions(+), 304 deletions(-) create mode 100644 src/services/providers/api-provider-utils.ts create mode 100644 src/services/providers/claude.ts create mode 100644 src/services/providers/gemini.ts create mode 100644 src/services/providers/openai-compatible.ts diff --git a/src/options/api-config-section.ts b/src/options/api-config-section.ts index 2f38b6e..4949f28 100644 --- a/src/options/api-config-section.ts +++ b/src/options/api-config-section.ts @@ -3,12 +3,30 @@ import { setStorageValue } from '../shared/utils/storage.ts'; import { STORAGE_KEYS } from '../shared/constants.ts'; import { requestHostPermission } from '../services/api-proofreader.ts'; import { logger } from '../services/logger.ts'; -import type { ModelSource, ApiConfig } from '../shared/types.ts'; +import type { ModelSource, ApiConfig, ApiType } from '../shared/types.ts'; import type { ApiTestConnectionResponse, ApiFetchModelsResponse, } from '../shared/messages/issues.ts'; +const API_TYPE_DEFAULTS: Record = { + claude: { + url: 'https://api.anthropic.com', + placeholder: 'sk-ant-...', + keyHint: 'Anthropic API key', + }, + gemini: { + url: 'https://generativelanguage.googleapis.com', + placeholder: 'AIza...', + keyHint: 'Google AI API key', + }, + 'openai-compatible': { + url: 'https://api.openai.com', + placeholder: 'sk-...', + keyHint: 'API key', + }, +}; + export interface ApiConfigSectionOptions { initialModelSource: ModelSource; initialApiConfig: ApiConfig; @@ -34,7 +52,9 @@ export function setupApiConfigSection(options: ApiConfigSectionOptions): ApiConf const apiConfigSection = document.querySelector('#apiConfigSection'); const localModelStatus = document.querySelector('#localModelStatus'); + const apiTypeSelect = document.querySelector('#apiType'); const apiUrlInput = document.querySelector('#apiUrl'); + const apiUrlHint = document.querySelector('#apiUrlHint'); const apiKeyInput = document.querySelector('#apiKey'); const toggleApiKeyBtn = document.querySelector('#toggleApiKey'); const testConnectionBtn = document.querySelector('#testConnectionBtn'); @@ -43,12 +63,24 @@ export function setupApiConfigSection(options: ApiConfigSectionOptions): ApiConf const modelSelectField = document.querySelector('#modelSelectField'); const selectedModelSelect = document.querySelector('#selectedModel'); + const applyTypeDefaults = (type: ApiType) => { + const defaults = API_TYPE_DEFAULTS[type]; + if (!defaults) return; + if (apiUrlInput) apiUrlInput.placeholder = defaults.url; + if (apiKeyInput) apiKeyInput.placeholder = defaults.placeholder; + if (apiUrlHint) + apiUrlHint.textContent = `Base URL for the ${type === 'claude' ? 'Anthropic' : type === 'gemini' ? 'Google AI' : ''} API`; + }; + + if (apiTypeSelect) apiTypeSelect.value = currentApiConfig.type; if (apiUrlInput) apiUrlInput.value = currentApiConfig.apiUrl; if (apiKeyInput) apiKeyInput.value = currentApiConfig.apiKey; + applyTypeDefaults(currentApiConfig.type); if (currentApiConfig.selectedModel && selectedModelSelect) { const label = currentApiConfig.selectedModelDisplayName || currentApiConfig.selectedModel; selectedModelSelect.innerHTML = ``; + modelSelectField?.removeAttribute('hidden'); } // ── Status display with timer cleanup ────────────────────────────────── @@ -87,6 +119,29 @@ export function setupApiConfigSection(options: ApiConfigSectionOptions): ApiConf sourceRadios.forEach((radio) => radio.removeEventListener('change', onSourceChange)) ); + // ── API type change ────────────────────────────────────────────────── + + const onApiTypeChange = () => { + if (!apiTypeSelect) return; + const newType = apiTypeSelect.value as ApiType; + const defaults = API_TYPE_DEFAULTS[newType]; + currentApiConfig = { + ...currentApiConfig, + type: newType, + apiUrl: defaults?.url ?? currentApiConfig.apiUrl, + selectedModel: '', + selectedModelDisplayName: '', + }; + if (apiUrlInput) apiUrlInput.value = currentApiConfig.apiUrl; + applyTypeDefaults(newType); + modelSelectField?.setAttribute('hidden', ''); + if (selectedModelSelect) selectedModelSelect.innerHTML = ''; + void saveApiConfig(currentApiConfig); + }; + + apiTypeSelect?.addEventListener('change', onApiTypeChange); + cleanups.push(() => apiTypeSelect?.removeEventListener('change', onApiTypeChange)); + // ── Form fields ──────────────────────────────────────────────────────── const onApiUrlInput = () => { diff --git a/src/options/main.ts b/src/options/main.ts index 4f8d975..64387da 100644 --- a/src/options/main.ts +++ b/src/options/main.ts @@ -134,7 +134,7 @@ async function initOptions() {
API -

Uses an external AI API (e.g. Claude by Anthropic).

+

Uses an external AI API (e.g. Claude, Gemini).

@@ -350,7 +350,7 @@ async function initOptions() {
API -

Uses an external AI API (e.g. Claude by Anthropic).

+

Uses an external AI API (e.g. Claude, Gemini).

@@ -365,16 +365,18 @@ async function initOptions() {
- Base URL for the Anthropic API + Base URL for the API
- +
diff --git a/src/services/api-proofreader.ts b/src/services/api-proofreader.ts index d906f87..4bdedab 100644 --- a/src/services/api-proofreader.ts +++ b/src/services/api-proofreader.ts @@ -1,300 +1,16 @@ -import type { IProofreader } from './proofreader.ts'; -import type { - ProofreadResult, - ProofreadCorrection, - CorrectionType, - ApiConfig, - ApiType, -} from '../shared/types.ts'; -import { logger } from './logger.ts'; - -// ─── Shared utilities ────────────────────────────────────────────────────── - -const DEFAULT_TIMEOUT_MS = 30_000; - -function fetchWithTimeout( - url: string, - init: RequestInit, - timeoutMs = DEFAULT_TIMEOUT_MS -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer)); -} - -async function ensureHostPermission(apiUrl: string): Promise { - const origin = new URL(apiUrl).origin + '/*'; - const granted = await chrome.permissions.contains({ origins: [origin] }); - if (!granted) { - throw new Error( - `Permission not granted for ${new URL(apiUrl).origin}. Please test the connection in Settings first.` - ); - } -} - -// ─── Provider interface ──────────────────────────────────────────────────── - -export interface ApiTestResult { - ok: boolean; - message: string; -} - -export interface ApiModel { - id: string; - displayName: string; -} - -export interface ApiProvider { - testConnection(config: ApiConfig): Promise; - fetchModels(config: ApiConfig): Promise; - createProofreader(config: ApiConfig): IProofreader; -} - -// ─── Claude provider ─────────────────────────────────────────────────────── - -const ANTHROPIC_API_VERSION = '2023-06-01'; - -const PROOFREAD_TOOL = { - name: 'report_corrections', - description: - 'Report all proofreading corrections found in the text. Each correction must reference the exact original text from the input.', - input_schema: { - type: 'object' as const, - properties: { - corrections: { - type: 'array', - items: { - type: 'object', - properties: { - originalText: { - type: 'string', - description: 'The exact original substring from the input that contains the error', - }, - correctedText: { - type: 'string', - description: 'The corrected replacement text', - }, - type: { - type: 'string', - enum: [ - 'spelling', - 'grammar', - 'punctuation', - 'capitalization', - 'preposition', - 'missing-words', - ], - description: 'The error category', - }, - explanation: { - type: 'string', - description: 'Brief explanation of the correction', - }, - }, - required: ['originalText', 'correctedText'], - }, - }, - }, - required: ['corrections'], - }, -}; - -interface ClaudeRawCorrection { - originalText: string; - correctedText: string; - type?: string; - explanation?: string; -} - -function resolvePositions( - text: string, - claudeCorrections: ClaudeRawCorrection[] -): ProofreadCorrection[] { - const corrections: ProofreadCorrection[] = []; - const usedRanges: Array<[number, number]> = []; - - for (const raw of claudeCorrections) { - const { originalText, correctedText, type, explanation } = raw; - if (!originalText || originalText === correctedText) continue; - - let searchFrom = 0; - let idx = -1; - while ((idx = text.indexOf(originalText, searchFrom)) !== -1) { - const end = idx + originalText.length; - const overlaps = usedRanges.some(([s, e]) => idx < e && end > s); - if (!overlaps) break; - searchFrom = idx + 1; - } - if (idx === -1) continue; - - usedRanges.push([idx, idx + originalText.length]); - corrections.push({ - startIndex: idx, - endIndex: idx + originalText.length, - correction: correctedText, - type: type as CorrectionType | undefined, - explanation, - }); - } - - corrections.sort((a, b) => a.startIndex - b.startIndex); - const filtered: ProofreadCorrection[] = []; - let lastEnd = -1; - for (const c of corrections) { - if (c.startIndex >= lastEnd) { - filtered.push(c); - lastEnd = c.endIndex; - } - } - - return filtered; -} - -function buildClaudeHeaders(apiKey: string): Record { - const headers: Record = { - 'content-type': 'application/json', - 'anthropic-version': ANTHROPIC_API_VERSION, - }; - if (apiKey) { - headers['x-api-key'] = apiKey; - } - return headers; -} - -function parseApiError(response: Response, errorData: unknown): string { - return ( - (errorData as { error?: { message?: string } })?.error?.message ?? - `HTTP ${response.status}: ${response.statusText}` - ); -} - -async function testClaudeConnection(config: ApiConfig): Promise { - const { apiUrl, apiKey } = config; - - try { - const response = await fetchWithTimeout(`${apiUrl}/v1/models`, { - headers: buildClaudeHeaders(apiKey), - }); - - if (response.ok) { - return { ok: true, message: 'Connection successful' }; - } - - const errorData = await response.json().catch(() => ({})); - return { ok: false, message: parseApiError(response, errorData) }; - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - return { ok: false, message: 'Connection timed out' }; - } - const msg = error instanceof Error ? error.message : 'Connection failed'; - return { ok: false, message: msg }; - } -} - -async function fetchClaudeModels(config: ApiConfig): Promise { - const { apiUrl, apiKey } = config; - - const response = await fetchWithTimeout(`${apiUrl}/v1/models`, { - headers: buildClaudeHeaders(apiKey), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(parseApiError(response, errorData)); - } - - const data = await response.json(); - const models = (data.data ?? []) as Array<{ - id: string; - display_name?: string; - }>; - return models.map((m) => ({ - id: m.id, - displayName: m.display_name ?? m.id, - })); -} - -function createClaudeApiProofreader(config: ApiConfig): IProofreader { - return { - async proofread(text: string): Promise { - const { apiUrl, apiKey, selectedModel } = config; - - if (!selectedModel) { - throw new Error('No AI model selected. Please choose a model in settings.'); - } - - await ensureHostPermission(apiUrl); - - const response = await fetchWithTimeout(`${apiUrl}/v1/messages`, { - method: 'POST', - headers: buildClaudeHeaders(apiKey), - body: JSON.stringify({ - model: selectedModel, - max_tokens: 2048, - system: - 'You are a precise proofreading assistant. Detect the language of the input text automatically and identify grammar, spelling, punctuation, capitalization, preposition, and missing-word errors. Use the provided tool to report corrections. Only report real errors — not style preferences.', - messages: [ - { - role: 'user', - content: `Proofread the following text and report all errors:\n\n${text}`, - }, - ], - tools: [PROOFREAD_TOOL], - tool_choice: { type: 'any' }, - }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(parseApiError(response, errorData)); - } - - const data = await response.json(); - - const toolUse = ( - data.content as Array<{ - type: string; - name?: string; - input?: unknown; - }> - )?.find((block) => block.type === 'tool_use' && block.name === 'report_corrections'); - - if (!toolUse) { - return { correctedInput: text, corrections: [] }; - } - - const rawCorrections: ClaudeRawCorrection[] = - (toolUse.input as { corrections?: ClaudeRawCorrection[] })?.corrections ?? []; - - const corrections = resolvePositions(text, rawCorrections); - - let correctedInput = text; - const sorted = [...corrections].sort((a, b) => b.startIndex - a.startIndex); - for (const correction of sorted) { - correctedInput = - correctedInput.slice(0, correction.startIndex) + - correction.correction + - correctedInput.slice(correction.endIndex); - } - - logger.info({ corrections: corrections.length }, 'Claude API proofreading completed'); - - return { correctedInput, corrections }; - }, - - destroy() {}, - }; -} - -const claudeProvider: ApiProvider = { - testConnection: testClaudeConnection, - fetchModels: fetchClaudeModels, - createProofreader: createClaudeApiProofreader, -}; - -// ─── Provider registry ───────────────────────────────────────────────────── - -const providers = new Map([['claude', claudeProvider]]); +import type { ApiType } from '../shared/types.ts'; +import type { ApiProvider } from './providers/api-provider-utils.ts'; +import { claudeProvider } from './providers/claude.ts'; +import { geminiProvider } from './providers/gemini.ts'; +import { openaiCompatibleProvider } from './providers/openai-compatible.ts'; + +export type { ApiTestResult, ApiModel, ApiProvider } from './providers/api-provider-utils.ts'; + +const providers = new Map([ + ['claude', claudeProvider], + ['gemini', geminiProvider], + ['openai-compatible', openaiCompatibleProvider], +]); export function getApiProvider(type: ApiType): ApiProvider { const provider = providers.get(type); diff --git a/src/services/providers/api-provider-utils.ts b/src/services/providers/api-provider-utils.ts new file mode 100644 index 0000000..f55ff1d --- /dev/null +++ b/src/services/providers/api-provider-utils.ts @@ -0,0 +1,158 @@ +import type { IProofreader } from '../proofreader.ts'; +import type { + ProofreadCorrection, + ProofreadResult, + CorrectionType, + ApiConfig, +} from '../../shared/types.ts'; +import { logger } from '../logger.ts'; + +export interface ApiTestResult { + ok: boolean; + message: string; +} + +export interface ApiModel { + id: string; + displayName: string; +} + +export interface ApiProvider { + testConnection(config: ApiConfig): Promise; + fetchModels(config: ApiConfig): Promise; + createProofreader(config: ApiConfig): IProofreader; +} + +export const DEFAULT_TIMEOUT_MS = 30_000; + +export function normalizeBaseUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +export const SYSTEM_PROMPT = + 'You are a precise proofreading assistant. Detect the language of the input text automatically and identify grammar, spelling, punctuation, capitalization, preposition, and missing-word errors. Use the provided tool to report corrections. Only report real errors — not style preferences.'; + +export function buildUserPrompt(text: string): string { + return `Proofread the following text and report all errors:\n\n${text}`; +} + +export interface RawCorrection { + originalText: string; + correctedText: string; + type?: string; + explanation?: string; +} + +export function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = DEFAULT_TIMEOUT_MS +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer)); +} + +export async function ensureHostPermission(apiUrl: string): Promise { + const origin = new URL(apiUrl).origin + '/*'; + const granted = await chrome.permissions.contains({ origins: [origin] }); + if (!granted) { + throw new Error( + `Permission not granted for ${new URL(apiUrl).origin}. Please test the connection in Settings first.` + ); + } +} + +export function parseErrorBody(response: Response, errorData: unknown): string { + return ( + (errorData as { error?: { message?: string } })?.error?.message ?? + `HTTP ${response.status}: ${response.statusText}` + ); +} + +export function resolvePositions( + text: string, + rawCorrections: RawCorrection[] +): ProofreadCorrection[] { + const corrections: ProofreadCorrection[] = []; + const usedRanges: Array<[number, number]> = []; + + for (const raw of rawCorrections) { + const { originalText, correctedText, type, explanation } = raw; + if (!originalText || originalText === correctedText) continue; + + let searchFrom = 0; + let idx = -1; + while ((idx = text.indexOf(originalText, searchFrom)) !== -1) { + const end = idx + originalText.length; + const overlaps = usedRanges.some(([s, e]) => idx < e && end > s); + if (!overlaps) break; + searchFrom = idx + 1; + } + if (idx === -1) continue; + + usedRanges.push([idx, idx + originalText.length]); + corrections.push({ + startIndex: idx, + endIndex: idx + originalText.length, + correction: correctedText, + type: type as CorrectionType | undefined, + explanation, + }); + } + + corrections.sort((a, b) => a.startIndex - b.startIndex); + const filtered: ProofreadCorrection[] = []; + let lastEnd = -1; + for (const c of corrections) { + if (c.startIndex >= lastEnd) { + filtered.push(c); + lastEnd = c.endIndex; + } + } + + return filtered; +} + +export function buildProofreadResult( + text: string, + rawCorrections: RawCorrection[], + providerName: string +): ProofreadResult { + const corrections = resolvePositions(text, rawCorrections); + + let correctedInput = text; + const sorted = [...corrections].sort((a, b) => b.startIndex - a.startIndex); + for (const correction of sorted) { + correctedInput = + correctedInput.slice(0, correction.startIndex) + + correction.correction + + correctedInput.slice(correction.endIndex); + } + + logger.info({ corrections: corrections.length }, `${providerName} API proofreading completed`); + + return { correctedInput, corrections }; +} + +export async function testConnectionWith( + fetchFn: () => Promise, + parseError: (res: Response, data: unknown) => string +): Promise<{ ok: boolean; message: string }> { + try { + const response = await fetchFn(); + + if (response.ok) { + return { ok: true, message: 'Connection successful' }; + } + + const errorData = await response.json().catch(() => ({})); + return { ok: false, message: parseError(response, errorData) }; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return { ok: false, message: 'Connection timed out' }; + } + const msg = error instanceof Error ? error.message : 'Connection failed'; + return { ok: false, message: msg }; + } +} diff --git a/src/services/providers/claude.ts b/src/services/providers/claude.ts new file mode 100644 index 0000000..364091a --- /dev/null +++ b/src/services/providers/claude.ts @@ -0,0 +1,151 @@ +import type { IProofreader } from '../proofreader.ts'; +import type { ProofreadResult, ApiConfig } from '../../shared/types.ts'; +import type { ApiProvider, ApiModel, RawCorrection } from './api-provider-utils.ts'; +import { + fetchWithTimeout, + ensureHostPermission, + parseErrorBody, + testConnectionWith, + buildProofreadResult, + normalizeBaseUrl, + SYSTEM_PROMPT, + buildUserPrompt, +} from './api-provider-utils.ts'; + +const ANTHROPIC_API_VERSION = '2023-06-01'; + +const PROOFREAD_TOOL = { + name: 'report_corrections', + description: + 'Report all proofreading corrections found in the text. Each correction must reference the exact original text from the input.', + input_schema: { + type: 'object' as const, + properties: { + corrections: { + type: 'array', + items: { + type: 'object', + properties: { + originalText: { + type: 'string', + description: 'The exact original substring from the input that contains the error', + }, + correctedText: { + type: 'string', + description: 'The corrected replacement text', + }, + type: { + type: 'string', + enum: [ + 'spelling', + 'grammar', + 'punctuation', + 'capitalization', + 'preposition', + 'missing-words', + ], + description: 'The error category', + }, + explanation: { + type: 'string', + description: 'Brief explanation of the correction', + }, + }, + required: ['originalText', 'correctedText'], + }, + }, + }, + required: ['corrections'], + }, +}; + +function buildHeaders(apiKey: string): Record { + const headers: Record = { + 'content-type': 'application/json', + 'anthropic-version': ANTHROPIC_API_VERSION, + }; + if (apiKey) { + headers['x-api-key'] = apiKey; + } + return headers; +} + +export const claudeProvider: ApiProvider = { + async testConnection(config: ApiConfig) { + const base = normalizeBaseUrl(config.apiUrl); + return testConnectionWith( + () => fetchWithTimeout(`${base}/v1/models`, { headers: buildHeaders(config.apiKey) }), + parseErrorBody + ); + }, + + async fetchModels(config: ApiConfig): Promise { + const base = normalizeBaseUrl(config.apiUrl); + + const response = await fetchWithTimeout(`${base}/v1/models`, { + headers: buildHeaders(config.apiKey), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseErrorBody(response, errorData)); + } + + const data = await response.json(); + const models = (data.data ?? []) as Array<{ id: string; display_name?: string }>; + return models.map((m) => ({ + id: m.id, + displayName: m.display_name ?? m.id, + })); + }, + + createProofreader(config: ApiConfig): IProofreader { + return { + async proofread(text: string): Promise { + const { apiKey, selectedModel } = config; + const base = normalizeBaseUrl(config.apiUrl); + + if (!selectedModel) { + throw new Error('No AI model selected. Please choose a model in settings.'); + } + + await ensureHostPermission(config.apiUrl); + + const response = await fetchWithTimeout(`${base}/v1/messages`, { + method: 'POST', + headers: buildHeaders(apiKey), + body: JSON.stringify({ + model: selectedModel, + max_tokens: 2048, + system: SYSTEM_PROMPT, + messages: [{ role: 'user', content: buildUserPrompt(text) }], + tools: [PROOFREAD_TOOL], + tool_choice: { type: 'any' }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseErrorBody(response, errorData)); + } + + const data = await response.json(); + + const toolUse = ( + data.content as Array<{ type: string; name?: string; input?: unknown }> + )?.find((block) => block.type === 'tool_use' && block.name === 'report_corrections'); + + if (!toolUse) { + return { correctedInput: text, corrections: [] }; + } + + const rawCorrections: RawCorrection[] = + (toolUse.input as { corrections?: RawCorrection[] })?.corrections ?? []; + + return buildProofreadResult(text, rawCorrections, 'Claude'); + }, + + destroy() {}, + }; + }, +}; diff --git a/src/services/providers/gemini.ts b/src/services/providers/gemini.ts new file mode 100644 index 0000000..b596220 --- /dev/null +++ b/src/services/providers/gemini.ts @@ -0,0 +1,168 @@ +import type { IProofreader } from '../proofreader.ts'; +import type { ProofreadResult, ApiConfig } from '../../shared/types.ts'; +import type { ApiProvider, ApiModel, RawCorrection } from './api-provider-utils.ts'; +import { + fetchWithTimeout, + ensureHostPermission, + parseErrorBody, + testConnectionWith, + buildProofreadResult, + normalizeBaseUrl, + SYSTEM_PROMPT, + buildUserPrompt, +} from './api-provider-utils.ts'; + +interface GeminiFunctionCall { + name: string; + args: Record; +} + +interface GeminiPart { + functionCall?: GeminiFunctionCall; +} + +interface GeminiResponse { + candidates?: Array<{ content?: { parts?: GeminiPart[] } }>; +} + +const GEMINI_PROOFREAD_TOOL = { + function_declarations: [ + { + name: 'report_corrections', + description: + 'Report all proofreading corrections found in the text. Each correction must reference the exact original text from the input.', + parameters: { + type: 'OBJECT', + properties: { + corrections: { + type: 'ARRAY', + items: { + type: 'OBJECT', + properties: { + originalText: { + type: 'STRING', + description: + 'The exact original substring from the input that contains the error', + }, + correctedText: { + type: 'STRING', + description: 'The corrected replacement text', + }, + type: { + type: 'STRING', + enum: [ + 'spelling', + 'grammar', + 'punctuation', + 'capitalization', + 'preposition', + 'missing-words', + ], + description: 'The error category', + }, + explanation: { + type: 'STRING', + description: 'Brief explanation of the correction', + }, + }, + required: ['originalText', 'correctedText'], + }, + }, + }, + required: ['corrections'], + }, + }, + ], +}; + +function buildUrl(apiUrl: string, path: string, apiKey: string): string { + const url = new URL(path, normalizeBaseUrl(apiUrl)); + if (apiKey) url.searchParams.set('key', apiKey); + return url.toString(); +} + +export const geminiProvider: ApiProvider = { + async testConnection(config: ApiConfig) { + const { apiUrl, apiKey } = config; + return testConnectionWith( + () => + fetchWithTimeout(buildUrl(apiUrl, '/v1beta/models', apiKey), { + headers: { 'content-type': 'application/json' }, + }), + parseErrorBody + ); + }, + + async fetchModels(config: ApiConfig): Promise { + const { apiUrl, apiKey } = config; + + const url = buildUrl(apiUrl, '/v1beta/models?pageSize=1000', apiKey); + const response = await fetchWithTimeout(url, { + headers: { 'content-type': 'application/json' }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseErrorBody(response, errorData)); + } + + const data = await response.json(); + const models = (data.models ?? []) as Array<{ + name: string; + displayName?: string; + supportedGenerationMethods?: string[]; + }>; + return models + .filter((m) => m.supportedGenerationMethods?.includes('generateContent')) + .map((m) => ({ + id: m.name.replace(/^models\//, ''), + displayName: m.displayName ?? m.name, + })); + }, + + createProofreader(config: ApiConfig): IProofreader { + return { + async proofread(text: string): Promise { + const { apiUrl, apiKey, selectedModel } = config; + + if (!selectedModel) { + throw new Error('No AI model selected. Please choose a model in settings.'); + } + + await ensureHostPermission(apiUrl); + + const url = buildUrl(apiUrl, `/v1beta/models/${selectedModel}:generateContent`, apiKey); + const response = await fetchWithTimeout(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] }, + contents: [{ role: 'user', parts: [{ text: buildUserPrompt(text) }] }], + tools: [GEMINI_PROOFREAD_TOOL], + toolConfig: { functionCallingConfig: { mode: 'ANY' } }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseErrorBody(response, errorData)); + } + + const data = await response.json(); + const parts = (data as GeminiResponse)?.candidates?.[0]?.content?.parts; + const fnCall = parts?.find((p) => p.functionCall?.name === 'report_corrections'); + + if (!fnCall?.functionCall) { + return { correctedInput: text, corrections: [] }; + } + + const rawCorrections = + (fnCall.functionCall.args as { corrections?: RawCorrection[] })?.corrections ?? []; + + return buildProofreadResult(text, rawCorrections, 'Gemini'); + }, + + destroy() {}, + }; + }, +}; diff --git a/src/services/providers/openai-compatible.ts b/src/services/providers/openai-compatible.ts new file mode 100644 index 0000000..cd31a8b --- /dev/null +++ b/src/services/providers/openai-compatible.ts @@ -0,0 +1,159 @@ +import type { IProofreader } from '../proofreader.ts'; +import type { ProofreadResult, ApiConfig } from '../../shared/types.ts'; +import type { ApiProvider, ApiModel, RawCorrection } from './api-provider-utils.ts'; +import { + fetchWithTimeout, + ensureHostPermission, + parseErrorBody, + testConnectionWith, + buildProofreadResult, + normalizeBaseUrl, +} from './api-provider-utils.ts'; +import { logger } from '../logger.ts'; + +const JSON_SYSTEM_PROMPT = `You are a precise proofreading assistant. Detect the language of the input text automatically and identify grammar, spelling, punctuation, capitalization, preposition, and missing-word errors. Only report real errors — not style preferences. + +Rules: +- NEVER correct proper nouns, brand names, product names, or technical terms. +- originalText must be the EXACT substring from the input containing the error. +- correctedText must be a SINGLE direct replacement — never multiple alternatives. +- The replacement must be directly substitutable: replacing originalText with correctedText in the input must produce valid text. + +Respond ONLY with a JSON object in this exact format, no other text: +{"corrections":[{"originalText":"exact error text","correctedText":"single fixed text","type":"spelling|grammar|punctuation|capitalization|preposition|missing-words","explanation":"brief reason"}]} + +If there are no errors, respond with: {"corrections":[]}`; + +function buildHeaders(apiKey: string): Record { + const headers: Record = { + 'content-type': 'application/json', + }; + if (apiKey) { + headers['authorization'] = `Bearer ${apiKey}`; + } + return headers; +} + +function extractJson(text: string): string | null { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenced) return fenced[1].trim(); + + const braces = text.match(/\{[\s\S]*\}/); + if (braces) return braces[0]; + + return null; +} + +function tryParseJson(json: string): unknown | null { + try { + return JSON.parse(json); + } catch { + return null; + } +} + +function repairJson(json: string): string { + return json + .replace(/,\s*([}\]])/g, '$1') + .replace(/([{,]\s*)(\w+)\s*:/g, '$1"$2":') + .replace(/:\s*'([^']*)'/g, ':"$1"') + .replace(/"\s*\n\s*"/g, '","'); +} + +function parseCorrections(content: string): RawCorrection[] { + const json = extractJson(content); + if (!json) { + logger.warn({ content }, 'OpenAI-compatible: no JSON found in response'); + return []; + } + + let parsed = tryParseJson(json); + if (!parsed) { + parsed = tryParseJson(repairJson(json)); + } + if (!parsed) { + logger.warn({ json }, 'OpenAI-compatible: failed to parse JSON from response'); + return []; + } + + const corrections = (parsed as { corrections?: unknown }).corrections; + return Array.isArray(corrections) ? (corrections as RawCorrection[]) : []; +} + +export const openaiCompatibleProvider: ApiProvider = { + async testConnection(config: ApiConfig) { + const base = normalizeBaseUrl(config.apiUrl); + return testConnectionWith( + () => fetchWithTimeout(`${base}/v1/models`, { headers: buildHeaders(config.apiKey) }), + parseErrorBody + ); + }, + + async fetchModels(config: ApiConfig): Promise { + const base = normalizeBaseUrl(config.apiUrl); + + const response = await fetchWithTimeout(`${base}/v1/models`, { + headers: buildHeaders(config.apiKey), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseErrorBody(response, errorData)); + } + + const data = await response.json(); + const models = (data.data ?? []) as Array<{ id: string; name?: string }>; + return models.map((m) => ({ + id: m.id, + displayName: m.name ?? m.id, + })); + }, + + createProofreader(config: ApiConfig): IProofreader { + return { + async proofread(text: string): Promise { + const { apiKey, selectedModel } = config; + const base = normalizeBaseUrl(config.apiUrl); + + if (!selectedModel) { + throw new Error('No AI model selected. Please choose a model in settings.'); + } + + await ensureHostPermission(config.apiUrl); + + const response = await fetchWithTimeout(`${base}/v1/chat/completions`, { + method: 'POST', + headers: buildHeaders(apiKey), + body: JSON.stringify({ + model: selectedModel, + messages: [ + { role: 'system', content: JSON_SYSTEM_PROMPT }, + { + role: 'user', + content: `Proofread the following text and report all errors:\n\n${text}`, + }, + ], + response_format: { type: 'json_object' }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseErrorBody(response, errorData)); + } + + const data = await response.json(); + const content = (data.choices?.[0]?.message?.content as string) ?? ''; + + if (!content) { + return { correctedInput: text, corrections: [] }; + } + + const rawCorrections = parseCorrections(content); + return buildProofreadResult(text, rawCorrections, 'OpenAI-compatible'); + }, + + destroy() {}, + }; + }, +}; diff --git a/src/shared/types.ts b/src/shared/types.ts index 5af1ed2..012f8c1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2,7 +2,7 @@ export type UnderlineStyle = 'solid' | 'wavy' | 'dotted'; export type ModelSource = 'local' | 'api'; -export type ApiType = 'claude' | 'openai-compatible'; +export type ApiType = 'claude' | 'gemini' | 'openai-compatible'; export interface ApiConfig { type: ApiType; From 866dee18ab570d7bc36f3aff3f1f86a0c1fb6cec Mon Sep 17 00:00:00 2001 From: Nathan Redblur Date: Fri, 27 Feb 2026 19:44:56 -0500 Subject: [PATCH 3/4] feat: enhance model readiness checks based on storage values * Updated model readiness logic to check the model source before ensuring the proofreader model is ready. * Refactored the isModelReady function to include model source in its checks. * Improved consistency in handling model readiness across different modules (main, options, sidepanel). --- src/content/main.ts | 9 +++++++-- src/options/main.ts | 2 +- src/shared/utils/storage.ts | 8 ++++---- src/sidepanel/main.ts | 8 ++++++-- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/content/main.ts b/src/content/main.ts index 5104d40..9e72a2e 100644 --- a/src/content/main.ts +++ b/src/content/main.ts @@ -1,6 +1,7 @@ import { logger } from '../services/logger.ts'; import { ProofreadingManager } from './proofreading-manager.ts'; -import { isModelReady } from '../shared/utils/storage.ts'; +import { isModelReady, getStorageValues } from '../shared/utils/storage.ts'; +import { STORAGE_KEYS } from '../shared/constants.ts'; import { ensureProofreaderModelReady } from '../services/model-checker.ts'; import { installDevSidepanelButton } from './dev-sidepanel-button.ts'; @@ -11,7 +12,11 @@ async function initProofreading() { try { await installDevSidepanelButton(); - await ensureProofreaderModelReady(); + + const { modelSource } = await getStorageValues([STORAGE_KEYS.MODEL_SOURCE]); + if (modelSource !== 'api') { + await ensureProofreaderModelReady(); + } const modelReady = await isModelReady(); logger.info({ modelReady }, 'Model ready check:'); diff --git a/src/options/main.ts b/src/options/main.ts index 64387da..4cfb1d9 100644 --- a/src/options/main.ts +++ b/src/options/main.ts @@ -103,7 +103,7 @@ async function initOptions() { await ensureProofreaderModelReady(); } - const modelReady = isApiMode ? true : await isModelReady(); + const modelReady = await isModelReady(); if (!modelReady) { app.innerHTML = ` diff --git a/src/shared/utils/storage.ts b/src/shared/utils/storage.ts index b4e55b5..f564a04 100644 --- a/src/shared/utils/storage.ts +++ b/src/shared/utils/storage.ts @@ -135,15 +135,15 @@ export async function setStorageValues(data: Partial): Promise { - const { proofreaderReady, modelDownloaded } = await getStorageValues([ + const { modelSource, proofreaderReady, modelDownloaded } = await getStorageValues([ + STORAGE_KEYS.MODEL_SOURCE, STORAGE_KEYS.PROOFREADER_READY, STORAGE_KEYS.MODEL_DOWNLOADED, ]); + if (modelSource === 'api') return true; + return proofreaderReady && modelDownloaded; } diff --git a/src/sidepanel/main.ts b/src/sidepanel/main.ts index d0c11c5..8e5d979 100644 --- a/src/sidepanel/main.ts +++ b/src/sidepanel/main.ts @@ -3,7 +3,8 @@ import './components/issues-panel.ts'; import './style.css'; import { logger } from '../services/logger.ts'; -import { isModelReady } from '../shared/utils/storage.ts'; +import { isModelReady, getStorageValues } from '../shared/utils/storage.ts'; +import { STORAGE_KEYS } from '../shared/constants.ts'; import type { ApplyAllIssuesMessage, IssuesStateRequestMessage, @@ -54,7 +55,10 @@ async function initSidepanel(): Promise { document.body.classList.add('prfly-page'); - await ensureProofreaderModelReady(); + const { modelSource } = await getStorageValues([STORAGE_KEYS.MODEL_SOURCE]); + if (modelSource !== 'api') { + await ensureProofreaderModelReady(); + } const modelReady = await isModelReady(); if (!modelReady) { From 1bc1ca6ac9d3ca8b277a454330c7d86eabdd409a Mon Sep 17 00:00:00 2001 From: Nathan Redblur Date: Fri, 27 Feb 2026 20:04:15 -0500 Subject: [PATCH 4/4] feat: update model source handling in background service * Added retrieval of model source from storage to update the model source cache during badge listener registration. * Modified the API request structure to include the system prompt in the user message for improved context during proofreading. --- src/background/main.ts | 1 + src/services/providers/openai-compatible.ts | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/background/main.ts b/src/background/main.ts index e0291ca..5b48a39 100644 --- a/src/background/main.ts +++ b/src/background/main.ts @@ -306,6 +306,7 @@ function registerBadgeListeners(): void { registerBadgeListeners(); void updateActionBadge(); +void getStorageValue(STORAGE_KEYS.MODEL_SOURCE).then(updateModelSourceCache); chrome.runtime.onInstalled.addListener(async (details) => { await initializeStorage(); diff --git a/src/services/providers/openai-compatible.ts b/src/services/providers/openai-compatible.ts index cd31a8b..6627eb8 100644 --- a/src/services/providers/openai-compatible.ts +++ b/src/services/providers/openai-compatible.ts @@ -127,13 +127,11 @@ export const openaiCompatibleProvider: ApiProvider = { body: JSON.stringify({ model: selectedModel, messages: [ - { role: 'system', content: JSON_SYSTEM_PROMPT }, { role: 'user', - content: `Proofread the following text and report all errors:\n\n${text}`, + content: `${JSON_SYSTEM_PROMPT}\n\nProofread the following text and report all errors:\n\n${text}`, }, ], - response_format: { type: 'json_object' }, }), });