diff --git a/.axioma/quality.md b/.axioma/quality.md index 2085bb3..1a32978 100644 --- a/.axioma/quality.md +++ b/.axioma/quality.md @@ -65,3 +65,7 @@ ## 2024-06-21 - [Testing Async Lifecycle and Unmount Safety] **Learning:** Testing components with asynchronous logic (like promises or timeouts) requires verifying that side-effect callbacks (e.g., `onSuccess`, `onError`) are NOT invoked if the component unmounts before the operation completes. This ensures the implementation correctly uses `isMounted` refs or `AbortController` to prevent state updates on unmounted components. **Action:** Always include "unmount during pending" and "unmount during timeout" test cases for components managing asynchronous lifecycles. + +## 2024-06-26 - [Verifying Polymorphic Type Inference] +**Learning:** Hooks that perform automatic type inference (like `useAIPrompt` for multimodal inputs) should have explicit unit tests for each supported input type (e.g., `ArrayBuffer`, `Blob`, `string[]`). Relying only on happy-path text tests can leave type-specific logic uncovered and prone to regressions. +**Action:** Always include a dedicated test case for each supported input type in internal normalization or inference functions to ensure full branch coverage and robustness. diff --git a/lib/hooks/useAIPrompt.test.ts b/lib/hooks/useAIPrompt.test.ts index 17db038..3bb3f9d 100644 --- a/lib/hooks/useAIPrompt.test.ts +++ b/lib/hooks/useAIPrompt.test.ts @@ -1,290 +1,373 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act, waitFor } from '@testing-library/react'; -import { useAIPrompt, AIPromptMessage } from './useAIPrompt'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useAIPrompt, AIPromptMessage } from './useAIPrompt' describe('useAIPrompt', () => { - const mockSession = { - prompt: vi.fn(), - promptStreaming: vi.fn(), - append: vi.fn(), - destroy: vi.fn(), - addEventListener: vi.fn(), - contextUsage: 10, - contextWindow: 1000, - }; - - const mockLanguageModel = { - availability: vi.fn(), - create: vi.fn(), - }; - - beforeEach(() => { - vi.stubGlobal('ai', { - languageModel: mockLanguageModel, - }); - - // Also mock window.ai for implementation that uses (window as any).ai - if (typeof window !== 'undefined') { - (window as any).ai = { - languageModel: mockLanguageModel, - }; + const mockSession = { + prompt: vi.fn(), + promptStreaming: vi.fn(), + append: vi.fn(), + destroy: vi.fn(), + addEventListener: vi.fn(), + contextUsage: 10, + contextWindow: 1000, } - mockLanguageModel.availability.mockResolvedValue('readily'); - mockLanguageModel.create.mockResolvedValue(mockSession); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.clearAllMocks(); - if (typeof window !== 'undefined') { - delete (window as any).ai; - delete (window as any).LanguageModel; + const mockLanguageModel = { + availability: vi.fn(), + create: vi.fn(), } - }); - - it('should initialize with idle status', () => { - const { result } = renderHook(() => useAIPrompt()); - expect(result.current.status).toBe('idle'); - expect(result.current.data).toBe(''); - }); - - it('should pass through prompting status when calling prompt', async () => { - mockSession.prompt.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve('response'), 50))); - const { result } = renderHook(() => useAIPrompt()); - - act(() => { - result.current.prompt('hello'); - }); - - await waitFor(() => expect(result.current.status).toBe('prompting')); - await waitFor(() => expect(result.current.status).toBe('success'), { timeout: 2000 }); - expect(result.current.data).toBe('response'); - expect(result.current.contextUsage).toBe(10); - expect(result.current.contextWindow).toBe(1000); - }); - - it('should handle streaming with cumulative chunks', async () => { - // Chrome API returns incremental chunks, hook accumulates them - const chunks = ['Hello', ' ', 'world', '!']; - const mockStream = { - [Symbol.asyncIterator]: async function* () { - for (const chunk of chunks) { - yield chunk + + beforeEach(() => { + vi.stubGlobal('ai', { + languageModel: mockLanguageModel, + }) + + // Also mock window.ai for implementation that uses (window as any).ai + if (typeof window !== 'undefined') { + ;(window as any).ai = { + languageModel: mockLanguageModel, + } } - }, - } - mockSession.promptStreaming.mockReturnValue(mockStream); - - const { result } = renderHook(() => useAIPrompt({ streaming: true })); - - await act(async () => { - await result.current.prompt('hello'); - }); - - expect(result.current.data).toBe('Hello world!'); - expect(result.current.status).toBe('success'); - }); - - it('should call destroy on unmount', async () => { - vi.stubGlobal('navigator', { - userActivation: { isActive: true }, - }); - const { unmount } = renderHook(() => useAIPrompt({ warmup: true })); - await waitFor(() => expect(mockLanguageModel.create).toHaveBeenCalled()); - unmount(); - expect(mockSession.destroy).toHaveBeenCalled(); - }); - - it('should handle errors during prompting', async () => { - const error = new Error('Prompt failed'); - mockSession.prompt.mockRejectedValue(error); - - const { result } = renderHook(() => useAIPrompt()); - - await act(async () => { - await result.current.prompt('hello'); - }); - - expect(result.current.status).toBe('error'); - expect(result.current.error).toBe(error); - }); - - it('should handle availability "after-download"', async () => { - mockLanguageModel.availability.mockResolvedValue('after-download'); - - // Delay session creation to ensure we can catch the downloading status - mockLanguageModel.create.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(mockSession), 100))); - mockSession.prompt.mockResolvedValue('response'); - - const { result } = renderHook(() => useAIPrompt()); - - act(() => { - result.current.prompt('hello'); - }); - - await waitFor(() => { - expect(result.current.status).toBe('downloading'); - }); - await waitFor(() => expect(result.current.status).toBe('success'), { timeout: 2000 }); - }); - - it('should respect warmup option', async () => { - vi.stubGlobal('navigator', { - userActivation: { isActive: true }, - }); - renderHook(() => useAIPrompt({ warmup: true })); - await waitFor(() => expect(mockLanguageModel.create).toHaveBeenCalled()); - }); - - it('should reset state correctly', async () => { - mockSession.prompt.mockResolvedValue('response'); - const { result } = renderHook(() => useAIPrompt()); - - await act(async () => { - await result.current.prompt('hello'); - }); - - expect(result.current.data).toBe('response'); - - act(() => { - result.current.reset(); - }); - - expect(result.current.data).toBe(''); - expect(result.current.status).toBe('idle'); - expect(mockSession.destroy).toHaveBeenCalled(); - }); - - it('should handle append method correctly', async () => { - const { result } = renderHook(() => useAIPrompt()); - const messages: AIPromptMessage[] = [ - { role: 'user', content: 'context message' } - ]; - - await act(async () => { - await result.current.append(messages); - }); - - expect(mockSession.append).toHaveBeenCalledWith([ - { - role: 'user', - content: [{ type: 'text', value: 'context message' }] - } - ]); - }); - - it('should handle errors in append method', async () => { - const error = new Error('Append failed'); - mockSession.append.mockRejectedValue(error); - const { result } = renderHook(() => useAIPrompt()); - - await act(async () => { - await result.current.append([{ role: 'user', content: 'test' }]); - }); - - expect(result.current.status).toBe('error'); - expect(result.current.error).toBe(error); - }); - - it('should handle multimodal input in prompt', async () => { - mockSession.prompt.mockResolvedValue('I see an image'); - const { result } = renderHook(() => useAIPrompt({ warmup: false })); - - const blob = new Blob(['image data'], { type: 'image/png' }); - const messages: AIPromptMessage[] = [ - { - role: 'user', - content: ['What is in this image?', blob] - } - ]; - - await act(async () => { - await result.current.prompt(messages); - }); - - await waitFor(() => expect(result.current.status).toBe('success')); - - expect(mockSession.prompt).toHaveBeenCalledWith( - [ - { - role: 'user', - content: [ - { type: 'text', value: 'What is in this image?' }, - { type: 'image', value: blob } - ] + + mockLanguageModel.availability.mockResolvedValue('readily') + mockLanguageModel.create.mockResolvedValue(mockSession) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + if (typeof window !== 'undefined') { + delete (window as any).ai + delete (window as any).LanguageModel + } + }) + + it('should initialize with idle status', () => { + const { result } = renderHook(() => useAIPrompt()) + expect(result.current.status).toBe('idle') + expect(result.current.data).toBe('') + }) + + it('should pass through prompting status when calling prompt', async () => { + mockSession.prompt.mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => resolve('response'), 50), + ), + ) + const { result } = renderHook(() => useAIPrompt()) + + act(() => { + result.current.prompt('hello') + }) + + await waitFor(() => expect(result.current.status).toBe('prompting')) + await waitFor(() => expect(result.current.status).toBe('success'), { + timeout: 2000, + }) + expect(result.current.data).toBe('response') + expect(result.current.contextUsage).toBe(10) + expect(result.current.contextWindow).toBe(1000) + }) + + it('should handle streaming with cumulative chunks', async () => { + // Chrome API returns incremental chunks, hook accumulates them + const chunks = ['Hello', ' ', 'world', '!'] + const mockStream = { + [Symbol.asyncIterator]: async function* () { + for (const chunk of chunks) { + yield chunk + } + }, } - ], - expect.any(Object) - ); - }); - - it('should fail if user activation is missing', async () => { - vi.stubGlobal('navigator', { - userActivation: { isActive: false }, - }); - - const { result } = renderHook(() => useAIPrompt({ warmup: false })); - - await act(async () => { - await result.current.prompt('hello'); - }); - - expect(result.current.status).toBe('error'); - expect(result.current.error?.message).toContain('User activation required'); - }); - - it('should handle download progress', async () => { - mockLanguageModel.availability.mockResolvedValue('after-download'); - - let monitorCallback: ((m: any) => void) | undefined; - mockLanguageModel.create.mockImplementation((options: any) => { - monitorCallback = options.monitor; - return new Promise((resolve) => { - setTimeout(() => resolve(mockSession), 100); - }); - }); - - const { result } = renderHook(() => useAIPrompt()); - - act(() => { - result.current.prompt('hello'); - }); - - await waitFor(() => expect(result.current.status).toBe('downloading')); - - const eventListeners = new Map void>(); - const mockMonitor = { - addEventListener: (event: string, callback: (e: any) => void) => { - eventListeners.set(event, callback); - }, - }; - - await waitFor(() => expect(monitorCallback).toBeDefined()); - monitorCallback?.(mockMonitor); - - act(() => { - const progressEvent = { loaded: 50, total: 100 }; - const callback = eventListeners.get('downloadprogress'); - callback?.(progressEvent); - }); - - expect(result.current.progress).toEqual({ loaded: 50, total: 100 }); - }); - - it('should handle contextoverflow event', async () => { - vi.stubGlobal('navigator', { - userActivation: { isActive: true }, - }); - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - renderHook(() => useAIPrompt({ warmup: true })); - - await waitFor(() => expect(mockSession.addEventListener).toHaveBeenCalledWith('contextoverflow', expect.any(Function))); - - const overflowCallback = mockSession.addEventListener.mock.calls.find(call => call[0] === 'contextoverflow')[1]; - overflowCallback(); - - expect(consoleSpy).toHaveBeenCalledWith('AI Prompt context window overflowed'); - consoleSpy.mockRestore(); - }); -}); + mockSession.promptStreaming.mockReturnValue(mockStream) + + const { result } = renderHook(() => useAIPrompt({ streaming: true })) + + await act(async () => { + await result.current.prompt('hello') + }) + + expect(result.current.data).toBe('Hello world!') + expect(result.current.status).toBe('success') + }) + + it('should call destroy on unmount', async () => { + vi.stubGlobal('navigator', { + userActivation: { isActive: true }, + }) + const { unmount } = renderHook(() => useAIPrompt({ warmup: true })) + await waitFor(() => expect(mockLanguageModel.create).toHaveBeenCalled()) + unmount() + expect(mockSession.destroy).toHaveBeenCalled() + }) + + it('should handle errors during prompting', async () => { + const error = new Error('Prompt failed') + mockSession.prompt.mockRejectedValue(error) + + const { result } = renderHook(() => useAIPrompt()) + + await act(async () => { + await result.current.prompt('hello') + }) + + expect(result.current.status).toBe('error') + expect(result.current.error).toBe(error) + }) + + it('should handle availability "after-download"', async () => { + mockLanguageModel.availability.mockResolvedValue('after-download') + + // Delay session creation to ensure we can catch the downloading status + mockLanguageModel.create.mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => resolve(mockSession), 100), + ), + ) + mockSession.prompt.mockResolvedValue('response') + + const { result } = renderHook(() => useAIPrompt()) + + act(() => { + result.current.prompt('hello') + }) + + await waitFor(() => { + expect(result.current.status).toBe('downloading') + }) + await waitFor(() => expect(result.current.status).toBe('success'), { + timeout: 2000, + }) + }) + + it('should respect warmup option', async () => { + vi.stubGlobal('navigator', { + userActivation: { isActive: true }, + }) + renderHook(() => useAIPrompt({ warmup: true })) + await waitFor(() => expect(mockLanguageModel.create).toHaveBeenCalled()) + }) + + it('should reset state correctly', async () => { + mockSession.prompt.mockResolvedValue('response') + const { result } = renderHook(() => useAIPrompt()) + + await act(async () => { + await result.current.prompt('hello') + }) + + expect(result.current.data).toBe('response') + + act(() => { + result.current.reset() + }) + + expect(result.current.data).toBe('') + expect(result.current.status).toBe('idle') + expect(mockSession.destroy).toHaveBeenCalled() + }) + + it('should handle append method correctly', async () => { + const { result } = renderHook(() => useAIPrompt()) + const messages: AIPromptMessage[] = [ + { role: 'user', content: 'context message' }, + ] + + await act(async () => { + await result.current.append(messages) + }) + + expect(mockSession.append).toHaveBeenCalledWith([ + { + role: 'user', + content: [{ type: 'text', value: 'context message' }], + }, + ]) + }) + + it('should handle errors in append method', async () => { + const error = new Error('Append failed') + mockSession.append.mockRejectedValue(error) + const { result } = renderHook(() => useAIPrompt()) + + await act(async () => { + await result.current.append([{ role: 'user', content: 'test' }]) + }) + + expect(result.current.status).toBe('error') + expect(result.current.error).toBe(error) + }) + + it('should handle multimodal input in prompt', async () => { + mockSession.prompt.mockResolvedValue('I see an image') + const { result } = renderHook(() => useAIPrompt({ warmup: false })) + + const blob = new Blob(['image data'], { type: 'image/png' }) + const messages: AIPromptMessage[] = [ + { + role: 'user', + content: ['What is in this image?', blob], + }, + ] + + await act(async () => { + await result.current.prompt(messages) + }) + + await waitFor(() => expect(result.current.status).toBe('success')) + + expect(mockSession.prompt).toHaveBeenCalledWith( + [ + { + role: 'user', + content: [ + { type: 'text', value: 'What is in this image?' }, + { type: 'image', value: blob }, + ], + }, + ], + expect.any(Object), + ) + }) + + it('should correctly infer audio type for ArrayBuffer', async () => { + mockSession.prompt.mockResolvedValue('Processed audio') + const { result } = renderHook(() => useAIPrompt()) + + const buffer = new ArrayBuffer(8) + const messages: AIPromptMessage[] = [ + { + role: 'user', + content: buffer, + }, + ] + + await act(async () => { + await result.current.prompt(messages) + }) + + expect(mockSession.prompt).toHaveBeenCalledWith( + [ + { + role: 'user', + content: [{ type: 'audio', value: buffer }], + }, + ], + expect.any(Object), + ) + }) + + it('should handle array of strings in content correctly', async () => { + mockSession.prompt.mockResolvedValue('Combined response') + const { result } = renderHook(() => useAIPrompt()) + + const messages: AIPromptMessage[] = [ + { + role: 'user', + content: ['Part 1', 'Part 2'], + }, + ] + + await act(async () => { + await result.current.prompt(messages) + }) + + expect(mockSession.prompt).toHaveBeenCalledWith( + [ + { + role: 'user', + content: [ + { type: 'text', value: 'Part 1' }, + { type: 'text', value: 'Part 2' }, + ], + }, + ], + expect.any(Object), + ) + }) + + it('should fail if user activation is missing', async () => { + vi.stubGlobal('navigator', { + userActivation: { isActive: false }, + }) + + const { result } = renderHook(() => useAIPrompt({ warmup: false })) + + await act(async () => { + await result.current.prompt('hello') + }) + + expect(result.current.status).toBe('error') + expect(result.current.error?.message).toContain( + 'User activation required', + ) + }) + + it('should handle download progress', async () => { + mockLanguageModel.availability.mockResolvedValue('after-download') + + let monitorCallback: ((m: any) => void) | undefined + mockLanguageModel.create.mockImplementation((options: any) => { + monitorCallback = options.monitor + return new Promise((resolve) => { + setTimeout(() => resolve(mockSession), 100) + }) + }) + + const { result } = renderHook(() => useAIPrompt()) + + act(() => { + result.current.prompt('hello') + }) + + await waitFor(() => expect(result.current.status).toBe('downloading')) + + const eventListeners = new Map void>() + const mockMonitor = { + addEventListener: (event: string, callback: (e: any) => void) => { + eventListeners.set(event, callback) + }, + } + + await waitFor(() => expect(monitorCallback).toBeDefined()) + monitorCallback?.(mockMonitor) + + act(() => { + const progressEvent = { loaded: 50, total: 100 } + const callback = eventListeners.get('downloadprogress') + callback?.(progressEvent) + }) + + expect(result.current.progress).toEqual({ loaded: 50, total: 100 }) + }) + + it('should handle contextoverflow event', async () => { + vi.stubGlobal('navigator', { + userActivation: { isActive: true }, + }) + const consoleSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}) + renderHook(() => useAIPrompt({ warmup: true })) + + await waitFor(() => + expect(mockSession.addEventListener).toHaveBeenCalledWith( + 'contextoverflow', + expect.any(Function), + ), + ) + + const overflowCallback = mockSession.addEventListener.mock.calls.find( + (call) => call[0] === 'contextoverflow', + )[1] + overflowCallback() + + expect(consoleSpy).toHaveBeenCalledWith( + 'AI Prompt context window overflowed', + ) + consoleSpy.mockRestore() + }) +}) diff --git a/lib/hooks/useAIPrompt.ts b/lib/hooks/useAIPrompt.ts index 05b3950..e1c6bf1 100644 --- a/lib/hooks/useAIPrompt.ts +++ b/lib/hooks/useAIPrompt.ts @@ -1,126 +1,199 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react' /** * Role of a participant in a conversation. */ -export type AIPromptRole = 'system' | 'user' | 'assistant'; +export type AIPromptRole = 'system' | 'user' | 'assistant' /** * Simplified content type for automatic type inference. */ -export type AIPromptContentSimple = string | - AudioBuffer | ArrayBufferView | ArrayBuffer | Blob | - HTMLImageElement | SVGImageElement | HTMLVideoElement | - HTMLCanvasElement | ImageBitmap | OffscreenCanvas | - VideoFrame | ImageData; +export type AIPromptContentSimple = + | string + | AudioBuffer + | ArrayBufferView + | ArrayBuffer + | Blob + | HTMLImageElement + | SVGImageElement + | HTMLVideoElement + | HTMLCanvasElement + | ImageBitmap + | OffscreenCanvas + | VideoFrame + | ImageData /** * Internal content type for Chrome Prompt API (type + value). */ export interface AIPromptContentInternal { - type: 'text' | 'audio' | 'image'; - value: AIPromptContentSimple; + type: 'text' | 'audio' | 'image' + value: AIPromptContentSimple } /** * Infer content type from value for automatic type detection. + * + * @param value - The content value to infer the type from. + * @returns The inferred content type ('text', 'audio', or 'image'). + * @internal + * + * @example + * ```ts + * const type = inferContentType('Hello world'); // 'text' + * const type = inferContentType(new ArrayBuffer(10)); // 'audio' + * ``` */ -function inferContentType(value: AIPromptContentSimple): 'text' | 'audio' | 'image' { - if (typeof value === 'string') return 'text'; - - // Audio types - if (typeof AudioBuffer !== 'undefined' && value instanceof AudioBuffer) return 'audio'; - if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return 'audio'; - - // Blob: check MIME type - if (value instanceof Blob) { - if (value.type.startsWith('audio/')) return 'audio'; - return 'image'; // fallback for images or blobs without clear type - } - - // Visual elements - if (typeof HTMLImageElement !== 'undefined' && value instanceof HTMLImageElement) return 'image'; - if (typeof SVGImageElement !== 'undefined' && value instanceof SVGImageElement) return 'image'; - if (typeof HTMLVideoElement !== 'undefined' && value instanceof HTMLVideoElement) return 'image'; - if (typeof HTMLCanvasElement !== 'undefined' && value instanceof HTMLCanvasElement) return 'image'; - if (typeof ImageBitmap !== 'undefined' && value instanceof ImageBitmap) return 'image'; - if (typeof OffscreenCanvas !== 'undefined' && value instanceof OffscreenCanvas) return 'image'; - if (typeof VideoFrame !== 'undefined' && value instanceof VideoFrame) return 'image'; - if (typeof ImageData !== 'undefined' && value instanceof ImageData) return 'image'; - - return 'text'; +function inferContentType( + value: AIPromptContentSimple, +): 'text' | 'audio' | 'image' { + if (typeof value === 'string') return 'text' + + // Audio types + if (typeof AudioBuffer !== 'undefined' && value instanceof AudioBuffer) + return 'audio' + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) + return 'audio' + + // Blob: check MIME type + if (value instanceof Blob) { + if (value.type.startsWith('audio/')) return 'audio' + return 'image' // fallback for images or blobs without clear type + } + + // Visual elements + if ( + typeof HTMLImageElement !== 'undefined' && + value instanceof HTMLImageElement + ) + return 'image' + if ( + typeof SVGImageElement !== 'undefined' && + value instanceof SVGImageElement + ) + return 'image' + if ( + typeof HTMLVideoElement !== 'undefined' && + value instanceof HTMLVideoElement + ) + return 'image' + if ( + typeof HTMLCanvasElement !== 'undefined' && + value instanceof HTMLCanvasElement + ) + return 'image' + if (typeof ImageBitmap !== 'undefined' && value instanceof ImageBitmap) + return 'image' + if ( + typeof OffscreenCanvas !== 'undefined' && + value instanceof OffscreenCanvas + ) + return 'image' + if (typeof VideoFrame !== 'undefined' && value instanceof VideoFrame) + return 'image' + if (typeof ImageData !== 'undefined' && value instanceof ImageData) + return 'image' + + return 'text' } /** * Normalize content to Chrome Prompt API format with automatic type inference. + * + * @param content - The content (single item or array) to normalize. + * @returns An array of internal content objects with inferred types. + * @internal + * + * @example + * ```ts + * const normalized = normalizeContent('Hello world'); + * // [{ type: 'text', value: 'Hello world' }] + * + * const normalized = normalizeContent(['Hello', new ArrayBuffer(10)]); + * // [{ type: 'text', value: 'Hello' }, { type: 'audio', value: ArrayBuffer }] + * ``` */ -function normalizeContent(content: AIPromptContentSimple | AIPromptContentSimple[]): AIPromptContentInternal[] { - const items = Array.isArray(content) ? content : [content]; - return items.map(value => ({ - type: inferContentType(value), - value - })); +function normalizeContent( + content: AIPromptContentSimple | AIPromptContentSimple[], +): AIPromptContentInternal[] { + const items = Array.isArray(content) ? content : [content] + return items.map((value) => ({ + type: inferContentType(value), + value, + })) } /** * A message in a conversation. */ export interface AIPromptMessage { - /** The role of the message sender */ - role: AIPromptRole; - /** The content of the message (text or multimodal) */ - content: AIPromptContentSimple | AIPromptContentSimple[]; + /** The role of the message sender */ + role: AIPromptRole + /** The content of the message (text or multimodal) */ + content: AIPromptContentSimple | AIPromptContentSimple[] } /** * Options for the useAIPrompt hook. */ export interface UseAIPromptOptions { - /** Initial prompts to provide context to the model */ - initialPrompts?: AIPromptMessage[]; - /** Temperature for sampling (higher is more creative) */ - temperature?: number; - /** Top-K sampling parameter */ - topK?: number; - /** Enable streaming output for real-time results */ - streaming?: boolean; - /** Preload the model on component mount (default: false) */ - warmup?: boolean; - /** Expected input types for multimodal support */ - expectedInputs?: { type: 'text' | 'audio' | 'image'; languages?: string[] }[]; - /** Expected output types for multimodal support */ - expectedOutputs?: { type: 'text' | 'audio' | 'image'; languages?: string[] }[]; - /** Output language for the model (e.g., 'en', 'es', 'ja') */ - outputLanguage?: string; + /** Initial prompts to provide context to the model */ + initialPrompts?: AIPromptMessage[] + /** Temperature for sampling (higher is more creative) */ + temperature?: number + /** Top-K sampling parameter */ + topK?: number + /** Enable streaming output for real-time results */ + streaming?: boolean + /** Preload the model on component mount (default: false) */ + warmup?: boolean + /** Expected input types for multimodal support */ + expectedInputs?: { + type: 'text' | 'audio' | 'image' + languages?: string[] + }[] + /** Expected output types for multimodal support */ + expectedOutputs?: { + type: 'text' | 'audio' | 'image' + languages?: string[] + }[] + /** Output language for the model (e.g., 'en', 'es', 'ja') */ + outputLanguage?: string } /** * Status of the AI prompt process. */ -export type AIPromptStatus = 'idle' | 'initializing' | 'downloading' | 'prompting' | 'success' | 'error'; +export type AIPromptStatus = + | 'idle' + | 'initializing' + | 'downloading' + | 'prompting' + | 'success' + | 'error' /** * Result object returned by the useAIPrompt hook. */ export interface UseAIPromptResult { - /** The response data from the AI */ - data: string; - /** Current status of the prompt process */ - status: AIPromptStatus; - /** Download progress if model is being downloaded */ - progress: { loaded: number; total: number } | null; - /** Error object if prompting failed */ - error: Error | null; - /** Function to send a prompt to the AI */ - prompt: (input: string | AIPromptMessage[]) => Promise; - /** Function to append contextual messages without generating response */ - append: (messages: AIPromptMessage[]) => Promise; - /** Function to reset the hook state */ - reset: () => void; - /** Number of tokens used in the current session */ - contextUsage: number; - /** Maximum number of tokens allowed in the session */ - contextWindow: number; + /** The response data from the AI */ + data: string + /** Current status of the prompt process */ + status: AIPromptStatus + /** Download progress if model is being downloaded */ + progress: { loaded: number; total: number } | null + /** Error object if prompting failed */ + error: Error | null + /** Function to send a prompt to the AI */ + prompt: (input: string | AIPromptMessage[]) => Promise + /** Function to append contextual messages without generating response */ + append: (messages: AIPromptMessage[]) => Promise + /** Function to reset the hook state */ + reset: () => void + /** Number of tokens used in the current session */ + contextUsage: number + /** Maximum number of tokens allowed in the session */ + contextWindow: number } /** @@ -159,196 +232,244 @@ export interface UseAIPromptResult { * @param options - Configuration for the Prompt API session * @returns An object with state and functions to interact with the model */ -export function useAIPrompt(options: UseAIPromptOptions = {}): UseAIPromptResult { - const { initialPrompts, temperature, topK, streaming = false, warmup = false, expectedInputs, expectedOutputs, outputLanguage } = 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 [contextUsage, setContextUsage] = useState(0); - const [contextWindow, setContextWindow] = useState(0); - - const sessionRef = useRef(null); - const abortControllerRef = useRef(null); - - const reset = useCallback(() => { - setData(''); - setStatus('idle'); - setProgress(null); - setError(null); - setContextUsage(0); - setContextWindow(0); - if (sessionRef.current) { - sessionRef.current.destroy(); - sessionRef.current = null; - } - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }, []); +export function useAIPrompt( + options: UseAIPromptOptions = {}, +): UseAIPromptResult { + const { + initialPrompts, + temperature, + topK, + streaming = false, + warmup = false, + expectedInputs, + expectedOutputs, + outputLanguage, + } = 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 [contextUsage, setContextUsage] = useState(0) + const [contextWindow, setContextWindow] = useState(0) + + const sessionRef = useRef(null) + const abortControllerRef = useRef(null) + + const reset = useCallback(() => { + setData('') + setStatus('idle') + setProgress(null) + setError(null) + setContextUsage(0) + setContextWindow(0) + if (sessionRef.current) { + sessionRef.current.destroy() + sessionRef.current = null + } + if (abortControllerRef.current) { + abortControllerRef.current.abort() + abortControllerRef.current = null + } + }, []) - const createSession = useCallback(async () => { - if (sessionRef.current) return sessionRef.current; + const createSession = useCallback(async () => { + if (sessionRef.current) return sessionRef.current - if (typeof window === 'undefined') { - throw new Error('Prompt API is only available in the browser'); - } + if (typeof window === 'undefined') { + throw new Error('Prompt API is only available in the browser') + } - // Support both window.ai.languageModel and the global LanguageModel - const ai = (window as any).ai; - const LanguageModel = (window as any).LanguageModel || ai?.languageModel || (window as any).ai?.LanguageModel; + // Support both window.ai.languageModel and the global LanguageModel + const ai = (window as any).ai + const LanguageModel = + (window as any).LanguageModel || + ai?.languageModel || + (window as any).ai?.LanguageModel - if (!LanguageModel) { - throw new Error('Prompt API not supported in this browser'); - } - - // Ensure we're not dealing with base constructors - if ( - LanguageModel === Object || - LanguageModel === Array || - LanguageModel === Function - ) { - throw new Error('Prompt API is not available'); - } + if (!LanguageModel) { + throw new Error('Prompt API not supported in this browser') + } - // Check availability - if (typeof LanguageModel.availability === 'function') { - const avail = await LanguageModel.availability(); - // Log availability result for debugging - console.log('LanguageModel.availability() result:', avail); - if (avail === 'unavailable') { - throw new Error('Prompt API is not available'); - } - if (avail === 'downloading' || avail === 'after-download') { - setStatus('downloading'); - } else { - setStatus('initializing'); - } - } + // Ensure we're not dealing with base constructors + if ( + LanguageModel === Object || + LanguageModel === Array || + LanguageModel === Function + ) { + throw new Error('Prompt API is not available') + } - // Check user activation (required by Chrome for built-in AI APIs) - if (typeof navigator !== 'undefined' && 'userActivation' in navigator && !(navigator as any).userActivation?.isActive) { - throw new Error('User activation required. Please interact with the page first.'); - } + // Check availability + if (typeof LanguageModel.availability === 'function') { + const avail = await LanguageModel.availability() + // Log availability result for debugging + console.log('LanguageModel.availability() result:', avail) + if (avail === 'unavailable') { + throw new Error('Prompt API is not available') + } + if (avail === 'downloading' || avail === 'after-download') { + setStatus('downloading') + } else { + setStatus('initializing') + } + } - const instance = await LanguageModel.create({ - initialPrompts, - temperature, - topK, - expectedInputs, - expectedOutputs, - outputLanguage, - monitor(m: any) { - m.addEventListener('downloadprogress', (e: any) => { - setProgress({ loaded: e.loaded, total: e.total }); - }); - }, - }); - - sessionRef.current = instance; - setContextUsage(instance.contextUsage || 0); - setContextWindow(instance.contextWindow || 0); - - // Listen for context overflow - instance.addEventListener?.('contextoverflow', () => { - console.warn('AI Prompt context window overflowed'); - }); - - return instance; - }, [initialPrompts, temperature, topK]); - - const prompt = useCallback(async (input: string | AIPromptMessage[]) => { - if (status === 'prompting' || status === 'initializing' || status === 'downloading') { - return; - } + // Check user activation (required by Chrome for built-in AI APIs) + if ( + typeof navigator !== 'undefined' && + 'userActivation' in navigator && + !(navigator as any).userActivation?.isActive + ) { + throw new Error( + 'User activation required. Please interact with the page first.', + ) + } - setError(null); - setData(''); - - try { - const session = await createSession(); - setStatus('prompting'); - - abortControllerRef.current = new AbortController(); - - // Normalize input to Chrome API format - const normalizedInput = typeof input === 'string' - ? input - : input.map(msg => ({ - role: msg.role, - content: normalizeContent(msg.content) - })); - - if (streaming) { - const stream = session.promptStreaming(normalizedInput, { signal: abortControllerRef.current.signal }); - for await (const chunk of stream) { - // Chrome Prompt API returns INCREMENTAL chunks (each chunk = new text only) - // We MUST accumulate them: setData(prev => prev + chunk) - // DO NOT change to setData(chunk) - this would break streaming! - // See .axioma/sentinel.md for context on incorrect assumptions - setData(prev => prev + chunk); - setContextUsage(session.contextUsage || 0); + const instance = await LanguageModel.create({ + initialPrompts, + temperature, + topK, + expectedInputs, + expectedOutputs, + outputLanguage, + monitor(m: any) { + m.addEventListener('downloadprogress', (e: any) => { + setProgress({ loaded: e.loaded, total: e.total }) + }) + }, + }) + + sessionRef.current = instance + setContextUsage(instance.contextUsage || 0) + setContextWindow(instance.contextWindow || 0) + + // Listen for context overflow + instance.addEventListener?.('contextoverflow', () => { + console.warn('AI Prompt context window overflowed') + }) + + return instance + }, [initialPrompts, temperature, topK]) + + const prompt = useCallback( + async (input: string | AIPromptMessage[]) => { + if ( + status === 'prompting' || + status === 'initializing' || + status === 'downloading' + ) { + return + } + + setError(null) + setData('') + + try { + const session = await createSession() + setStatus('prompting') + + abortControllerRef.current = new AbortController() + + // Normalize input to Chrome API format + const normalizedInput = + typeof input === 'string' + ? input + : input.map((msg) => ({ + role: msg.role, + content: normalizeContent(msg.content), + })) + + if (streaming) { + const stream = session.promptStreaming(normalizedInput, { + signal: abortControllerRef.current.signal, + }) + for await (const chunk of stream) { + // Chrome Prompt API returns INCREMENTAL chunks (each chunk = new text only) + // We MUST accumulate them: setData(prev => prev + chunk) + // DO NOT change to setData(chunk) - this would break streaming! + // See .axioma/sentinel.md for context on incorrect assumptions + setData((prev) => prev + chunk) + setContextUsage(session.contextUsage || 0) + } + setStatus('success') + } else { + const result = await session.prompt(normalizedInput, { + signal: abortControllerRef.current.signal, + }) + setData(result) + setContextUsage(session.contextUsage || 0) + setStatus('success') + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + return + } + setError( + err instanceof Error + ? err + : new Error('Unknown error during prompting'), + ) + setStatus('error') + } + }, + [status, streaming, createSession], + ) + + useEffect(() => { + if (warmup) { + createSession() + .then(() => setStatus('idle')) + .catch((err) => { + console.error('Failed to warmup AI Prompt:', err) + setStatus('idle') + }) } - setStatus('success') - } else { - const result = await session.prompt(normalizedInput, { signal: abortControllerRef.current.signal }); - setData(result); - setContextUsage(session.contextUsage || 0); - setStatus('success'); - } - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - return; - } - setError(err instanceof Error ? err : new Error('Unknown error during prompting')); - setStatus('error'); - } - }, [status, streaming, createSession]); - - useEffect(() => { - if (warmup) { - createSession().then(() => setStatus('idle')).catch((err) => { - console.error('Failed to warmup AI Prompt:', err); - setStatus('idle'); - }); - } - return () => { - if (sessionRef.current) { - sessionRef.current.destroy(); - sessionRef.current = null; - } - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }; - }, [warmup, createSession]); - - const append = useCallback(async (messages: AIPromptMessage[]) => { - try { - const session = await createSession(); - const normalizedMessages = messages.map(msg => ({ - role: msg.role, - content: normalizeContent(msg.content) - })); - await session.append(normalizedMessages); - } catch (err) { - setError(err instanceof Error ? err : new Error('Unknown error during append')); - setStatus('error'); + return () => { + if (sessionRef.current) { + sessionRef.current.destroy() + sessionRef.current = null + } + if (abortControllerRef.current) { + abortControllerRef.current.abort() + abortControllerRef.current = null + } + } + }, [warmup, createSession]) + + const append = useCallback( + async (messages: AIPromptMessage[]) => { + try { + const session = await createSession() + const normalizedMessages = messages.map((msg) => ({ + role: msg.role, + content: normalizeContent(msg.content), + })) + await session.append(normalizedMessages) + } catch (err) { + setError( + err instanceof Error + ? err + : new Error('Unknown error during append'), + ) + setStatus('error') + } + }, + [createSession], + ) + + return { + data, + status, + progress, + error, + prompt, + append, + reset, + contextUsage, + contextWindow, } - }, [createSession]); - - return { - data, - status, - progress, - error, - prompt, - append, - reset, - contextUsage, - contextWindow, - }; }