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..5b48a39 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,14 +292,25 @@ function registerBadgeListeners(): void { void updateActionBadge(); }); + onStorageChange(STORAGE_KEYS.MODEL_SOURCE, (newValue) => { + updateModelSourceCache(newValue); + resetProofreaderServices(); + }); + + onStorageChange(STORAGE_KEYS.API_CONFIG, () => { + resetProofreaderServices(); + }); + badgeListenersRegistered = true; } registerBadgeListeners(); void updateActionBadge(); +void getStorageValue(STORAGE_KEYS.MODEL_SOURCE).then(updateModelSourceCache); 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 +334,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 +409,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/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/api-config-section.ts b/src/options/api-config-section.ts new file mode 100644 index 0000000..4949f28 --- /dev/null +++ b/src/options/api-config-section.ts @@ -0,0 +1,298 @@ +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, 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; + 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 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'); + const fetchModelsBtn = document.querySelector('#fetchModelsBtn'); + const connectionStatus = document.querySelector('#connectionStatus'); + 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 ────────────────────────────────── + + 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)) + ); + + // ── 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 = () => { + 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..4cfb1d9 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,10 +65,43 @@ 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(); @@ -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,69 @@ 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 API +
+
+ +
+ + +
+
+
+ + + +
+ +
+
+
+ +

Model Status

-

Review the status of AI models.

+

Review the status of the local AI model.

@@ -701,13 +831,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 +904,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 +929,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..4bdedab --- /dev/null +++ b/src/services/api-proofreader.ts @@ -0,0 +1,32 @@ +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); + 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/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..6627eb8 --- /dev/null +++ b/src/services/providers/openai-compatible.ts @@ -0,0 +1,157 @@ +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: 'user', + content: `${JSON_SYSTEM_PROMPT}\n\nProofread the following text and report all errors:\n\n${text}`, + }, + ], + }), + }); + + 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/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..012f8c1 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' | 'gemini' | '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..f564a04 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; /** @@ -131,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) {