diff --git a/.axioma/quality.md b/.axioma/quality.md index a36ca5e..0fba00c 100644 --- a/.axioma/quality.md +++ b/.axioma/quality.md @@ -73,6 +73,11 @@ **Learning:** To achieve 100% branch coverage in hooks or functions that use default parameters (e.g., `useThrottle(value, limit = 500)`), unit tests must explicitly invoke the function without the optional arguments. Simply relying on tests that provide values for all arguments leaves the default assignment branch uncovered. **Action:** Always include a test case that omits optional arguments to ensure default parameter logic is verified and coverage is maximized. +## 2024-06-20 - [Consistent Code Formatting and Micro-Improvements] + +**Learning:** The project's `.prettierrc.json` (4 spaces, no semicolons) may conflict with the existing style of some older files (2 spaces, semicolons). Running global formatters on these files can create large diffs that exceed "micro-improvement" constraints (e.g., 50-line limit). +**Action:** When performing micro-improvements, prefer manual formatting that matches the file's current style if a full reformat would exceed the line-count limit, or ensure reformatting is justified by the project's official config. + ## 2024-06-25 - [Robust Timer Input Validation] **Learning:** Browser timer APIs like `setInterval` and `setTimeout` have inconsistent behaviors when receiving `NaN` or unexpected objects (like `Date` for intervals), often defaulting to 1ms or 0ms without warning. Explicitly validating these inputs and providing fallback values (e.g., 1000ms for intervals) with `console.warn` ensures predictable behavior and improves developer experience. diff --git a/lib/hooks/useAISummarize.coverage.test.ts b/lib/hooks/useAISummarize.coverage.test.ts new file mode 100644 index 0000000..17ef495 --- /dev/null +++ b/lib/hooks/useAISummarize.coverage.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useAISummarize } from './useAISummarize' + +describe('useAISummarize Coverage', () => { + const mockSummarizer = { + summarize: vi.fn(), + summarizeStreaming: vi.fn(), + destroy: vi.fn(), + } + + const mockSummarizerCreate = vi.fn() + const mockAvailability = vi.fn() + + beforeEach(() => { + vi.stubGlobal('navigator', { + userActivation: { isActive: true }, + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const SummarizerConstructor = function () {} as any + SummarizerConstructor.availability = mockAvailability + SummarizerConstructor.create = mockSummarizerCreate + + vi.stubGlobal('Summarizer', SummarizerConstructor) + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).Summarizer = SummarizerConstructor + } + mockAvailability.mockResolvedValue('readily') + mockSummarizerCreate.mockResolvedValue(mockSummarizer) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (window as any).Summarizer + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (window as any).LanguageDetector + } + }) + + it('should handle base constructors in createSummarizer', async () => { + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).Summarizer = Object + } + const { result } = renderHook(() => useAISummarize()) + + await act(async () => { + await result.current.summarize('text') + }) + + expect(result.current.status).toBe('error') + expect(result.current.error?.message).toContain( + 'Summarizer is not available', + ) + }) + + it('should handle unavailable status in createSummarizer', async () => { + mockAvailability.mockResolvedValue('unavailable') + const { result } = renderHook(() => useAISummarize()) + + await act(async () => { + await result.current.summarize('text') + }) + + expect(result.current.status).toBe('error') + expect(result.current.error?.message).toContain( + 'Summarizer is not available', + ) + }) + + it('should handle base constructors in detectLanguageFromText', async () => { + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).LanguageDetector = Object + } + const { result } = renderHook(() => + useAISummarize({ outputLanguage: 'auto' }), + ) + + await act(async () => { + await result.current.summarize('text') + }) + + // Should fallback to 'en' + expect(mockSummarizerCreate).toHaveBeenCalledWith( + expect.objectContaining({ outputLanguage: 'en' }), + ) + }) + + it('should handle unavailable LanguageDetector in detectLanguageFromText', async () => { + const mockLanguageAvailability = vi + .fn() + .mockResolvedValue('unavailable') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const LanguageDetectorConstructor = function () {} as any + LanguageDetectorConstructor.availability = mockLanguageAvailability + + vi.stubGlobal('LanguageDetector', LanguageDetectorConstructor) + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).LanguageDetector = LanguageDetectorConstructor + } + + const { result } = renderHook(() => + useAISummarize({ outputLanguage: 'auto' }), + ) + + await act(async () => { + await result.current.summarize('text') + }) + + expect(mockSummarizerCreate).toHaveBeenCalledWith( + expect.objectContaining({ outputLanguage: 'en' }), + ) + }) + + it('should handle missing userActivation in detectLanguageFromText', async () => { + vi.stubGlobal('navigator', { + userActivation: { isActive: false }, + }) + + const mockLanguageDetectorCreate = vi.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const LanguageDetectorConstructor = function () {} as any + LanguageDetectorConstructor.availability = vi + .fn() + .mockResolvedValue('readily') + LanguageDetectorConstructor.create = mockLanguageDetectorCreate + + vi.stubGlobal('LanguageDetector', LanguageDetectorConstructor) + + const { result } = renderHook(() => + useAISummarize({ outputLanguage: 'auto' }), + ) + + await act(async () => { + await result.current.summarize('text') + }) + + // Should return 'en' and NOT call LanguageDetector.create + expect(mockLanguageDetectorCreate).not.toHaveBeenCalled() + expect(result.current.status).toBe('error') + expect(result.current.error?.message).toContain( + 'User activation required', + ) + }) + + it('should handle explicit outputLanguage', async () => { + const { result } = renderHook(() => + useAISummarize({ outputLanguage: 'ja' }), + ) + + await act(async () => { + await result.current.summarize('text') + }) + + expect(mockSummarizerCreate).toHaveBeenCalledWith( + expect.objectContaining({ outputLanguage: 'ja' }), + ) + }) + + it('should handle context in summarize', async () => { + const { result } = renderHook(() => useAISummarize()) + + await act(async () => { + await result.current.summarize('text', 'custom context') + }) + + expect(mockSummarizer.summarize).toHaveBeenCalledWith( + 'text', + expect.objectContaining({ context: 'custom context' }), + ) + }) + + it('should handle warmup error', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + mockSummarizerCreate.mockRejectedValue(new Error('Warmup failed')) + + renderHook(() => useAISummarize({ warmup: true })) + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to warmup summarizer:', + expect.any(Error), + ) + }) + consoleSpy.mockRestore() + }) + + it('should handle missing create method in detectLanguageFromText', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const LanguageDetectorConstructor = function () {} as any + LanguageDetectorConstructor.availability = vi + .fn() + .mockResolvedValue('readily') + // create is missing + + vi.stubGlobal('LanguageDetector', LanguageDetectorConstructor) + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).LanguageDetector = LanguageDetectorConstructor + } + + const { result } = renderHook(() => + useAISummarize({ outputLanguage: 'auto' }), + ) + + await act(async () => { + await result.current.summarize('text') + }) + + expect(mockSummarizerCreate).toHaveBeenCalledWith( + expect.objectContaining({ outputLanguage: 'en' }), + ) + }) + + it('should handle empty results in detectLanguageFromText', async () => { + const mockLanguageDetector = { + detect: vi.fn().mockResolvedValue([]), + destroy: vi.fn(), + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const LanguageDetectorConstructor = function () {} as any + LanguageDetectorConstructor.availability = vi + .fn() + .mockResolvedValue('readily') + LanguageDetectorConstructor.create = vi + .fn() + .mockResolvedValue(mockLanguageDetector) + + vi.stubGlobal('LanguageDetector', LanguageDetectorConstructor) + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).LanguageDetector = LanguageDetectorConstructor + } + + const { result } = renderHook(() => + useAISummarize({ outputLanguage: 'auto' }), + ) + + await act(async () => { + await result.current.summarize('text') + }) + + expect(mockSummarizerCreate).toHaveBeenCalledWith( + expect.objectContaining({ outputLanguage: 'en' }), + ) + }) +}) diff --git a/lib/hooks/useAISummarize.ts b/lib/hooks/useAISummarize.ts index 5169264..f14e771 100644 --- a/lib/hooks/useAISummarize.ts +++ b/lib/hooks/useAISummarize.ts @@ -1,113 +1,169 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; -import type { Availability } from './useAI'; -import { getUserLanguage } from '../utilities/userLanguage'; +import { useState, useCallback, useRef, useEffect } from 'react' +import type { Availability } from './useAI' +import { getUserLanguage } from '../utilities/userLanguage' /** * Result object returned by the language detection. */ interface DetectionResult { - /** The detected language code (BCP 47 format, e.g., 'en', 'es', 'fr') */ - detectedLanguage: string; - /** Confidence level between 0.0 (lowest) and 1.0 (highest) */ - confidence: number; + /** The detected language code (BCP 47 format, e.g., 'en', 'es', 'fr') */ + detectedLanguage: string + /** Confidence level between 0.0 (lowest) and 1.0 (highest) */ + confidence: number } /** * Monitor for tracking LanguageDetector creation progress. */ interface CreateMonitor { - addEventListener(event: 'downloadprogress', callback: (e: ProgressEvent) => void): void; + addEventListener( + event: 'downloadprogress', + callback: (e: ProgressEvent) => void, + ): void } /** * Chrome's LanguageDetector interface. */ interface LanguageDetector { - detect(text: string): Promise; - destroy(): void; + detect(text: string): Promise + destroy(): void } /** * Options for creating a LanguageDetector instance. */ interface LanguageDetectorCreateOptions { - monitor?(m: CreateMonitor): void; + monitor?(m: CreateMonitor): void } - /** * Helper function to detect language from text using Chrome's LanguageDetector API. * @param text - Text to detect language from * @returns The detected language code (e.g., 'en', 'es', 'ja') */ async function detectLanguageFromText(text: string): Promise { - if (typeof window === 'undefined' || typeof (window as unknown as { LanguageDetector?: unknown }).LanguageDetector !== 'function') { - return 'en'; - } - - const LanguageDetector = (window as unknown as { LanguageDetector: { availability?: () => Promise; create?: (options?: LanguageDetectorCreateOptions) => Promise } }).LanguageDetector; - - // Ensure we're not dealing with base constructors - if ( - LanguageDetector === (Object as any) || - LanguageDetector === (Array as any) || - LanguageDetector === (Function as any) - ) { - return 'en'; - } - - // Check availability - if (typeof LanguageDetector.availability === 'function') { - const avail = await LanguageDetector.availability(); - if (avail === 'unavailable') { - return 'en'; + if ( + typeof window === 'undefined' || + typeof (window as unknown as { LanguageDetector?: unknown }) + .LanguageDetector !== 'function' + ) { + return 'en' } - } - // Check user activation (required by Chrome) - if (typeof navigator !== 'undefined' && 'userActivation' in navigator && !(navigator as unknown as { userActivation?: { isActive: boolean } }).userActivation?.isActive) { - return 'en'; - } + const LanguageDetector = ( + window as unknown as { + LanguageDetector: { + availability?: () => Promise + create?: ( + options?: LanguageDetectorCreateOptions, + ) => Promise + } + } + ).LanguageDetector - if (typeof LanguageDetector.create !== 'function') { - return 'en'; - } + // Ensure we're not dealing with base constructors + if ( + LanguageDetector === (Object as any) || + LanguageDetector === (Array as any) || + LanguageDetector === (Function as any) + ) { + return 'en' + } - const detector = await LanguageDetector.create(); - const results = await detector.detect(text); - detector.destroy(); + // Check availability + if (typeof LanguageDetector.availability === 'function') { + const avail = await LanguageDetector.availability() + if (avail === 'unavailable') { + return 'en' + } + } - // Return the most likely language with highest confidence - if (results.length > 0) { - const detected = results[0].detectedLanguage.split('-')[0]; - return detected; - } + // Check user activation (required by Chrome) + if ( + typeof navigator !== 'undefined' && + 'userActivation' in navigator && + !(navigator as unknown as { userActivation?: { isActive: boolean } }) + .userActivation?.isActive + ) { + return 'en' + } + + if (typeof LanguageDetector.create !== 'function') { + return 'en' + } + + const detector = await LanguageDetector.create() + const results = await detector.detect(text) + detector.destroy() + + // Return the most likely language with highest confidence + if (results.length > 0) { + const detected = results[0].detectedLanguage.split('-')[0] + return detected + } - return 'en'; + return 'en' } export interface UseAISummarizeOptions { - type?: 'tldr' | 'key-points' | 'teaser' | 'headline'; - format?: 'plain-text' | 'markdown'; - length?: 'short' | 'medium' | 'long'; - sharedContext?: string; - outputLanguage?: 'en' | 'es' | 'ja' | 'auto' | 'user'; - expectedInputLanguages?: string[]; - expectedContextLanguages?: string[]; - preference?: 'auto' | 'capability'; - streaming?: boolean; - warmup?: boolean; + type?: 'tldr' | 'key-points' | 'teaser' | 'headline' + format?: 'plain-text' | 'markdown' + length?: 'short' | 'medium' | 'long' + sharedContext?: string + outputLanguage?: 'en' | 'es' | 'ja' | 'auto' | 'user' + expectedInputLanguages?: string[] + expectedContextLanguages?: string[] + preference?: 'auto' | 'capability' + streaming?: boolean + warmup?: boolean } -export type AISummarizeStatus = 'idle' | 'initializing' | 'downloading' | 'summarizing' | 'success' | 'error'; +export type AISummarizeStatus = + | 'idle' + | 'initializing' + | 'downloading' + | 'summarizing' + | 'success' + | 'error' +/** + * Result object returned by the useAISummarize hook. + */ export interface UseAISummarizeReturn { - data: string; - status: AISummarizeStatus; - progress: { loaded: number; total: number } | null; - error: Error | null; - summarize: (text: string, context?: string) => Promise; - reset: () => void; + /** + * The generated summary. + * @example "This is a short summary of the text." + */ + data: string + /** + * Current status of the summarization process. + * @example "summarizing" + */ + status: AISummarizeStatus + /** + * Download progress when the model is being downloaded. + * @example { loaded: 50, total: 100 } + */ + progress: { loaded: number; total: number } | null + /** + * Error object if initialization or summarization fails. + * @example null + */ + error: Error | null + /** + * Function to trigger the summarization process. + * @param text - The text to summarize. + * @param context - Optional context to guide the summarization. + * @returns A promise that resolves when the summarization starts (or finishes if not streaming). + * @example summarize("Long article content...", "Focus on key takeaways") + */ + summarize: (text: string, context?: string) => Promise + /** + * Resets the hook's state and aborts any ongoing summarization. + * @example reset() + */ + reset: () => void } // Type definitions for Chrome's Summarizer API @@ -117,31 +173,40 @@ export interface UseAISummarizeReturn { * Monitor for tracking Summarizer creation progress. */ interface AICreateMonitor { - addEventListener(event: 'downloadprogress', callback: (e: Event) => void): void; + addEventListener( + event: 'downloadprogress', + callback: (e: Event) => void, + ): void } /** * Options for creating a Summarizer instance. */ interface SummarizerCreateOptions { - type?: string; - format?: string; - length?: string; - sharedContext?: string; - outputLanguage?: string; - expectedInputLanguages?: string[]; - expectedContextLanguages?: string[]; - preference?: 'auto' | 'capability'; - monitor?(m: AICreateMonitor): void; + type?: string + format?: string + length?: string + sharedContext?: string + outputLanguage?: string + expectedInputLanguages?: string[] + expectedContextLanguages?: string[] + preference?: 'auto' | 'capability' + monitor?(m: AICreateMonitor): void } /** * Chrome's Summarizer interface. */ interface AISummarizer { - summarize(text: string, options?: { signal?: AbortSignal; context?: string }): Promise; - summarizeStreaming(text: string, options?: { signal?: AbortSignal; context?: string }): ReadableStream; - destroy(): void; + summarize( + text: string, + options?: { signal?: AbortSignal; context?: string }, + ): Promise + summarizeStreaming( + text: string, + options?: { signal?: AbortSignal; context?: string }, + ): ReadableStream + destroy(): void } /** @@ -166,174 +231,265 @@ interface AISummarizer { * @param options.streaming - Enable streaming output for real-time results (default: false) * @param options.warmup - Preload model on mount for faster first summary (default: false) * @returns An object with data, status, progress, error, and functions to summarize or reset + * + * @example + * ```tsx + * const { data, status, summarize } = useAISummarize({ + * type: 'key-points', + * format: 'markdown', + * length: 'short' + * }); + * + * const handleSummarize = async () => { + * await summarize("Long text to be summarized..."); + * }; + * + * return ( + *
+ * + * {status === 'summarizing' &&

Summarizing...

} + * {status === 'success' &&

{data}

} + *
+ * ); + * ``` */ -export function useAISummarize(options: UseAISummarizeOptions = {}): UseAISummarizeReturn { - const { type, format, length, sharedContext, outputLanguage = 'auto', expectedInputLanguages, expectedContextLanguages, preference = 'auto', streaming = false, warmup = false } = options; - const [data, setData] = useState(''); - const [status, setStatus] = useState('idle'); - const [progress, setProgress] = useState<{ loaded: number; total: number } | null>(null); - const [error, setError] = useState(null); - - const summarizerRef = useRef(null); - const abortControllerRef = useRef(null); - - const reset = useCallback(() => { - setData(''); - setStatus('idle'); - setProgress(null); - setError(null); - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }, []); - - const createSummarizer = useCallback(async (overrideOutputLanguage?: string) => { - if (summarizerRef.current) return summarizerRef.current; - - // Chrome native API: Summarizer is a global constructor - if (typeof window === 'undefined' || typeof (window as unknown as { Summarizer?: unknown }).Summarizer !== 'function') { - throw new Error('Summarizer API not supported in this browser'); - } - - const Summarizer = (window as unknown as { Summarizer: { availability?: () => Promise; create?: (options: SummarizerCreateOptions) => Promise } }).Summarizer; - - // Ensure we're not dealing with base constructors - if ( - Summarizer === (Object as any) || - Summarizer === (Array as any) || - Summarizer === (Function as any) - ) { - throw new Error('Summarizer is not available'); - } - - // Check availability - if (typeof Summarizer.availability === 'function') { - const avail = await Summarizer.availability(); - if (avail === 'unavailable') { - throw new Error('Summarizer is not available'); - } - if (avail === 'downloading' || avail === 'downloadable') { - setStatus('downloading'); - } else { - setStatus('initializing'); - } - } - - // Check user activation (required by Chrome) - if (typeof navigator !== 'undefined' && 'userActivation' in navigator && !(navigator as unknown as { userActivation?: { isActive: boolean } }).userActivation?.isActive) { - throw new Error('User activation required. Please interact with the page first.'); - } - - if (typeof Summarizer.create !== 'function') { - throw new Error('Summarizer.create is not available'); - } - - const resolvedOutputLanguage = overrideOutputLanguage || outputLanguage; - - const instance = await Summarizer.create({ - type, - format, - length, - sharedContext, - outputLanguage: resolvedOutputLanguage, - expectedInputLanguages, - expectedContextLanguages, - preference, - monitor(m: AICreateMonitor) { - m.addEventListener('downloadprogress', (e: Event) => { - const progressEvent = e as ProgressEvent; - setProgress({ loaded: progressEvent.loaded, total: progressEvent.total }); - }); - }, - }); - - summarizerRef.current = instance; - return instance; - }, [type, format, length, sharedContext, outputLanguage, expectedInputLanguages, expectedContextLanguages, preference]); - - const summarize = useCallback( - async (text: string, context?: string) => { - if (status === 'summarizing' || status === 'initializing' || status === 'downloading') { - return; - } - - setError(null); - setData(''); - - try { - // Resolve outputLanguage based on 'auto' or 'user' settings - let resolvedLanguage: string; - if (outputLanguage === 'auto') { - resolvedLanguage = await detectLanguageFromText(text); - } else if (outputLanguage === 'user') { - resolvedLanguage = getUserLanguage(); - } else { - resolvedLanguage = outputLanguage; +export function useAISummarize( + options: UseAISummarizeOptions = {}, +): UseAISummarizeReturn { + const { + type, + format, + length, + sharedContext, + outputLanguage = 'auto', + expectedInputLanguages, + expectedContextLanguages, + preference = 'auto', + streaming = false, + warmup = false, + } = options + const [data, setData] = useState('') + const [status, setStatus] = useState('idle') + const [progress, setProgress] = useState<{ + loaded: number + total: number + } | null>(null) + const [error, setError] = useState(null) + + const summarizerRef = useRef(null) + const abortControllerRef = useRef(null) + + const reset = useCallback(() => { + setData('') + setStatus('idle') + setProgress(null) + setError(null) + if (abortControllerRef.current) { + abortControllerRef.current.abort() + abortControllerRef.current = null } - - const summarizer = await createSummarizer(resolvedLanguage); - setStatus('summarizing'); - - abortControllerRef.current = new AbortController(); - - const options: { signal: AbortSignal; context?: string } = { signal: abortControllerRef.current.signal }; - if (context) { - options.context = context; + }, []) + + const createSummarizer = useCallback( + async (overrideOutputLanguage?: string) => { + if (summarizerRef.current) return summarizerRef.current + + // Chrome native API: Summarizer is a global constructor + if ( + typeof window === 'undefined' || + typeof (window as unknown as { Summarizer?: unknown }) + .Summarizer !== 'function' + ) { + throw new Error('Summarizer API not supported in this browser') + } + + const Summarizer = ( + window as unknown as { + Summarizer: { + availability?: () => Promise + create?: ( + options: SummarizerCreateOptions, + ) => Promise + } + } + ).Summarizer + + // Ensure we're not dealing with base constructors + if ( + Summarizer === (Object as any) || + Summarizer === (Array as any) || + Summarizer === (Function as any) + ) { + throw new Error('Summarizer is not available') + } + + // Check availability + if (typeof Summarizer.availability === 'function') { + const avail = await Summarizer.availability() + if (avail === 'unavailable') { + throw new Error('Summarizer is not available') + } + if (avail === 'downloading' || avail === 'downloadable') { + setStatus('downloading') + } else { + setStatus('initializing') + } + } + + // Check user activation (required by Chrome) + if ( + typeof navigator !== 'undefined' && + 'userActivation' in navigator && + !( + navigator as unknown as { + userActivation?: { isActive: boolean } + } + ).userActivation?.isActive + ) { + throw new Error( + 'User activation required. Please interact with the page first.', + ) + } + + if (typeof Summarizer.create !== 'function') { + throw new Error('Summarizer.create is not available') + } + + const resolvedOutputLanguage = + overrideOutputLanguage || outputLanguage + + const instance = await Summarizer.create({ + type, + format, + length, + sharedContext, + outputLanguage: resolvedOutputLanguage, + expectedInputLanguages, + expectedContextLanguages, + preference, + monitor(m: AICreateMonitor) { + m.addEventListener('downloadprogress', (e: Event) => { + const progressEvent = e as ProgressEvent + setProgress({ + loaded: progressEvent.loaded, + total: progressEvent.total, + }) + }) + }, + }) + + summarizerRef.current = instance + return instance + }, + [ + type, + format, + length, + sharedContext, + outputLanguage, + expectedInputLanguages, + expectedContextLanguages, + preference, + ], + ) + + const summarize = useCallback( + async (text: string, context?: string) => { + if ( + status === 'summarizing' || + status === 'initializing' || + status === 'downloading' + ) { + return + } + + setError(null) + setData('') + + try { + // Resolve outputLanguage based on 'auto' or 'user' settings + let resolvedLanguage: string + if (outputLanguage === 'auto') { + resolvedLanguage = await detectLanguageFromText(text) + } else if (outputLanguage === 'user') { + resolvedLanguage = getUserLanguage() + } else { + resolvedLanguage = outputLanguage + } + + const summarizer = await createSummarizer(resolvedLanguage) + setStatus('summarizing') + + abortControllerRef.current = new AbortController() + + const options: { signal: AbortSignal; context?: string } = { + signal: abortControllerRef.current.signal, + } + if (context) { + options.context = context + } + + if (streaming) { + const stream = summarizer.summarizeStreaming(text, options) + // @ts-expect-error - ReadableStream is async iterable in many environments + for await (const chunk of stream) { + // The Summarizer API returns incremental chunks, accumulate them + setData((prev) => prev + chunk) + } + setStatus('success') + } else { + const result = await summarizer.summarize(text, options) + setData(result) + setStatus('success') + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + setStatus('idle') + return + } + setError( + err instanceof Error + ? err + : new Error('Unknown error during summarization'), + ) + setStatus('error') + } + }, + [status, streaming, createSummarizer, outputLanguage], + ) + + useEffect(() => { + if (warmup) { + // For warmup, use 'en' as default if outputLanguage is 'auto' to avoid Chrome API error + const warmupLanguage = + outputLanguage === 'auto' ? 'en' : outputLanguage + createSummarizer(warmupLanguage) + .then(() => setStatus('idle')) + .catch((err) => { + console.error('Failed to warmup summarizer:', err) + }) } - if (streaming) { - const stream = summarizer.summarizeStreaming(text, options); - // @ts-expect-error - ReadableStream is async iterable in many environments - for await (const chunk of stream) { - // The Summarizer API returns incremental chunks, accumulate them - setData(prev => prev + chunk); - } - setStatus('success'); - } else { - const result = await summarizer.summarize(text, options); - setData(result); - setStatus('success'); - } - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - setStatus('idle'); - return; + return () => { + if (summarizerRef.current) { + summarizerRef.current.destroy() + summarizerRef.current = null + } + if (abortControllerRef.current) { + abortControllerRef.current.abort() + abortControllerRef.current = null + } } - setError(err instanceof Error ? err : new Error('Unknown error during summarization')); - setStatus('error'); - } - }, - [status, streaming, createSummarizer, outputLanguage] - ); - - useEffect(() => { - if (warmup) { - // For warmup, use 'en' as default if outputLanguage is 'auto' to avoid Chrome API error - const warmupLanguage = outputLanguage === 'auto' ? 'en' : outputLanguage; - createSummarizer(warmupLanguage).then(() => setStatus('idle')).catch((err) => { - console.error('Failed to warmup summarizer:', err); - }); + }, [warmup, createSummarizer, outputLanguage]) + + return { + data, + status, + progress, + error, + summarize, + reset, } - - return () => { - if (summarizerRef.current) { - summarizerRef.current.destroy(); - summarizerRef.current = null; - } - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }; - }, [warmup, createSummarizer, outputLanguage]); - - return { - data, - status, - progress, - error, - summarize, - reset, - }; }