diff --git a/apps/readest-app/.env.tauri.example b/apps/readest-app/.env.tauri.example new file mode 100644 index 00000000..89f4a0dd --- /dev/null +++ b/apps/readest-app/.env.tauri.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_APP_PLATFORM=tauri +AI_GATEWAY_API_KEY=your_key_here diff --git a/apps/readest-app/.env.web.example b/apps/readest-app/.env.web.example new file mode 100644 index 00000000..a9c53be4 --- /dev/null +++ b/apps/readest-app/.env.web.example @@ -0,0 +1,3 @@ +NEXT_PUBLIC_APP_PLATFORM=web +AI_GATEWAY_API_KEY=your_key_here +NEXT_PUBLIC_AI_GATEWAY_API_KEY=your_key_here diff --git a/apps/readest-app/components.json b/apps/readest-app/components.json new file mode 100644 index 00000000..6ddc6beb --- /dev/null +++ b/apps/readest-app/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/readest-app/next.config.mjs b/apps/readest-app/next.config.mjs index cc0d8fb1..e1085f2f 100644 --- a/apps/readest-app/next.config.mjs +++ b/apps/readest-app/next.config.mjs @@ -27,18 +27,32 @@ const nextConfig = { assetPrefix: '', reactStrictMode: true, serverExternalPackages: ['isows'], - turbopack: {}, - transpilePackages: !isDev - ? [ - 'i18next-browser-languagedetector', - 'react-i18next', - 'i18next', - '@tauri-apps', - 'highlight.js', - 'foliate-js', - 'marked', - ] - : [], + turbopack: { + resolveAlias: { + // polyfill buffer for @supabase/storage-js which requires it in browser + buffer: 'buffer', + }, + }, + transpilePackages: [ + 'ai', + 'ai-sdk-ollama', + '@ai-sdk/react', + '@assistant-ui/react', + '@assistant-ui/react-ai-sdk', + '@assistant-ui/react-markdown', + 'streamdown', + ...(isDev + ? [] + : [ + 'i18next-browser-languagedetector', + 'react-i18next', + 'i18next', + '@tauri-apps', + 'highlight.js', + 'foliate-js', + 'marked', + ]), + ], async headers() { return [ { diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index f34011ab..962e62e3 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -55,11 +55,25 @@ "build-check": "pnpm build && pnpm build-web && pnpm check:all" }, "dependencies": { + "@ai-sdk/react": "^3.0.49", + "@assistant-ui/react": "0.11.56", + "@assistant-ui/react-ai-sdk": "1.1.21", + "@assistant-ui/react-markdown": "0.11.9", "@aws-sdk/client-s3": "^3.735.0", "@aws-sdk/s3-request-presigner": "^3.735.0", "@choochmeque/tauri-plugin-sharekit-api": "^0.3.0", "@fabianlars/tauri-plugin-oauth": "2", "@opennextjs/cloudflare": "^1.14.7", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@serwist/next": "^9.4.2", "@stripe/react-stripe-js": "^3.7.0", "@stripe/stripe-js": "^7.4.0", @@ -82,9 +96,14 @@ "@tauri-apps/plugin-websocket": "~2.4.2", "@zip.js/zip.js": "^2.7.53", "abortcontroller-polyfill": "^1.7.8", + "ai": "^6.0.47", + "ai-sdk-ollama": "^3.2.0", "app-store-server-api": "^0.17.1", "aws4fetch": "^1.0.20", + "buffer": "^6.0.3", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "cors": "^2.8.5", "dayjs": "^1.11.13", "dompurify": "^3.3.0", @@ -101,7 +120,10 @@ "isomorphic-ws": "^5.0.0", "js-md5": "^0.8.3", "jwt-decode": "^4.0.0", + "lucide-react": "^0.562.0", + "lunr": "^2.3.9", "marked": "^15.0.12", + "nanoid": "^5.1.6", "next": "16.0.10", "overlayscrollbars": "^2.11.4", "overlayscrollbars-react": "^0.5.6", @@ -114,14 +136,17 @@ "react-responsive": "^10.0.0", "react-virtuoso": "^4.17.0", "react-window": "^1.8.11", + "remark-gfm": "^4.0.1", "semver": "^7.7.1", + "streamdown": "^1.6.10", "stripe": "^18.2.1", "styled-jsx": "^5.1.7", + "tailwind-merge": "^3.4.0", "tinycolor2": "^1.6.0", "uuid": "^11.1.0", "ws": "^8.18.3", "zod": "^4.0.8", - "zustand": "5.0.6" + "zustand": "5.0.10" }, "devDependencies": { "@next/bundle-analyzer": "^15.4.2", @@ -131,10 +156,11 @@ "@testing-library/react": "^16.3.0", "@types/cors": "^2.8.17", "@types/cssbeautify": "^0.3.5", + "@types/lunr": "^2.3.7", "@types/node": "^22.15.31", - "@types/react": "18.3.12", + "@types/react": "^19.0.0", "@types/react-color": "^3.0.13", - "@types/react-dom": "18.3.1", + "@types/react-dom": "^19.0.0", "@types/react-window": "^1.8.8", "@types/semver": "^7.7.0", "@types/tinycolor2": "^1.4.6", diff --git a/apps/readest-app/src/__tests__/ai/aiStore.test.ts b/apps/readest-app/src/__tests__/ai/aiStore.test.ts new file mode 100644 index 00000000..9b11fd03 --- /dev/null +++ b/apps/readest-app/src/__tests__/ai/aiStore.test.ts @@ -0,0 +1,140 @@ +import { describe, test, expect, vi } from 'vitest'; + +vi.mock('lunr', () => { + // mock lunr index for testing + return { + default: () => ({ + search: vi.fn(() => []), + }), + Index: { + load: vi.fn(() => ({ + search: vi.fn(() => []), + })), + }, + }; +}); + +// mock the global indexedDB +const createMockIDB = () => { + const stores = new Map>(); + + return { + open: vi.fn(() => ({ + result: { + createObjectStore: vi.fn(), + objectStoreNames: { contains: () => false }, + transaction: vi.fn(() => ({ + objectStore: vi.fn((name: string) => ({ + put: vi.fn((value: unknown, key: string) => { + if (!stores.has(name)) stores.set(name, new Map()); + stores.get(name)!.set(key, value); + return { onsuccess: null, onerror: null }; + }), + get: vi.fn((key: string) => { + const store = stores.get(name); + const value = store?.get(key); + return { + onsuccess: null, + onerror: null, + result: value, + }; + }), + index: vi.fn(() => ({ + openCursor: vi.fn(() => ({ + onsuccess: null, + onerror: null, + })), + })), + })), + oncomplete: null, + onerror: null, + })), + }, + onsuccess: null, + onerror: null, + onupgradeneeded: null, + })), + }; +}; + +vi.stubGlobal('indexedDB', createMockIDB()); + +import type { TextChunk } from '@/services/ai/types'; + +describe('AI Store', () => { + describe('cosineSimilarity', () => { + // inline implementation for testing since it's private + const cosineSimilarity = (a: number[], b: number[]): number => { + if (a.length !== b.length) return 0; + let dotProduct = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < a.length; i++) { + dotProduct += a[i]! * b[i]!; + normA += a[i]! * a[i]!; + normB += b[i]! * b[i]!; + } + const denominator = Math.sqrt(normA) * Math.sqrt(normB); + return denominator === 0 ? 0 : dotProduct / denominator; + }; + + test('should return 1 for identical vectors', () => { + const vec = [1, 2, 3, 4, 5]; + expect(cosineSimilarity(vec, vec)).toBeCloseTo(1); + }); + + test('should return 0 for orthogonal vectors', () => { + const a = [1, 0, 0]; + const b = [0, 1, 0]; + expect(cosineSimilarity(a, b)).toBeCloseTo(0); + }); + + test('should return -1 for opposite vectors', () => { + const a = [1, 1, 1]; + const b = [-1, -1, -1]; + expect(cosineSimilarity(a, b)).toBeCloseTo(-1); + }); + + test('should handle zero vectors', () => { + const zero = [0, 0, 0]; + const vec = [1, 2, 3]; + expect(cosineSimilarity(zero, vec)).toBe(0); + }); + + test('should return 0 for different length vectors', () => { + const a = [1, 2]; + const b = [1, 2, 3]; + expect(cosineSimilarity(a, b)).toBe(0); + }); + }); + + describe('chunk operations', () => { + const testChunk: TextChunk = { + id: 'test-hash-0-0', + bookHash: 'test-hash', + sectionIndex: 0, + chapterTitle: 'Test Chapter', + pageNumber: 1, + text: 'This is test content for the chunk.', + embedding: [0.1, 0.2, 0.3, 0.4, 0.5], + }; + + test('should create valid chunk structure', () => { + expect(testChunk.id).toBe('test-hash-0-0'); + expect(testChunk.bookHash).toBe('test-hash'); + expect(testChunk.embedding).toHaveLength(5); + }); + + test('should handle chunk without embedding', () => { + const chunkNoEmbed: TextChunk = { + id: 'test-hash-0-1', + bookHash: 'test-hash', + sectionIndex: 0, + chapterTitle: 'Test Chapter', + pageNumber: 1, + text: 'Chunk without embedding.', + }; + expect(chunkNoEmbed.embedding).toBeUndefined(); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/ai/chunker.test.ts b/apps/readest-app/src/__tests__/ai/chunker.test.ts new file mode 100644 index 00000000..b6d4e11d --- /dev/null +++ b/apps/readest-app/src/__tests__/ai/chunker.test.ts @@ -0,0 +1,98 @@ +import { describe, test, expect, vi } from 'vitest'; + +// mock the types module to avoid import issues +vi.mock('../types', () => ({ + TextChunk: {}, +})); + +import { extractTextFromDocument, chunkSection } from '@/services/ai/utils/chunker'; + +describe('AI Chunker', () => { + const createDocument = (html: string): Document => { + const parser = new DOMParser(); + return parser.parseFromString(`${html}`, 'text/html'); + }; + + describe('extractTextFromDocument', () => { + test('should extract text from simple HTML', () => { + const doc = createDocument('

Hello world

'); + const text = extractTextFromDocument(doc); + expect(text).toBe('Hello world'); + }); + + test('should remove script and style tags', () => { + const doc = createDocument(` +

Visible text

+ + +

More text

+ `); + const text = extractTextFromDocument(doc); + expect(text).toContain('Visible text'); + expect(text).toContain('More text'); + expect(text).not.toContain('console.log'); + expect(text).not.toContain('.hidden'); + }); + + test('should handle empty document', () => { + const doc = createDocument(''); + const text = extractTextFromDocument(doc); + expect(text).toBe(''); + }); + + test('should trim whitespace', () => { + const doc = createDocument('

Text with spaces

'); + const text = extractTextFromDocument(doc); + expect(text).toBe('Text with spaces'); + }); + }); + + describe('chunkSection', () => { + const bookHash = 'test-hash'; + const sectionIndex = 0; + const chapterTitle = 'Chapter 1'; + + test('should create single chunk for short text', () => { + const doc = createDocument('

Short text that is less than max chunk size.

'); + const chunks = chunkSection(doc, sectionIndex, chapterTitle, bookHash, 0); + + expect(chunks.length).toBe(1); + expect(chunks[0]!.id).toBe(`${bookHash}-${sectionIndex}-0`); + expect(chunks[0]!.bookHash).toBe(bookHash); + expect(chunks[0]!.sectionIndex).toBe(sectionIndex); + expect(chunks[0]!.chapterTitle).toBe(chapterTitle); + }); + + test('should split long text into multiple chunks', () => { + const longText = 'Lorem ipsum dolor sit amet. '.repeat(50); + const doc = createDocument(`

${longText}

`); + const chunks = chunkSection(doc, sectionIndex, chapterTitle, bookHash, 0); + + expect(chunks.length).toBeGreaterThan(1); + chunks.forEach((chunk, i) => { + expect(chunk.id).toBe(`${bookHash}-${sectionIndex}-${i}`); + }); + }); + + test('should return empty array for empty document', () => { + const doc = createDocument(''); + const chunks = chunkSection(doc, sectionIndex, chapterTitle, bookHash, 0); + expect(chunks).toEqual([]); + }); + + test('should respect custom chunk options', () => { + const longText = 'Word '.repeat(100); + const doc = createDocument(`

${longText}

`); + const chunks = chunkSection(doc, sectionIndex, chapterTitle, bookHash, 0, { + maxChunkSize: 100, + minChunkSize: 20, + }); + + expect(chunks.length).toBeGreaterThan(1); + // all chunks except last should be close to maxChunkSize + chunks.slice(0, -1).forEach((chunk) => { + expect(chunk.text.length).toBeLessThanOrEqual(150); // allow some flexibility for break points + }); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/ai/constants.test.ts b/apps/readest-app/src/__tests__/ai/constants.test.ts new file mode 100644 index 00000000..40ed6f64 --- /dev/null +++ b/apps/readest-app/src/__tests__/ai/constants.test.ts @@ -0,0 +1,102 @@ +import { describe, test, expect, vi } from 'vitest'; + +// mock stores and dependencies before imports +vi.mock('@/store/settingsStore', () => { + const mockState = { + settings: { + aiSettings: { + enabled: true, + provider: 'ollama', + ollamaBaseUrl: 'http://127.0.0.1:11434', + ollamaModel: 'llama3.2', + ollamaEmbeddingModel: 'nomic-embed-text', + spoilerProtection: true, + maxContextChunks: 5, + indexingMode: 'on-demand', + }, + }, + setSettings: vi.fn(), + saveSettings: vi.fn(), + }; + + const fn = vi.fn(() => mockState) as unknown as { + (): typeof mockState; + getState: () => typeof mockState; + setState: (partial: Partial) => void; + subscribe: (listener: () => void) => () => void; + destroy: () => void; + }; + fn.getState = () => mockState; + fn.setState = vi.fn(); + fn.subscribe = vi.fn(); + fn.destroy = vi.fn(); + + return { useSettingsStore: fn }; +}); + +import type { AISettings } from '@/services/ai/types'; +import { DEFAULT_AI_SETTINGS, GATEWAY_MODELS } from '@/services/ai/constants'; + +describe('DEFAULT_AI_SETTINGS', () => { + test('should have enabled set to false by default', () => { + expect(DEFAULT_AI_SETTINGS.enabled).toBe(false); + }); + + test('should have ollama as default provider', () => { + expect(DEFAULT_AI_SETTINGS.provider).toBe('ollama'); + }); + + test('should have valid ollama defaults', () => { + expect(DEFAULT_AI_SETTINGS.ollamaBaseUrl).toBe('http://127.0.0.1:11434'); + expect(DEFAULT_AI_SETTINGS.ollamaModel).toBe('llama3.2'); + expect(DEFAULT_AI_SETTINGS.ollamaEmbeddingModel).toBe('nomic-embed-text'); + }); + + test('should have spoiler protection enabled by default', () => { + expect(DEFAULT_AI_SETTINGS.spoilerProtection).toBe(true); + }); +}); + +describe('Model constants', () => { + test('GATEWAY_MODELS should have expected models', () => { + expect(GATEWAY_MODELS.GEMINI_FLASH_LITE).toBeDefined(); + expect(GATEWAY_MODELS.GPT_5_NANO).toBeDefined(); + expect(GATEWAY_MODELS.LLAMA_4_SCOUT).toBeDefined(); + expect(GATEWAY_MODELS.GROK_4_1_FAST).toBeDefined(); + expect(GATEWAY_MODELS.DEEPSEEK_V3_2).toBeDefined(); + expect(GATEWAY_MODELS.QWEN_3_235B).toBeDefined(); + }); +}); + +describe('AISettings Type', () => { + test('should allow creating valid settings object', () => { + const settings: AISettings = { + enabled: true, + provider: 'ollama', + ollamaBaseUrl: 'http://localhost:11434', + ollamaModel: 'mistral', + ollamaEmbeddingModel: 'nomic-embed-text', + spoilerProtection: false, + maxContextChunks: 10, + indexingMode: 'background', + }; + + expect(settings.enabled).toBe(true); + expect(settings.provider).toBe('ollama'); + expect(settings.indexingMode).toBe('background'); + }); + + test('should support ai-gateway provider', () => { + const settings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'ai-gateway', + aiGatewayApiKey: 'test-key', + aiGatewayModel: 'openai/gpt-5.2', + aiGatewayEmbeddingModel: 'openai/text-embedding-3-small', + }; + + expect(settings.provider).toBe('ai-gateway'); + expect(settings.aiGatewayApiKey).toBe('test-key'); + }); +}); diff --git a/apps/readest-app/src/__tests__/ai/providers.test.ts b/apps/readest-app/src/__tests__/ai/providers.test.ts new file mode 100644 index 00000000..5ab30a00 --- /dev/null +++ b/apps/readest-app/src/__tests__/ai/providers.test.ts @@ -0,0 +1,181 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +// mock fetch for provider tests +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// mock logger +vi.mock('@/services/ai/logger', () => ({ + aiLogger: { + provider: { + init: vi.fn(), + error: vi.fn(), + }, + }, +})); + +// mock ai-sdk-ollama +vi.mock('ai-sdk-ollama', () => ({ + createOllama: vi.fn(() => { + const ollamaFn = Object.assign(vi.fn(), { + embeddingModel: vi.fn(), + }); + return ollamaFn; + }), +})); + +import { OllamaProvider } from '@/services/ai/providers/OllamaProvider'; +import { AIGatewayProvider } from '@/services/ai/providers/AIGatewayProvider'; +import { getAIProvider } from '@/services/ai/providers'; +import type { AISettings } from '@/services/ai/types'; +import { DEFAULT_AI_SETTINGS } from '@/services/ai/constants'; + +describe('OllamaProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should create provider with default settings', () => { + const settings: AISettings = { ...DEFAULT_AI_SETTINGS, enabled: true }; + const provider = new OllamaProvider(settings); + + expect(provider.id).toBe('ollama'); + expect(provider.name).toBe('Ollama (Local)'); + expect(provider.requiresAuth).toBe(false); + }); + + test('isAvailable should return true when Ollama responds', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + const settings: AISettings = { ...DEFAULT_AI_SETTINGS, enabled: true }; + const provider = new OllamaProvider(settings); + + const result = await provider.isAvailable(); + expect(result).toBe(true); + }); + + test('isAvailable should return false when Ollama not running', async () => { + mockFetch.mockRejectedValueOnce(new Error('Connection refused')); + const settings: AISettings = { ...DEFAULT_AI_SETTINGS, enabled: true }; + const provider = new OllamaProvider(settings); + + const result = await provider.isAvailable(); + expect(result).toBe(false); + }); + + test('healthCheck should verify model exists', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ models: [{ name: 'llama3.2:latest' }] }), + }); + const settings: AISettings = { ...DEFAULT_AI_SETTINGS, enabled: true, ollamaModel: 'llama3.2' }; + const provider = new OllamaProvider(settings); + + const result = await provider.healthCheck(); + expect(result).toBe(true); + }); + + test('healthCheck should return false if model not found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ models: [{ name: 'other-model' }] }), + }); + const settings: AISettings = { ...DEFAULT_AI_SETTINGS, enabled: true, ollamaModel: 'llama3.2' }; + const provider = new OllamaProvider(settings); + + const result = await provider.healthCheck(); + expect(result).toBe(false); + }); +}); + +describe('AIGatewayProvider', () => { + test('should throw if no API key', () => { + const settings: AISettings = { ...DEFAULT_AI_SETTINGS, enabled: true, provider: 'ai-gateway' }; + + expect(() => new AIGatewayProvider(settings)).toThrow('API key required'); + }); + + test('should create provider with API key', () => { + const settings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'ai-gateway', + aiGatewayApiKey: 'test-key', + }; + const provider = new AIGatewayProvider(settings); + + expect(provider.id).toBe('ai-gateway'); + expect(provider.name).toBe('AI Gateway (Cloud)'); + expect(provider.requiresAuth).toBe(true); + }); + + test('isAvailable should return true if key exists', async () => { + const settings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'ai-gateway', + aiGatewayApiKey: 'test-key', + }; + const provider = new AIGatewayProvider(settings); + + const result = await provider.isAvailable(); + expect(result).toBe(true); + }); + + test('isAvailable should return false if key does not exist', async () => { + const settings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'ai-gateway', + aiGatewayApiKey: '', + }; + + // provider throws on construction if no key, so we test via getAIProvider fallback + expect(() => new AIGatewayProvider(settings)).toThrow('API key required'); + }); + + test('healthCheck should return false if key does not exist', async () => { + const settings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'ai-gateway', + aiGatewayApiKey: 'valid-key', + }; + const provider = new AIGatewayProvider(settings); + + // override key after construction to simulate missing key check in healthCheck + (provider as unknown as { settings: AISettings }).settings.aiGatewayApiKey = ''; + const result = await provider.healthCheck(); + expect(result).toBe(false); + }); +}); + +describe('getAIProvider', () => { + test('should return OllamaProvider for ollama', () => { + const settings: AISettings = { ...DEFAULT_AI_SETTINGS, enabled: true, provider: 'ollama' }; + const provider = getAIProvider(settings); + + expect(provider.id).toBe('ollama'); + }); + + test('should return AIGatewayProvider for ai-gateway', () => { + const settings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'ai-gateway', + aiGatewayApiKey: 'test-key', + }; + const provider = getAIProvider(settings); + + expect(provider.id).toBe('ai-gateway'); + }); + + test('should throw for unknown provider', () => { + const settings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'unknown' as unknown, + } as AISettings; + + expect(() => getAIProvider(settings)).toThrow('Unknown provider'); + }); +}); diff --git a/apps/readest-app/src/__tests__/ai/retry.test.ts b/apps/readest-app/src/__tests__/ai/retry.test.ts new file mode 100644 index 00000000..b1497f64 --- /dev/null +++ b/apps/readest-app/src/__tests__/ai/retry.test.ts @@ -0,0 +1,94 @@ +import { describe, test, expect, vi } from 'vitest'; +import { withRetry, withTimeout, AI_TIMEOUTS, AI_RETRY_CONFIGS } from '@/services/ai/utils/retry'; + +describe('withRetry', () => { + test('should return result on first success', async () => { + const fn = vi.fn().mockResolvedValue('success'); + const result = await withRetry(fn); + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + test('should retry on failure and succeed', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('fail 1')) + .mockRejectedValueOnce(new Error('fail 2')) + .mockResolvedValue('success'); + + const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 1, maxDelayMs: 5 }); + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(3); + }); + + test('should throw after max retries', async () => { + const fn = vi.fn().mockRejectedValue(new Error('always fails')); + + await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 1, maxDelayMs: 5 })).rejects.toThrow( + 'always fails', + ); + + expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries + }); + + test('should not retry on AbortError', async () => { + const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; + const fn = vi.fn().mockRejectedValue(abortError); + + await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 1, maxDelayMs: 5 })).rejects.toThrow( + 'Aborted', + ); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + test('should call onRetry callback', async () => { + const onRetry = vi.fn(); + const fn = vi.fn().mockRejectedValueOnce(new Error('fail')).mockResolvedValue('success'); + + await withRetry(fn, { maxRetries: 2, baseDelayMs: 1, maxDelayMs: 5, onRetry }); + + expect(onRetry).toHaveBeenCalledWith(1, expect.any(Error)); + }); +}); + +describe('withTimeout', () => { + test('should return result before timeout', async () => { + const promise = Promise.resolve('fast'); + const result = await withTimeout(promise, 1000); + expect(result).toBe('fast'); + }); + + test('should throw on timeout', async () => { + const slowPromise = new Promise((resolve) => setTimeout(resolve, 5000)); + + await expect(withTimeout(slowPromise, 10)).rejects.toThrow('Timeout after 10ms'); + }); + + test('should use custom message', async () => { + const slowPromise = new Promise((resolve) => setTimeout(resolve, 5000)); + + await expect(withTimeout(slowPromise, 10, 'Custom timeout')).rejects.toThrow('Custom timeout'); + }); +}); + +describe('AI_TIMEOUTS', () => { + test('should have correct timeout values', () => { + expect(AI_TIMEOUTS.EMBEDDING_SINGLE).toBe(30_000); + expect(AI_TIMEOUTS.EMBEDDING_BATCH).toBe(120_000); + expect(AI_TIMEOUTS.CHAT_STREAM).toBe(60_000); + expect(AI_TIMEOUTS.HEALTH_CHECK).toBe(5_000); + expect(AI_TIMEOUTS.OLLAMA_CONNECT).toBe(5_000); + }); +}); + +describe('AI_RETRY_CONFIGS', () => { + test('should have correct retry configs', () => { + expect(AI_RETRY_CONFIGS.EMBEDDING.maxRetries).toBe(3); + expect(AI_RETRY_CONFIGS.CHAT.maxRetries).toBe(2); + expect(AI_RETRY_CONFIGS.HEALTH_CHECK.maxRetries).toBe(1); + }); +}); diff --git a/apps/readest-app/src/app/api/ai/chat/route.ts b/apps/readest-app/src/app/api/ai/chat/route.ts new file mode 100644 index 00000000..1ee1b968 --- /dev/null +++ b/apps/readest-app/src/app/api/ai/chat/route.ts @@ -0,0 +1,46 @@ +import { validateUserAndToken } from '@/utils/access'; +import { streamText, createGateway } from 'ai'; +import type { ModelMessage } from 'ai'; + +export async function POST(req: Request): Promise { + try { + const { user, token } = await validateUserAndToken(req.headers.get('authorization')); + if (!user || !token) { + return Response.json({ error: 'Not authenticated' }, { status: 403 }); + } + + const { messages, system, apiKey, model } = await req.json(); + + if (!messages || !Array.isArray(messages)) { + return new Response(JSON.stringify({ error: 'Messages required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const gatewayApiKey = apiKey || process.env['AI_GATEWAY_API_KEY']; + if (!gatewayApiKey) { + return new Response(JSON.stringify({ error: 'API key required' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const gateway = createGateway({ apiKey: gatewayApiKey }); + const languageModel = gateway(model || 'google/gemini-2.5-flash-lite'); + + const result = streamText({ + model: languageModel, + system: system || 'You are a helpful assistant.', + messages: messages as ModelMessage[], + }); + + return result.toTextStreamResponse(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return new Response(JSON.stringify({ error: `Chat failed: ${errorMessage}` }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/apps/readest-app/src/app/api/ai/embed/route.ts b/apps/readest-app/src/app/api/ai/embed/route.ts new file mode 100644 index 00000000..d7684db1 --- /dev/null +++ b/apps/readest-app/src/app/api/ai/embed/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { embed, embedMany, createGateway } from 'ai'; +import { validateUserAndToken } from '@/utils/access'; + +export async function POST(req: Request): Promise { + try { + const { user, token } = await validateUserAndToken(req.headers.get('authorization')); + if (!user || !token) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 403 }); + } + + const { texts, single, apiKey } = await req.json(); + + if (!texts || !Array.isArray(texts) || texts.length === 0) { + return NextResponse.json({ error: 'Texts array required' }, { status: 400 }); + } + + const gatewayApiKey = apiKey || process.env['AI_GATEWAY_API_KEY']; + if (!gatewayApiKey) { + return NextResponse.json({ error: 'API key required' }, { status: 401 }); + } + + const gateway = createGateway({ apiKey: gatewayApiKey }); + const model = gateway.embeddingModel( + process.env['AI_GATEWAY_EMBEDDING_MODEL'] || 'openai/text-embedding-3-small', + ); + + if (single) { + const { embedding } = await embed({ model, value: texts[0] }); + return NextResponse.json({ embedding }); + } else { + const { embeddings } = await embedMany({ model, values: texts }); + return NextResponse.json({ embeddings }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return NextResponse.json({ error: `Embedding failed: ${errorMessage}` }, { status: 500 }); + } +} diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 07b236fa..631db7bc 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -91,7 +91,7 @@ const FoliateViewer: React.FC<{ const viewSettings = getViewSettings(bookKey); const viewRef = useRef(null); - const containerRef = useRef(null); + const containerRef = useRef(null); const isViewCreated = useRef(false); const doubleClickDisabled = useRef(!!viewSettings?.disableDoubleClick); const [toastMessage, setToastMessage] = useState(''); diff --git a/apps/readest-app/src/app/reader/components/notebook/Header.tsx b/apps/readest-app/src/app/reader/components/notebook/Header.tsx index 6beb2760..0a73bb2f 100644 --- a/apps/readest-app/src/app/reader/components/notebook/Header.tsx +++ b/apps/readest-app/src/app/reader/components/notebook/Header.tsx @@ -13,7 +13,15 @@ const NotebookHeader: React.FC<{ handleClose: () => void; handleTogglePin: () => void; handleToggleSearchBar: () => void; -}> = ({ isPinned, isSearchBarVisible, handleClose, handleTogglePin, handleToggleSearchBar }) => { + showSearchButton?: boolean; +}> = ({ + isPinned, + isSearchBarVisible, + handleClose, + handleTogglePin, + handleToggleSearchBar, + showSearchButton = true, +}) => { const _ = useTranslation(); const iconSize14 = useResponsiveSize(14); const iconSize18 = useResponsiveSize(18); @@ -42,15 +50,20 @@ const NotebookHeader: React.FC<{ -
- -
+ {showSearchButton && ( +
+ +
+ )} ); }; diff --git a/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx index 5a5b1520..7b5b6d05 100644 --- a/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx +++ b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx @@ -6,6 +6,7 @@ import { useBookDataStore } from '@/store/bookDataStore'; import { useReaderStore } from '@/store/readerStore'; import { useSidebarStore } from '@/store/sidebarStore'; import { useNotebookStore } from '@/store/notebookStore'; +import { useAIChatStore } from '@/store/aiChatStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useThemeStore } from '@/store/themeStore'; import { useEnv } from '@/context/EnvContext'; @@ -20,9 +21,11 @@ import { saveSysSettings } from '@/helpers/settings'; import { NOTE_PREFIX } from '@/types/view'; import useShortcuts from '@/hooks/useShortcuts'; import BooknoteItem from '../sidebar/BooknoteItem'; +import AIAssistant from '../sidebar/AIAssistant'; import NotebookHeader from './Header'; import NoteEditor from './NoteEditor'; import SearchBar from './SearchBar'; +import NotebookTabNavigation from './NotebookTabNavigation'; const MIN_NOTEBOOK_WIDTH = 0.15; const MAX_NOTEBOOK_WIDTH = 0.45; @@ -33,13 +36,16 @@ const Notebook: React.FC = ({}) => { const { envConfig, appService } = useEnv(); const { settings } = useSettingsStore(); const { sideBarBookKey } = useSidebarStore(); - const { notebookWidth, isNotebookVisible, isNotebookPinned } = useNotebookStore(); + const { notebookWidth, isNotebookVisible, isNotebookPinned, notebookActiveTab } = + useNotebookStore(); const { notebookNewAnnotation, notebookEditAnnotation, setNotebookPin } = useNotebookStore(); const { getBookData, getConfig, saveConfig, updateBooknotes } = useBookDataStore(); const { getView, getViewSettings } = useReaderStore(); const { getNotebookWidth, setNotebookWidth, setNotebookVisible, toggleNotebookPin } = useNotebookStore(); - const { setNotebookNewAnnotation, setNotebookEditAnnotation } = useNotebookStore(); + const { setNotebookNewAnnotation, setNotebookEditAnnotation, setNotebookActiveTab } = + useNotebookStore(); + const { activeConversationId } = useAIChatStore(); const [isSearchBarVisible, setIsSearchBarVisible] = useState(false); const [searchResults, setSearchResults] = useState(null); @@ -75,6 +81,9 @@ const Notebook: React.FC = ({}) => { setNotebookWidth(settings.globalReadSettings.notebookWidth); setNotebookPin(settings.globalReadSettings.isNotebookPinned); setNotebookVisible(settings.globalReadSettings.isNotebookPinned); + if (settings.globalReadSettings.notebookActiveTab) { + setNotebookActiveTab(settings.globalReadSettings.notebookActiveTab); + } eventDispatcher.on('navigate', onNavigateEvent); return () => { @@ -95,6 +104,13 @@ const Notebook: React.FC = ({}) => { saveSysSettings(envConfig, 'globalReadSettings', newGlobalReadSettings); }; + const handleTabChange = (tab: 'notes' | 'ai') => { + setNotebookActiveTab(tab); + const globalReadSettings = settings.globalReadSettings; + const newGlobalReadSettings = { ...globalReadSettings, notebookActiveTab: tab }; + saveSysSettings(envConfig, 'globalReadSettings', newGlobalReadSettings); + }; + const handleClickOverlay = () => { setNotebookVisible(false); setNotebookNewAnnotation(null); @@ -266,106 +282,123 @@ const Notebook: React.FC = ({}) => {
setNotebookVisible(false)} handleTogglePin={handleTogglePin} handleToggleSearchBar={handleToggleSearchBar} + showSearchButton={notebookActiveTab === 'notes'} /> -
- -
-
-
- {isSearchBarVisible && searchResults && !hasSearchResults && hasAnyNotes && ( -
-

{_('No notes match your search')}

+ {notebookActiveTab === 'notes' && ( +
+
)} -
- {filteredExcerptNotes.length > 0 && ( -

- {_('Excerpts')} - {isSearchBarVisible && searchResults && ( - - ({filteredExcerptNotes.length}) - - )} -

- )} +
+ {notebookActiveTab === 'ai' ? ( +
+
-
    - {filteredExcerptNotes.map((item, index) => ( -
  • -
    { - if (e.key === 'Backspace' || e.key === 'Delete') { - handleEditNote(item, true); - } - }} - className='booknote-item collapse-arrow border-base-300 bg-base-100 collapse border' - > + ) : ( +
    + {isSearchBarVisible && searchResults && !hasSearchResults && hasAnyNotes && ( +
    +

    {_('No notes match your search')}

    +
    + )} +
    + {filteredExcerptNotes.length > 0 && ( +

    + {_('Excerpts')} + {isSearchBarVisible && searchResults && ( + + ({filteredExcerptNotes.length}) + + )} +

    + )} +
    +
      + {filteredExcerptNotes.map((item, index) => ( +
    • { + if (e.key === 'Backspace' || e.key === 'Delete') { + handleEditNote(item, true); + } + }} + className='booknote-item collapse-arrow border-base-300 bg-base-100 collapse border' > -

      {item.text || `Excerpt ${index + 1}`}

      -
      -
      -

      {item.text}

      -
      - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/} -
      - {_('Delete')} +
      +

      {item.text || `Excerpt ${index + 1}`}

      +
      +
      +

      {item.text}

      +
      + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/} +
      + {_('Delete')} +
      -
      -
    • - ))} -
    -
    - {(notebookNewAnnotation || filteredAnnotationNotes.length > 0) && ( -

    - {_('Notes')} - {isSearchBarVisible && searchResults && filteredAnnotationNotes.length > 0 && ( - - ({filteredAnnotationNotes.length}) - - )} -

    +
  • + ))} +
+
+ {(notebookNewAnnotation || filteredAnnotationNotes.length > 0) && ( +

+ {_('Notes')} + {isSearchBarVisible && searchResults && filteredAnnotationNotes.length > 0 && ( + + ({filteredAnnotationNotes.length}) + + )} +

+ )} +
+ {(notebookNewAnnotation || notebookEditAnnotation) && !isSearchBarVisible && ( + handleEditNote(item, false)} /> )} +
    + {filteredAnnotationNotes.map((item, index) => ( + + ))} +
- {(notebookNewAnnotation || notebookEditAnnotation) && !isSearchBarVisible && ( - handleEditNote(item, false)} /> - )} -
    - {filteredAnnotationNotes.map((item, index) => ( - - ))} -
+ )} +
+
diff --git a/apps/readest-app/src/app/reader/components/notebook/NotebookTabNavigation.tsx b/apps/readest-app/src/app/reader/components/notebook/NotebookTabNavigation.tsx new file mode 100644 index 00000000..d0a816de --- /dev/null +++ b/apps/readest-app/src/app/reader/components/notebook/NotebookTabNavigation.tsx @@ -0,0 +1,82 @@ +import clsx from 'clsx'; +import React from 'react'; +import { PiNotePencil, PiRobot } from 'react-icons/pi'; + +import { useEnv } from '@/context/EnvContext'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useSettingsStore } from '@/store/settingsStore'; +import { NotebookTab } from '@/store/notebookStore'; + +interface NotebookTabNavigationProps { + activeTab: NotebookTab; + onTabChange: (tab: NotebookTab) => void; +} + +const NotebookTabNavigation: React.FC = ({ + activeTab, + onTabChange, +}) => { + const _ = useTranslation(); + const { appService } = useEnv(); + const { settings } = useSettingsStore(); + const aiEnabled = settings?.aiSettings?.enabled ?? false; + + const tabs: NotebookTab[] = aiEnabled ? ['notes', 'ai'] : []; + + const getTabLabel = (tab: NotebookTab) => { + switch (tab) { + case 'notes': + return _('Notes'); + case 'ai': + return _('AI'); + default: + return ''; + } + }; + + const getTabIcon = (tab: NotebookTab) => { + switch (tab) { + case 'notes': + return ; + case 'ai': + return ; + default: + return null; + } + }; + + return ( +
+ {tabs.map((tab) => ( +
onTabChange(tab)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onTabChange(tab); + } + }} + title={getTabLabel(tab)} + aria-label={getTabLabel(tab)} + > +
{getTabIcon(tab)}
+
+ ))} +
+ ); +}; + +export default NotebookTabNavigation; diff --git a/apps/readest-app/src/app/reader/components/sidebar/AIAssistant.tsx b/apps/readest-app/src/app/reader/components/sidebar/AIAssistant.tsx new file mode 100644 index 00000000..101ea929 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/sidebar/AIAssistant.tsx @@ -0,0 +1,338 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { + AssistantRuntimeProvider, + useLocalRuntime, + useAssistantRuntime, + type ThreadMessage, + type ThreadHistoryAdapter, +} from '@assistant-ui/react'; + +import { useTranslation } from '@/hooks/useTranslation'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useReaderStore } from '@/store/readerStore'; +import { useAIChatStore } from '@/store/aiChatStore'; +import { + indexBook, + isBookIndexed, + aiStore, + aiLogger, + createTauriAdapter, + getLastSources, + clearLastSources, +} from '@/services/ai'; +import type { EmbeddingProgress, AISettings, AIMessage } from '@/services/ai/types'; +import { useEnv } from '@/context/EnvContext'; + +import { Thread } from '@/components/assistant-ui/thread'; +import { Button } from '@/components/ui/button'; +import { Loader2Icon, BookOpenIcon } from 'lucide-react'; + +// Helper function to convert AIMessage array to ExportedMessageRepository format +// Each message needs to be wrapped with { message, parentId } structure +function convertToExportedMessages( + aiMessages: AIMessage[], +): { message: ThreadMessage; parentId: string | null }[] { + return aiMessages.map((msg, idx) => { + const baseMessage = { + id: msg.id, + content: [{ type: 'text' as const, text: msg.content }], + createdAt: new Date(msg.createdAt), + metadata: { custom: {} }, + }; + + // Build role-specific message to satisfy ThreadMessage union type + const threadMessage: ThreadMessage = + msg.role === 'user' + ? ({ + ...baseMessage, + role: 'user' as const, + attachments: [] as const, + } as unknown as ThreadMessage) + : ({ + ...baseMessage, + role: 'assistant' as const, + status: { type: 'complete' as const, reason: 'stop' as const }, + } as unknown as ThreadMessage); + + return { + message: threadMessage, + parentId: idx > 0 ? (aiMessages[idx - 1]?.id ?? null) : null, + }; + }); +} + +interface AIAssistantProps { + bookKey: string; +} + +// inner component that uses the runtime hook +const AIAssistantChat = ({ + aiSettings, + bookHash, + bookTitle, + authorName, + currentPage, + onResetIndex, +}: { + aiSettings: AISettings; + bookHash: string; + bookTitle: string; + authorName: string; + currentPage: number; + onResetIndex: () => void; +}) => { + const { activeConversationId, messages: storedMessages, addMessage } = useAIChatStore(); + + // use a ref to keep up-to-date options without triggering re-renders of the runtime + const optionsRef = useRef({ + settings: aiSettings, + bookHash, + bookTitle, + authorName, + currentPage, + }); + + // update ref on every render with latest values + useEffect(() => { + optionsRef.current = { + settings: aiSettings, + bookHash, + bookTitle, + authorName, + currentPage, + }; + }); + + // create adapter ONCE and keep it stable + const adapter = useMemo(() => { + // eslint-disable-next-line react-hooks/refs -- intentional: we read optionsRef inside a deferred callback, not during render + return createTauriAdapter(() => optionsRef.current); + }, []); + + // Create history adapter to load/persist messages + const historyAdapter = useMemo(() => { + if (!activeConversationId) return undefined; + + return { + async load() { + // storedMessages are already loaded by aiChatStore when conversation is selected + return { + messages: convertToExportedMessages(storedMessages), + }; + }, + async append(item) { + // item is ExportedMessageRepositoryItem - access the actual message via .message + const msg = item.message; + // Persist new messages to our store + if (activeConversationId && msg.role !== 'system') { + const textContent = msg.content + .filter( + (part): part is { type: 'text'; text: string } => + 'type' in part && part.type === 'text', + ) + .map((part) => part.text) + .join('\n'); + + if (textContent) { + await addMessage({ + conversationId: activeConversationId, + role: msg.role as 'user' | 'assistant', + content: textContent, + }); + } + } + }, + }; + }, [activeConversationId, storedMessages, addMessage]); + + return ( + + ); +}; + +// separate component to ensure useLocalRuntime is always called with a valid adapter +const AIAssistantWithRuntime = ({ + adapter, + historyAdapter, + onResetIndex, +}: { + adapter: NonNullable>; + historyAdapter?: ThreadHistoryAdapter; + onResetIndex: () => void; +}) => { + const runtime = useLocalRuntime(adapter, { + adapters: historyAdapter ? { history: historyAdapter } : undefined, + }); + + // ensure runtime is available before providing it + if (!runtime) return null; + + return ( + + + + ); +}; + +// inner component that uses useAssistantRuntime (must be inside provider) +const ThreadWrapper = ({ onResetIndex }: { onResetIndex: () => void }) => { + const [sources, setSources] = useState(getLastSources()); + const assistantRuntime = useAssistantRuntime(); + const { setActiveConversation } = useAIChatStore(); + + // poll for sources updates (adapter stores them) + useEffect(() => { + const interval = setInterval(() => { + setSources(getLastSources()); + }, 500); + return () => clearInterval(interval); + }, []); + + const handleClear = useCallback(() => { + clearLastSources(); + setSources([]); + setActiveConversation(null); + assistantRuntime.switchToNewThread(); + }, [assistantRuntime, setActiveConversation]); + + return ; +}; + +const AIAssistant = ({ bookKey }: AIAssistantProps) => { + const _ = useTranslation(); + const { appService } = useEnv(); + const { settings } = useSettingsStore(); + const { getBookData } = useBookDataStore(); + const { getProgress } = useReaderStore(); + const bookData = getBookData(bookKey); + const progress = getProgress(bookKey); + + const [isLoading, setIsLoading] = useState(true); + const [isIndexing, setIsIndexing] = useState(false); + const [indexProgress, setIndexProgress] = useState(null); + const [indexed, setIndexed] = useState(false); + + const bookHash = bookKey.split('-')[0] || ''; + const bookTitle = bookData?.book?.title || 'Unknown'; + const authorName = bookData?.book?.author || ''; + const currentPage = progress?.pageinfo?.current ?? 0; + const aiSettings = settings?.aiSettings; + + // check if book is indexed on mount + useEffect(() => { + if (bookHash) { + isBookIndexed(bookHash).then((result) => { + setIndexed(result); + setIsLoading(false); + }); + } else { + setIsLoading(false); + } + }, [bookHash]); + + const handleIndex = useCallback(async () => { + if (!bookData?.bookDoc || !aiSettings) return; + setIsIndexing(true); + try { + await indexBook( + bookData.bookDoc as Parameters[0], + bookHash, + aiSettings, + setIndexProgress, + ); + setIndexed(true); + } catch (e) { + aiLogger.rag.indexError(bookHash, (e as Error).message); + } finally { + setIsIndexing(false); + setIndexProgress(null); + } + }, [bookData?.bookDoc, bookHash, aiSettings]); + + const handleResetIndex = useCallback(async () => { + if (!appService) return; + if (!(await appService.ask(_('Are you sure you want to re-index this book?')))) return; + await aiStore.clearBook(bookHash); + setIndexed(false); + }, [bookHash, appService, _]); + + if (!aiSettings?.enabled) { + return ( +
+

{_('Enable AI in Settings')}

+
+ ); + } + + // show nothing while checking index status to prevent flicker + if (isLoading) { + return null; + } + + const progressPercent = + indexProgress?.phase === 'embedding' && indexProgress.total > 0 + ? Math.round((indexProgress.current / indexProgress.total) * 100) + : 0; + + if (!indexed && !isIndexing) { + return ( +
+
+ +
+
+

{_('Index This Book')}

+

+ {_('Enable AI search and chat for this book')} +

+
+ +
+ ); + } + + if (isIndexing) { + return ( +
+ +
+

{_('Indexing book...')}

+

+ {indexProgress?.phase === 'embedding' + ? `${indexProgress.current} / ${indexProgress.total} chunks` + : _('Preparing...')} +

+
+
+
+
+
+ ); + } + + return ( + + ); +}; + +export default AIAssistant; diff --git a/apps/readest-app/src/app/reader/components/sidebar/ChatHistoryView.tsx b/apps/readest-app/src/app/reader/components/sidebar/ChatHistoryView.tsx new file mode 100644 index 00000000..20edd45d --- /dev/null +++ b/apps/readest-app/src/app/reader/components/sidebar/ChatHistoryView.tsx @@ -0,0 +1,253 @@ +'use client'; + +import clsx from 'clsx'; +import dayjs from 'dayjs'; +import React, { useEffect, useState, useCallback } from 'react'; +import { LuMessageSquare, LuTrash2, LuPencil, LuCheck, LuX, LuPlus } from 'react-icons/lu'; + +import { useTranslation } from '@/hooks/useTranslation'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useAIChatStore } from '@/store/aiChatStore'; +import { useNotebookStore } from '@/store/notebookStore'; +import type { AIConversation } from '@/services/ai/types'; +import { useEnv } from '@/context/EnvContext'; + +interface ChatHistoryViewProps { + bookKey: string; +} + +const ChatHistoryView: React.FC = ({ bookKey }) => { + const _ = useTranslation(); + const { appService } = useEnv(); + const { getBookData } = useBookDataStore(); + const { + conversations, + isLoadingHistory, + loadConversations, + setActiveConversation, + deleteConversation, + renameConversation, + createConversation, + } = useAIChatStore(); + const { setNotebookVisible, setNotebookActiveTab } = useNotebookStore(); + + const [editingId, setEditingId] = useState(null); + const [editTitle, setEditTitle] = useState(''); + + const bookData = getBookData(bookKey); + const bookHash = bookKey.split('-')[0] || ''; + const bookTitle = bookData?.book?.title || 'Unknown'; + + // Load conversations for this book + useEffect(() => { + if (bookHash) { + loadConversations(bookHash); + } + }, [bookHash, loadConversations]); + + const handleSelectConversation = useCallback( + async (conversation: AIConversation) => { + await setActiveConversation(conversation.id); + setNotebookVisible(true); + setNotebookActiveTab('ai'); + }, + [setActiveConversation, setNotebookVisible, setNotebookActiveTab], + ); + + const handleNewConversation = useCallback(async () => { + await createConversation(bookHash, `Chat about ${bookTitle}`); + setNotebookVisible(true); + setNotebookActiveTab('ai'); + }, [bookHash, bookTitle, createConversation, setNotebookVisible, setNotebookActiveTab]); + + const handleDeleteConversation = useCallback( + async (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + if (!appService) return; + if (await appService.ask(_('Delete this conversation?'))) { + await deleteConversation(id); + } + }, + [deleteConversation, _, appService], + ); + + const handleStartRename = useCallback((e: React.MouseEvent, conversation: AIConversation) => { + e.stopPropagation(); + setEditingId(conversation.id); + setEditTitle(conversation.title); + }, []); + + const handleSaveRename = useCallback( + async (e: React.MouseEvent | React.KeyboardEvent) => { + e.stopPropagation(); + if (editingId && editTitle.trim()) { + await renameConversation(editingId, editTitle.trim()); + } + setEditingId(null); + setEditTitle(''); + }, + [editingId, editTitle, renameConversation], + ); + + const handleCancelRename = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setEditingId(null); + setEditTitle(''); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSaveRename(e); + } else if (e.key === 'Escape') { + setEditingId(null); + setEditTitle(''); + } + }, + [handleSaveRename], + ); + + if (isLoadingHistory) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Conversation list */} +
+ {conversations.length === 0 ? ( +
+
+ +
+
+

{_('No conversations yet')}

+

+ {_('Start a new chat to ask questions about this book')} +

+
+
+ ) : ( +
    + {conversations.map((conversation) => ( +
  • +
    handleSelectConversation(conversation)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSelectConversation(conversation); + } + }} + > + + +
    + {editingId === conversation.id ? ( +
    e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + setEditTitle(e.target.value)} + onKeyDown={handleKeyDown} + className={clsx( + 'input input-xs input-bordered w-full', + 'bg-base-100 text-base-content', + )} + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus + /> + + +
    + ) : ( + <> +

    + {conversation.title} +

    +

    + {dayjs(conversation.updatedAt).format('MMM D, YYYY h:mm A')} +

    + + )} +
    +
    + + {editingId !== conversation.id && ( +
    + + +
    + )} +
  • + ))} +
+ )} +
+ + {/* Floating New Chat button at bottom right */} +
+ +
+
+ ); +}; + +export default ChatHistoryView; diff --git a/apps/readest-app/src/app/reader/components/sidebar/Content.tsx b/apps/readest-app/src/app/reader/components/sidebar/Content.tsx index e0ed1bdf..b46db30a 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/Content.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/Content.tsx @@ -6,12 +6,14 @@ import { useThemeStore } from '@/store/themeStore'; import { useReaderStore } from '@/store/readerStore'; import { useSidebarStore } from '@/store/sidebarStore'; import { useBookDataStore } from '@/store/bookDataStore'; +import { useSettingsStore } from '@/store/settingsStore'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; import 'overlayscrollbars/overlayscrollbars.css'; import TOCView from './TOCView'; import BooknoteView from './BooknoteView'; import TabNavigation from './TabNavigation'; +import ChatHistoryView from './ChatHistoryView'; const SidebarContent: React.FC<{ bookDoc: BookDoc; @@ -21,11 +23,13 @@ const SidebarContent: React.FC<{ const { setHoveredBookKey } = useReaderStore(); const { setSideBarVisible } = useSidebarStore(); const { getConfig, setConfig } = useBookDataStore(); + const { settings } = useSettingsStore(); const config = getConfig(sideBarBookKey); const [activeTab, setActiveTab] = useState(config?.viewSettings?.sideBarTab || 'toc'); const [fade, setFade] = useState(false); const [targetTab, setTargetTab] = useState(activeTab); const isMobile = window.innerWidth < 640 || window.innerHeight < 640; + const aiEnabled = settings?.aiSettings?.enabled ?? false; useEffect(() => { if (!sideBarBookKey) return; @@ -34,6 +38,14 @@ const SidebarContent: React.FC<{ // eslint-disable-next-line react-hooks/exhaustive-deps }, [sideBarBookKey]); + // reset to toc if history tab was active but AI is now disabled + useEffect(() => { + if ((activeTab === 'history' || targetTab === 'history') && !aiEnabled) { + setActiveTab('toc'); + setTargetTab('toc'); + } + }, [aiEnabled, activeTab, targetTab]); + const handleTabChange = (tab: string) => { setFade(true); const timeout = setTimeout(() => { @@ -61,31 +73,38 @@ const SidebarContent: React.FC<{ 'font-sans text-base font-normal sm:text-sm', )} > - -
+ ) : ( + - {targetTab === 'toc' && bookDoc.toc && ( - - )} - {targetTab === 'annotations' && ( - - )} - {targetTab === 'bookmarks' && ( - - )} -
-
+
+ {targetTab === 'toc' && bookDoc.toc && ( + + )} + {targetTab === 'annotations' && ( + + )} + {targetTab === 'bookmarks' && ( + + )} +
+ + )}
} > diff --git a/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx b/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx index 40cd2a8c..3e6c3aa2 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx @@ -1,11 +1,13 @@ import clsx from 'clsx'; import React from 'react'; -import { MdBookmarkBorder as BookmarkIcon } from 'react-icons/md'; -import { IoIosList as TOCIcon } from 'react-icons/io'; -import { PiNotePencil as NoteIcon } from 'react-icons/pi'; +import { MdBookmarkBorder } from 'react-icons/md'; +import { IoIosList } from 'react-icons/io'; +import { PiNotePencil } from 'react-icons/pi'; +import { LuMessageSquare } from 'react-icons/lu'; import { useEnv } from '@/context/EnvContext'; import { useTranslation } from '@/hooks/useTranslation'; +import { useSettingsStore } from '@/store/settingsStore'; const TabNavigation: React.FC<{ activeTab: string; @@ -13,32 +15,43 @@ const TabNavigation: React.FC<{ }> = ({ activeTab, onTabChange }) => { const _ = useTranslation(); const { appService } = useEnv(); + const { settings } = useSettingsStore(); + const aiEnabled = settings?.aiSettings?.enabled ?? false; - const tabs = ['toc', 'annotations', 'bookmarks']; + const tabs = ['toc', 'annotations', 'bookmarks', ...(aiEnabled ? ['history'] : [])]; + + const getTabLabel = (tab: string) => { + switch (tab) { + case 'toc': + return _('TOC'); + case 'annotations': + return _('Annotate'); + case 'bookmarks': + return _('Bookmark'); + case 'history': + return _('Chat'); + default: + return ''; + } + }; return (
-
{tabs.map((tab) => (
onTabChange(tab)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -46,18 +59,18 @@ const TabNavigation: React.FC<{ onTabChange(tab); } }} - title={tab === 'toc' ? _('TOC') : tab === 'annotations' ? _('Annotate') : _('Bookmark')} - aria-label={ - tab === 'toc' ? _('TOC') : tab === 'annotations' ? _('Annotate') : _('Bookmark') - } + title={getTabLabel(tab)} + aria-label={getTabLabel(tab)} > -
+
{tab === 'toc' ? ( - + ) : tab === 'annotations' ? ( - + + ) : tab === 'bookmarks' ? ( + ) : ( - + )}
diff --git a/apps/readest-app/src/app/reader/hooks/useOpenAIInNotebook.ts b/apps/readest-app/src/app/reader/hooks/useOpenAIInNotebook.ts new file mode 100644 index 00000000..b606454d --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useOpenAIInNotebook.ts @@ -0,0 +1,42 @@ +import { useCallback } from 'react'; +import { useNotebookStore } from '@/store/notebookStore'; +import { useAIChatStore } from '@/store/aiChatStore'; + +// Hook to open the Notebook panel with the AI tab and optionally load a specific conversation + +export function useOpenAIInNotebook() { + const { setNotebookVisible, setNotebookActiveTab } = useNotebookStore(); + const { setActiveConversation, createConversation } = useAIChatStore(); + + const openAIInNotebook = useCallback( + async (options?: { + conversationId?: string; + bookHash?: string; + newConversationTitle?: string; + }) => { + // Open notebook and switch to AI tab + setNotebookVisible(true); + setNotebookActiveTab('ai'); + + if (options?.conversationId) { + // Load existing conversation + await setActiveConversation(options.conversationId); + } else if (options?.bookHash && options?.newConversationTitle) { + // Create new conversation + await createConversation(options.bookHash, options.newConversationTitle); + } + }, + [setNotebookVisible, setNotebookActiveTab, setActiveConversation, createConversation], + ); + + const closeAIInNotebook = useCallback(() => { + setNotebookActiveTab('notes'); + }, [setNotebookActiveTab]); + + return { + openAIInNotebook, + closeAIInNotebook, + }; +} + +export default useOpenAIInNotebook; diff --git a/apps/readest-app/src/app/reader/hooks/usePagination.ts b/apps/readest-app/src/app/reader/hooks/usePagination.ts index eae8592b..e3be4749 100644 --- a/apps/readest-app/src/app/reader/hooks/usePagination.ts +++ b/apps/readest-app/src/app/reader/hooks/usePagination.ts @@ -100,8 +100,8 @@ export const viewPagination = ( export const usePagination = ( bookKey: string, - viewRef: React.MutableRefObject, - containerRef: React.RefObject, + viewRef: React.RefObject, + containerRef: React.RefObject, ) => { const { appService } = useEnv(); const { getBookData } = useBookDataStore(); diff --git a/apps/readest-app/src/components/Dropdown.tsx b/apps/readest-app/src/components/Dropdown.tsx index dc594770..5dfa9c27 100644 --- a/apps/readest-app/src/components/Dropdown.tsx +++ b/apps/readest-app/src/components/Dropdown.tsx @@ -16,8 +16,13 @@ interface DropdownProps { }>; disabled?: boolean; onToggle?: (isOpen: boolean) => void; + showTooltip?: boolean; } +type MenuItemProps = { + setIsDropdownOpen?: (open: boolean) => void; +}; + const enhanceMenuItems = ( children: ReactNode, setIsDropdownOpen: (isOpen: boolean) => void, @@ -27,7 +32,7 @@ const enhanceMenuItems = ( return node; } - const element = node as ReactElement; + const element = node as React.ReactElement>; const isMenuItem = element.type === MenuItem || (typeof element.type === 'function' && element.type.name === 'MenuItem'); @@ -61,6 +66,7 @@ const Dropdown: React.FC = ({ children, disabled, onToggle, + showTooltip = true, }) => { const [isOpen, setIsOpen] = useState(false); const [isFocused, setIsFocused] = useState(false); @@ -137,7 +143,7 @@ const Dropdown: React.FC = ({ aria-haspopup='menu' aria-expanded={isOpen} aria-label={label} - title={label} + title={showTooltip ? label : undefined} className={clsx( 'dropdown-toggle touch-target', isFocused && isOpen && 'bg-base-300/50', diff --git a/apps/readest-app/src/components/Menu.tsx b/apps/readest-app/src/components/Menu.tsx index f9f16df5..2dfe3ade 100644 --- a/apps/readest-app/src/components/Menu.tsx +++ b/apps/readest-app/src/components/Menu.tsx @@ -10,7 +10,7 @@ interface MenuProps { } const Menu: React.FC = ({ children, className, style, onCancel }) => { - const menuRef = useRef(null); + const menuRef = useRef(null); useKeyDownActions({ onCancel, elementRef: menuRef }); diff --git a/apps/readest-app/src/components/WindowButtons.tsx b/apps/readest-app/src/components/WindowButtons.tsx index 18639c51..4f356204 100644 --- a/apps/readest-app/src/components/WindowButtons.tsx +++ b/apps/readest-app/src/components/WindowButtons.tsx @@ -8,7 +8,7 @@ import { useTranslation } from '@/hooks/useTranslation'; interface WindowButtonsProps { className?: string; - headerRef?: React.RefObject; + headerRef?: React.RefObject; showMinimize?: boolean; showMaximize?: boolean; showClose?: boolean; diff --git a/apps/readest-app/src/components/assistant-ui/markdown-text.tsx b/apps/readest-app/src/components/assistant-ui/markdown-text.tsx new file mode 100644 index 00000000..3551f8bc --- /dev/null +++ b/apps/readest-app/src/components/assistant-ui/markdown-text.tsx @@ -0,0 +1,133 @@ +'use client'; + +import '@assistant-ui/react-markdown/styles/dot.css'; + +import { + type CodeHeaderProps, + MarkdownTextPrimitive, + unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, + useIsMarkdownCodeBlock, +} from '@assistant-ui/react-markdown'; +import remarkGfm from 'remark-gfm'; +import { type FC, memo, useState } from 'react'; +import { CheckIcon, CopyIcon } from 'lucide-react'; + +import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'; +import { cn } from '@/lib/utils'; + +const MarkdownTextImpl = () => { + return ( + + ); +}; + +export const MarkdownText = memo(MarkdownTextImpl); + +const CodeHeader: FC = ({ language, code }) => { + const { isCopied, copyToClipboard } = useCopyToClipboard(); + const onCopy = () => { + if (!code || isCopied) return; + copyToClipboard(code); + }; + + return ( +
+ {language} + + {!isCopied && } + {isCopied && } + +
+ ); +}; + +const useCopyToClipboard = ({ copiedDuration = 3000 }: { copiedDuration?: number } = {}) => { + const [isCopied, setIsCopied] = useState(false); + + const copyToClipboard = (value: string) => { + if (!value) return; + + navigator.clipboard.writeText(value).then(() => { + setIsCopied(true); + setTimeout(() => setIsCopied(false), copiedDuration); + }); + }; + + return { isCopied, copyToClipboard }; +}; + +const defaultComponents = memoizeMarkdownComponents({ + h1: ({ className, ...props }) => ( + // eslint-disable-next-line jsx-a11y/heading-has-content +

+ ), + h2: ({ className, ...props }) => ( + // eslint-disable-next-line jsx-a11y/heading-has-content +

+ ), + h3: ({ className, ...props }) => ( + // eslint-disable-next-line jsx-a11y/heading-has-content +

+ ), + p: ({ className, ...props }) => ( +

+ ), + a: ({ className, ...props }) => ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ), + blockquote: ({ className, ...props }) => ( +

+ ), + ul: ({ className, ...props }) => ( +