diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..abcd4e9 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +save-exact=true +ignore-scripts=true +registry=https://registry.npmjs.org/ +lockfile-version=3 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5eaa09f..3866b9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@webcomponents/webcomponentsjs": "^2.8.0" }, "devDependencies": { - "@crxjs/vite-plugin": "^2.0.3", + "@crxjs/vite-plugin": "2.0.3", "@types/chrome": "^0.1.29", "@types/dom-chromium-ai": "^0.0.10", "@types/node": "^24.9.2", @@ -19,7 +19,7 @@ "eslint": "^9.39.1", "globals": "^15.15.0", "husky": "^9.1.7", - "pino": "^10.1.0", + "pino": "^10.1.1", "prettier": "^3.6.2", "puppeteer-core": "^24.27.0", "typescript": "~5.8.3", @@ -31,9 +31,9 @@ } }, "node_modules/@crxjs/vite-plugin": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.2.0.tgz", - "integrity": "sha512-HpT1GLbUQy42nlpN4sGzFgulacBraMM778s8Q+oPo4cb26DwO9tTwdndlvAS8fe6vEProFXvbdt37objp/0IQA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.0.3.tgz", + "integrity": "sha512-OfAzERiwBlLg7u/+aRExJVYlxx95IaYOSEMJpMzXcKqWPNBMlA1ppwC2nelHTIvF5LWxn5CEp6zqlHjLH9QGtg==", "dev": true, "license": "MIT", "dependencies": { @@ -3567,32 +3567,32 @@ } }, "node_modules/pino": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz", - "integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz", + "integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==", "dev": true, "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", + "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" + "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", "dev": true, "license": "MIT", "dependencies": { @@ -4208,13 +4208,16 @@ } }, "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", "dev": true, "license": "MIT", "dependencies": { "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/tinybench": { @@ -4383,9 +4386,9 @@ } }, "node_modules/undici": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", - "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.19.1.tgz", + "integrity": "sha512-Gpq0iNm5M6cQWlyHQv9MV+uOj1jWk7LpkoE5vSp/7zjb4zMdAcUD+VL5y0nH4p9EbUklq00eVIIX/XcDHzu5xg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 7bad068..33c7768 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "prepare": "husky" }, "devDependencies": { - "@crxjs/vite-plugin": "^2.0.3", + "@crxjs/vite-plugin": "2.0.3", "@types/chrome": "^0.1.29", "@types/dom-chromium-ai": "^0.0.10", "@types/node": "^24.9.2", @@ -27,7 +27,7 @@ "eslint": "^9.39.1", "globals": "^15.15.0", "husky": "^9.1.7", - "pino": "^10.1.0", + "pino": "^10.1.1", "prettier": "^3.6.2", "puppeteer-core": "^24.27.0", "typescript": "~5.8.3", @@ -40,4 +40,4 @@ "dependencies": { "@webcomponents/webcomponentsjs": "^2.8.0" } -} +} \ No newline at end of file diff --git a/src/shared/components/model-downloader.ts b/src/shared/components/model-downloader.ts index 4b0723e..ed86a90 100644 --- a/src/shared/components/model-downloader.ts +++ b/src/shared/components/model-downloader.ts @@ -1,8 +1,7 @@ import { createModelDownloader, type DownloadProgress } from '../../services/model-downloader.ts'; -import { STORAGE_DEFAULTS, STORAGE_KEYS } from '../constants.ts'; +import { STORAGE_KEYS } from '../constants.ts'; const PROOFREADER_FLAG_URL = 'chrome://flags/#proofreader-api-for-gemini-nano'; -const PROOFREADER_FLAG_INSTRUCTIONS = `Enable "Proofreader API for Gemini Nano" on ${PROOFREADER_FLAG_URL}`; export class ModelDownloaderComponent extends HTMLElement { private shadow: ShadowRoot; @@ -12,12 +11,13 @@ export class ModelDownloaderComponent extends HTMLElement { private elements = { container: null as HTMLDivElement | null, + requirements: null as HTMLDivElement | null, status: null as HTMLDivElement | null, button: null as HTMLButtonElement | null, progress: null as HTMLProgressElement | null, progressText: null as HTMLDivElement | null, error: null as HTMLDivElement | null, - }; + } constructor() { super(); @@ -25,10 +25,11 @@ export class ModelDownloaderComponent extends HTMLElement { } connectedCallback() { - this.render(); + this.render().then(() => { this.attachEventListeners(); this.checkInitialState(); - } + }); +} disconnectedCallback() { this.cleanup.forEach((fn) => fn()); @@ -39,69 +40,55 @@ export class ModelDownloaderComponent extends HTMLElement { } } - private async checkInitialState() { - try { - const { [STORAGE_KEYS.MODEL_DOWNLOADED]: modelDownloaded } = await chrome.storage.local.get({ - [STORAGE_KEYS.MODEL_DOWNLOADED]: STORAGE_DEFAULTS[STORAGE_KEYS.MODEL_DOWNLOADED], - }); - const hasDownloadedModel = Boolean(modelDownloaded); - - if (!('Proofreader' in window)) { - const messageParts = [ - 'Proofreader API not found. This extension requires Chrome 141+ with the Built-in AI Proofreader API enabled.', - `${PROOFREADER_FLAG_INSTRUCTIONS}.`, - ]; +private async checkInitialState() { + try { + // 1. Check if the flag is even enabled (window check) + if (!('Proofreader' in window)) { + this.showError('Built-in AI features are disabled. Please enable the flag below.'); + this.hideDownloadButton(); + return; + } - if (hasDownloadedModel) { - messageParts.push('Downloaded models cannot be used until the flag is turned on.'); - } + // 2. Check the specific Proofreader availability status + const availability = await this.downloader.checkProofreaderAvailability(); - this.showError(messageParts.join(' ')); - this.hideDownloadButton(); - return; - } + switch (availability) { + case 'available': + // Model is already on disk + await this.markModelAsReady(); + this.showSuccess(); + break; - const proofreaderAvailability = await this.downloader.checkProofreaderAvailability(); - // Check language detector availability to trigger download if needed - await this.downloader.checkLanguageDetectorAvailability(); - - if (proofreaderAvailability === 'unavailable') { - if (hasDownloadedModel) { - this.showError( - [ - 'Proofreader API features are disabled in Chrome even though the models are ready.', - `${PROOFREADER_FLAG_INSTRUCTIONS}.`, - ].join(' ') - ); - } else { - this.showError( - 'Proofreader API is unavailable on this device. Requirements:\n' + - '• Chrome 141 or later\n' + - '• At least 22 GB free storage\n' + - '• GPU with 4GB+ VRAM\n' + - '• Enable chrome://flags/#proofreader-api-for-gemini-nano' - ); - } - this.hideDownloadButton(); - return; - } + case 'downloadable': + // Requirements met, but model is missing + this.hideError(); + this.showDownloadButton(); + break; - if (proofreaderAvailability === 'available') { - await chrome.storage.local.set({ - [STORAGE_KEYS.MODEL_DOWNLOADED]: true, - [STORAGE_KEYS.PROOFREADER_READY]: true, - [STORAGE_KEYS.MODEL_AVAILABILITY]: 'available', - }); - this.showSuccess(); - return; - } + case 'downloading': + // Already in progress (perhaps from a previous session) + this.showProgress(); + break; - this.hideError(); - this.showDownloadButton(); - } catch (error) { - this.showError(`${(error as Error).message}`); + case 'unavailable': + default: + // System doesn't meet requirements (GPU, Storage, etc.) + this.showError('Your system does not meet the hardware requirements for Gemini Nano.'); + this.hideDownloadButton(); + break; } + } catch (err) { + this.showError(`Initialization failed: ${(err as Error).message}`); } +} + +private async markModelAsReady() { + await chrome.storage.local.set({ + [STORAGE_KEYS.MODEL_DOWNLOADED]: true, + [STORAGE_KEYS.PROOFREADER_READY]: true, + [STORAGE_KEYS.MODEL_AVAILABILITY]: 'available', + }); +} private async handleDownload() { if (!this.elements.button) return; @@ -145,21 +132,34 @@ export class ModelDownloaderComponent extends HTMLElement { private updateProgress(progress: DownloadProgress) { if (!this.elements.progress || !this.elements.progressText) return; - this.elements.progress.value = progress.progress; + if (progress.progress <= 0 || progress.state === 'checking' || progress.state === 'extracting') { + this.elements.progress.removeAttribute('value'); + } else { + this.elements.progress.value = progress.progress; + } const percent = Math.floor(progress.progress * 100); - const modelLabel = - progress.modelType === 'language-detector' ? 'Language Detection' : 'Proofreader'; - let text = `${modelLabel}: ${percent}%`; - - if (progress.state === 'downloading' && progress.bytesDownloaded && progress.totalBytes) { - const downloaded = this.formatBytes(progress.bytesDownloaded); - const total = this.formatBytes(progress.totalBytes); - text = `${modelLabel}: ${downloaded} / ${total} (${percent}%)`; - } else if (progress.state === 'extracting') { - text = `Extracting ${modelLabel.toLowerCase()} model...`; - } else if (progress.state === 'checking') { - text = `Checking ${modelLabel.toLowerCase()} availability...`; + const modelLabel = progress.modelType === 'language-detector' ? 'Language Detection' : 'Proofreader'; + + let text = ''; + switch (progress.state) { + case 'checking': + text = `Initializing ${modelLabel.toLowerCase()}...`; + break; + case 'extracting': + text = `Finalizing ${modelLabel.toLowerCase()} setup...`; + break; + case 'downloading': + if (progress.bytesDownloaded && progress.totalBytes) { + const downloaded = this.formatBytes(progress.bytesDownloaded); + const total = this.formatBytes(progress.totalBytes); + text = `Downloading ${modelLabel}: ${downloaded} / ${total} (${percent}%)`; + } else { + text = `Downloading ${modelLabel}: ${percent}%`; + } + break; + default: + text = `Processing...`; } this.elements.progressText.textContent = text; @@ -242,20 +242,26 @@ export class ModelDownloaderComponent extends HTMLElement { private attachEventListeners() { if (this.elements.button) { - const handleClick = () => this.handleDownload(); - this.elements.button.addEventListener('click', handleClick); - this.cleanup.push(() => this.elements.button?.removeEventListener('click', handleClick)); + // The handler needs to be bound to 'this' to access class methods + const handleButtonClick = () => this.handleDownload(); + this.elements.button.addEventListener('click', handleButtonClick); + + // Clean up to avoid memory leaks + this.cleanup.push(() => this.elements.button?.removeEventListener('click', handleButtonClick)); } - const unsubscribeProgress = this.downloader.on('download-progress', (progress) => { + // Listen to the downloader service for progress updates + const unsubscribeProgress = this.downloader.on('state-change', (progress) => { this.updateProgress(progress); + + if (progress.state === 'ready') { + this.showSuccess(); + } else if (progress.state === 'error') { + this.showError(progress.error?.message || 'Download failed'); + } }); + this.cleanup.push(unsubscribeProgress); - - const unsubscribeError = this.downloader.on('error', (error) => { - this.showError(error.message); - }); - this.cleanup.push(unsubscribeError); } private getStyles(): string { @@ -367,6 +373,26 @@ export class ModelDownloaderComponent extends HTMLElement { color: #6b7280; } + progress:not([value]) { + background-color: #e5e7eb; + } + + progress:not([value])::-webkit-progress-bar { + background-image: linear-gradient( + 90deg, + #4f46e5 25%, + #818cf8 50%, + #4f46e5 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s infinite linear; + } + + @keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + .status { margin-top: 1rem; padding: 0.75rem; @@ -392,83 +418,117 @@ export class ModelDownloaderComponent extends HTMLElement { white-space: pre-line; text-align: left; } - `; - } - private render() { - const container = document.createElement('div'); - container.className = 'container'; + .requirement-item { + display: flex; + align-items: center; + gap: 10px; + margin: 8px 0; + font-size: 0.85rem; + color: #4b5563; + line-height: 1.4; + } - const title = document.createElement('h2'); - title.className = 'title'; - title.textContent = 'Welcome to Proofly!'; - - const description = document.createElement('p'); - description.className = 'description'; - description.innerHTML = - 'Complete your setup by downloading the AI models to get started with on-device proofreading.
This is a one-time setup.'; - - const requirements = document.createElement('div'); - requirements.className = 'requirements'; - requirements.innerHTML = ` - Requirements - - `; + .code-block-wrapper { + display: inline-flex; + align-items: center; + background: #f3f4f6; + padding: 2px 8px; + border-radius: 4px; + margin-left: 4px; + cursor: pointer; + border: 1px solid #e5e7eb; + vertical-align: middle; + } - const button = document.createElement('button'); - button.className = 'button'; - button.type = 'button'; - button.textContent = 'Download AI Model (~22GB)'; + .code-block-wrapper:hover { + background: #e5e7eb; + } - const progressContainer = document.createElement('div'); - progressContainer.className = 'progress-container'; + code { + font-size: 0.75rem; + color: #1f2937; + } - const progress = document.createElement('progress'); - progress.max = 1; - progress.value = 0; - progress.style.display = 'none'; + .copy-icon { + margin-left: 6px; + font-size: 12px; + opacity: 0.6; + } - const progressText = document.createElement('div'); - progressText.className = 'progress-text'; - progressText.style.display = 'none'; + .icon-check { color: #10b981; font-weight: bold; } + .icon-cross { color: #ef4444; font-weight: bold; } + .icon-pending { color: #9ca3af; } + `; + } - const status = document.createElement('div'); - status.className = 'status'; - status.style.display = 'none'; +private async render() { + const isFlagEnabled = 'Proofreader' in window; + + // Check disk space using the Storage Manager API + const storageInfo = await navigator.storage.estimate(); + const freeSpaceGB = (storageInfo.quota && storageInfo.usage) + ? (storageInfo.quota - storageInfo.usage) / (1024 ** 3) + : 0; + const hasEnoughSpace = freeSpaceGB >= 22; - const error = document.createElement('div'); - error.className = 'error'; + const container = document.createElement('div'); + container.className = 'container'; - progressContainer.appendChild(progress); - progressContainer.appendChild(progressText); + const getReqLine = (text: string, isMet: boolean | null, isFlag = false) => { + const icon = isMet === true ? '✓' : isMet === false ? '✕' : '○'; + const iconClass = isMet === true ? 'icon-check' : isMet === false ? 'icon-cross' : 'icon-pending'; + + return ` +
  • + ${icon} + ${text} + ${isFlag && !isMet ? ` +
    + chrome://flags/#proofreader-api-for-gemini-nano + 📋 +
    ` : ''} +
  • + `; + }; - container.appendChild(title); - container.appendChild(description); - container.appendChild(requirements); - container.appendChild(button); - container.appendChild(progressContainer); - container.appendChild(status); - container.appendChild(error); + container.innerHTML = ` +

    Welcome to Proofly!

    +

    + Complete your setup by downloading the AI models to get started. This is a one-time setup. +

    +
    + System Check + +
    + +
    + + +
    + + + `; const style = document.createElement('style'); style.textContent = this.getStyles(); - this.shadow.appendChild(style); this.shadow.appendChild(container); + // Cast types to avoid TS errors this.elements = { container, - status, - button, - progress, - progressText, - error, + requirements: container.querySelector('.requirements') as HTMLDivElement, + button: container.querySelector('.button') as HTMLButtonElement, + status: container.querySelector('.status') as HTMLDivElement, + progress: container.querySelector('progress') as HTMLProgressElement, + progressText: container.querySelector('.progress-text') as HTMLDivElement, + error: container.querySelector('.error') as HTMLDivElement, }; } }