feat(ai): AI reading assistant phase 2 (#3023)

* feat(ai): add dependencies

* chore: bump zod version to default version

* feat(ai): define types and model constants

* feat(ai): ollama provider for local LLM

* feat(ai): implement openrouter provider for cloud models

* feat(settings): register ai settings panel in global dialog

* refactor(ai): expose provider factory and service layer entry point

* test(ai): add unit tests for the providers

* test(ai): add unit tests for the providers

* feat(ai): settings panel for ai configurations

* refactor(ai): rewrite aipanel with autosave and greyed out disabled state

* fix: remove unused onClose prop from aipanel

* test(ai): update mock data

* refactor(ai): remove models

* refactor: use centralised defaults in system defaults

* chore(ai): remove comments

* fix(ai): merge default ai settings on load to prevent undefined values

* refactor(ai): rewrite settings panel with autosave and model input

* feat(ai): add ai tab with simplified highlighting

* feat(sidebar): render AIAssistant for ai tab

* feat(ai): add chat UI

* feat(ai); add chat service with RAG context

* feat(ai): temp debug logger

* feat(ai): add RAG service

* feat(ai): add text chunking utility

* feat(ai): add structured method

* feat(ai): add chatstructured method

* feat(ai): add rag types nd structured output schema

* feat(ai): add aistore, indexdb, bm25

* fix: update lock file

* feat(ai): update types for AI SDK v5

* feat(ai): add placeholder gateway model constants

* refactor(ai): update OllamaProvider for AI SDK

* feat(ai): add native gateway provider

* refactor(ai): update provider exports

* refactor(ai): use streamText from AI SDK

* refactor(ai): use embed from AI sdk

* refactor(ai): update provider factory exports

* feat(ai): add AI Elements and shadcn components

* config: add shadcn component config

* deps: add AI SDK and AI Elements dependencies

* config: add ai packages to transpilePackages

* refactor(ai): remove OpenRouterProvider and old tests

* feat(ai):add assistant-ui components

* feat(ai): add TauriChatAdapter for assistant-ui runtime

* refactor(ai): remove ai-elements components

* dep(ai): install assistant-ui and update next config

* chore(ai): export adapters from service index

* feat(ui): enhance ui components for assistant integration

* feat(settings): migrate ai settings to gateway

* feat(sidebar): integrate assistant-ui

* feat: add ai settings toggle to sidebar content

* feat: conditionally show ai tab in sidebar navigation

* feat: update ai model constants for cheaper options

* feat: add gateway provider with proxied embedding

* feat: add timeouts to ollama provider health checks

* feat: add retry logic to rag service embeddings

* feat: add error recovery to ai store

* feat: add ai feature tests

* feat: add ai api endpoints

* feat: add proxied gateway embedding provider

* feat: add ai runtime utilities

* feat: add ai retry utilities

* feat: add tauri env example template

* feat: add web env example template

* chore: add env

* feat(ai): update models and pricing, remove GLM-4.7-FlashX

* feat(ai): improve system prompt with official headings and no numeric citations

* feat(ai): optimize system prompt for tauri chat

* feat(ui): refine ai chat UI and relocate sources

* feat(ui): update ai settings panel with model pricing and custom model support

* feat(ai): add custom model support to ai settings

* test(ai): update constants tests for removed model

* feat(api): implement ai chat proxy route

* feat(api): implement ai embedding proxy route

* feat(ai): implement ai gateway health check and proxy logic

* feat(ai): simplify proxied embedding provider

* feat(ui): improve markdown text rendering

* feat(ui): add input group component

* test(ai): update ai provider tests

* feat(ai): add pageNumber to text chunk schema

* feat(ai): implement page-based chunking with 1500 char formula

* feat(ai): bump db to v2 and add store reset migration

* feat(ai): transition rag pipeline to page level spoiler filtering

* feat(ai): overhaul readest persona and antijailbreak prompt

* feat(ai): update tauri adapter for page tracking and persona

* chore(ai): export aiStore and logger from core index

* feat(reader): integrate page tracking and manual index reset

* feat(ui): add re-index action and reset logic to chat

* chore: sync pnpm lockfile with ai dependencies

* feat(utils): add browser-safe file utilities for web builds

* refactor(utils): use dynamic tauri fs import to prevent web crashes

* refactor(services): defer osType call to init() for web compatibility

* refactor(services): import RemoteFile from file.web

* refactor(services): import ClosableFile from file.web

* fix(libs): cast Entry to any for getData access

* fix(annotator): cast Overlayer to any for bubble access

* refactor(ai): replace SparklesIcon with BookOpenIcon for index prompt

* test(ai): add pageNumber to TextChunk mocks

* test(ai): fix chunkSection signature in tests

* chore: update files

* fix(ai): prevent useLocalRuntime crash when adapter is null

* refactor: optimize annotator overlay drawing

* feat: stabilize AI assistant runtime and adapter

* refactor: improve document zip loader type safety

* feat: update tauri chat adapter for dynamic options

* fix: restore architecture comments and refine platform properties

* build: update lockfile with assistant-ui patch

* fix(library): patch @assistant-ui/react for runtime initialization

* build: update dependencies in readest-app

* build: update root dependencies and patch configuration

* fix(ai): patch @assistant-ui/react for thread deletion and runtime init

* fix(ai): update assistant-ui patch with dist guards and deletion fallback

* build: sync lockfile with assistant-ui patch updates

* chore(env): update .gitignore by removing .env files from it

* chore(env): update .gitignore by adding .env.local

* chore(env): update .gitignore by adding .env*.local

* fix: restore static osType import

* chore: sync submodules with upstream/main

* refactor: remove redundant file.web module and revert import

* chore: update pnpm-lock.yaml

* refactor: revert guards

* refactor; remove deprecated codes and extract prompts.ts

* refactor(ai): remove unused ragservice exports

* refactor: remove unused ollama and embedding models

* refactor: remove unused type

* test: remove test for the now deleted constants

* refactor: remove unused export

* style: fix ui component formatting

* style: fix core and style file formatting

* test: fix broken ai provider import

* fix: typescript error

* fix: add eslint disable command

* fix(deps): remove unused ai sdk provider util after v6 ai sdk migration

* fix(patch): add lookbehind regex patch

* feat(dep): upgrade vercel ai sdk to v6 and ai-sdk-ollama to v3

* chore: update lockfile for vercel ai sdk v6

* refactor(ai): remove EmbeddingModel generic for ai sdk v6

* refactor(ai): remove EmbeddingModel generic for ai sdk v6

* test(ai): update mock to use embeddingModel

* fix(patch): add lookbehind regex patch for email autolinks in markdown

* refactor(ai): use ai sdk v6 syntax

* fix: prettier formatting

* chore: revert cargo.lock

* fix(ai): update proxied embedding model to v3 spec

* feat(ai): add aiconversation types for chat persistence

* feat(ai): add conversation/message indexeddb and crud operations

* feat(ai): create aiChatStore zustand store for chat state management

* feat(notebook): add notebookactivetab state for Notes/AI

* refactor(ai): refine conversation and message types for persistence

* feat(types): add notebookActiveTab to ReadSettings type

* chore: update deps

* feat: add notebookactive tab default value

* feat: add hook for ai chat

* feat: update left side panel with history/chat icon

* feat: integrate ChatHistoryView into sidebar content

* feat: create UI for managing AI chat history

* feat: implement persistent history with assistant-ui adapter

* feat: create tab navigation component for notes and AI

* feat: add tab navigation and AI assistant view

* feat: update header to display active tab title

* fix: formatting

* feat: remove title and update new chat button

* fix: formatting

* fix: revert tooltip and styling

* feat: implement cross-platform ask dialog bridge

* feat(ai): preserve history during ui clear & use native dialogs

* fix: align notebook navigation height with sidebar tabs

* fix(ai): add missing dependency to handleDeleteConversation hook

* docs: update PROJECT.md with session highlights

* chore: delete projectmd

* chore: update package.json and lock file

* chore: update package.json

* chore: remove patch

* chore: upgrade react types to 19 and show ai features only in development mode for now

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
Mohammed Efaz
2026-01-24 16:38:48 +06:00
committed by GitHub
parent 224acd68b1
commit 5bbc5ceccc
79 changed files with 12716 additions and 4607 deletions
@@ -91,7 +91,7 @@ const FoliateViewer: React.FC<{
const viewSettings = getViewSettings(bookKey);
const viewRef = useRef<FoliateView | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const isViewCreated = useRef(false);
const doubleClickDisabled = useRef(!!viewSettings?.disableDoubleClick);
const [toastMessage, setToastMessage] = useState('');
@@ -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<{
<MdArrowBackIosNew />
</button>
</div>
<div className='flex items-center justify-end gap-x-4'>
<button
title={isSearchBarVisible ? _('Hide Search Bar') : _('Show Search Bar')}
onClick={handleToggleSearchBar}
className={clsx('btn btn-ghost h-8 min-h-8 w-8 p-0', isSearchBarVisible && 'bg-base-300')}
>
<FiSearch size={iconSize18} />
</button>
</div>
{showSearchButton && (
<div className='flex items-center justify-end gap-x-4'>
<button
title={isSearchBarVisible ? _('Hide Search Bar') : _('Show Search Bar')}
onClick={handleToggleSearchBar}
className={clsx(
'btn btn-ghost h-8 min-h-8 w-8 p-0',
isSearchBarVisible && 'bg-base-300',
)}
>
<FiSearch size={iconSize18} />
</button>
</div>
)}
</div>
);
};
@@ -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<BookNote[] | null>(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 = ({}) => {
<div className='flex-shrink-0'>
<NotebookHeader
isPinned={isNotebookPinned}
isSearchBarVisible={isSearchBarVisible}
isSearchBarVisible={isSearchBarVisible && notebookActiveTab === 'notes'}
handleClose={() => setNotebookVisible(false)}
handleTogglePin={handleTogglePin}
handleToggleSearchBar={handleToggleSearchBar}
showSearchButton={notebookActiveTab === 'notes'}
/>
<div
className={clsx('search-bar', {
'search-bar-visible': isSearchBarVisible,
})}
>
<SearchBar
isVisible={isSearchBarVisible}
bookKey={sideBarBookKey}
searchTerm={searchTerm}
onSearchResultChange={setSearchResults}
/>
</div>
</div>
<div className='flex-grow overflow-y-auto px-3'>
{isSearchBarVisible && searchResults && !hasSearchResults && hasAnyNotes && (
<div className='flex h-32 items-center justify-center text-gray-500'>
<p className='font-size-sm text-center'>{_('No notes match your search')}</p>
{notebookActiveTab === 'notes' && (
<div
className={clsx('search-bar', {
'search-bar-visible': isSearchBarVisible,
})}
>
<SearchBar
isVisible={isSearchBarVisible}
bookKey={sideBarBookKey}
searchTerm={searchTerm}
onSearchResultChange={setSearchResults}
/>
</div>
)}
<div dir='ltr'>
{filteredExcerptNotes.length > 0 && (
<p className='content font-size-base'>
{_('Excerpts')}
{isSearchBarVisible && searchResults && (
<span className='font-size-xs ml-2 text-gray-500'>
({filteredExcerptNotes.length})
</span>
)}
</p>
)}
</div>
{notebookActiveTab === 'ai' ? (
<div className='flex min-h-0 flex-1 flex-col'>
<AIAssistant key={activeConversationId ?? 'new'} bookKey={sideBarBookKey} />
</div>
<ul className=''>
{filteredExcerptNotes.map((item, index) => (
<li key={`${index}-${item.id}`} className='my-2'>
<div
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
handleEditNote(item, true);
}
}}
className='booknote-item collapse-arrow border-base-300 bg-base-100 collapse border'
>
) : (
<div className='flex-grow overflow-y-auto px-3'>
{isSearchBarVisible && searchResults && !hasSearchResults && hasAnyNotes && (
<div className='flex h-32 items-center justify-center text-gray-500'>
<p className='font-size-sm text-center'>{_('No notes match your search')}</p>
</div>
)}
<div dir='ltr'>
{filteredExcerptNotes.length > 0 && (
<p className='content font-size-base'>
{_('Excerpts')}
{isSearchBarVisible && searchResults && (
<span className='font-size-xs ml-2 text-gray-500'>
({filteredExcerptNotes.length})
</span>
)}
</p>
)}
</div>
<ul className=''>
{filteredExcerptNotes.map((item, index) => (
<li key={`${index}-${item.id}`} className='my-2'>
<div
className={clsx(
'collapse-title pe-8 text-sm font-medium',
'h-[2.5rem] min-h-[2.5rem] p-[0.6rem]',
)}
style={
{
'--top-override': '1.25rem',
'--end-override': '0.7rem',
} as React.CSSProperties
}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
handleEditNote(item, true);
}
}}
className='booknote-item collapse-arrow border-base-300 bg-base-100 collapse border'
>
<p className='line-clamp-1'>{item.text || `Excerpt ${index + 1}`}</p>
</div>
<div className='collapse-content font-size-xs select-text px-3 pb-0'>
<p className='hyphens-auto text-justify'>{item.text}</p>
<div className='flex justify-end' dir='ltr'>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/}
<div
className='font-size-xs cursor-pointer align-bottom text-red-500 hover:text-red-600'
onClick={handleEditNote.bind(null, item, true)}
aria-label={_('Delete')}
>
{_('Delete')}
<div
className={clsx(
'collapse-title pe-8 text-sm font-medium',
'h-[2.5rem] min-h-[2.5rem] p-[0.6rem]',
)}
style={
{
'--top-override': '1.25rem',
'--end-override': '0.7rem',
} as React.CSSProperties
}
>
<p className='line-clamp-1'>{item.text || `Excerpt ${index + 1}`}</p>
</div>
<div className='collapse-content font-size-xs select-text px-3 pb-0'>
<p className='hyphens-auto text-justify'>{item.text}</p>
<div className='flex justify-end' dir='ltr'>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/}
<div
className='font-size-xs cursor-pointer align-bottom text-red-500 hover:text-red-600'
onClick={handleEditNote.bind(null, item, true)}
aria-label={_('Delete')}
>
{_('Delete')}
</div>
</div>
</div>
</div>
</div>
</li>
))}
</ul>
<div dir='ltr'>
{(notebookNewAnnotation || filteredAnnotationNotes.length > 0) && (
<p className='content font-size-base'>
{_('Notes')}
{isSearchBarVisible && searchResults && filteredAnnotationNotes.length > 0 && (
<span className='font-size-xs ml-2 text-gray-500'>
({filteredAnnotationNotes.length})
</span>
)}
</p>
</li>
))}
</ul>
<div dir='ltr'>
{(notebookNewAnnotation || filteredAnnotationNotes.length > 0) && (
<p className='content font-size-base'>
{_('Notes')}
{isSearchBarVisible && searchResults && filteredAnnotationNotes.length > 0 && (
<span className='font-size-xs ml-2 text-gray-500'>
({filteredAnnotationNotes.length})
</span>
)}
</p>
)}
</div>
{(notebookNewAnnotation || notebookEditAnnotation) && !isSearchBarVisible && (
<NoteEditor onSave={handleSaveNote} onEdit={(item) => handleEditNote(item, false)} />
)}
<ul>
{filteredAnnotationNotes.map((item, index) => (
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={sideBarBookKey} item={item} />
))}
</ul>
</div>
{(notebookNewAnnotation || notebookEditAnnotation) && !isSearchBarVisible && (
<NoteEditor onSave={handleSaveNote} onEdit={(item) => handleEditNote(item, false)} />
)}
<ul>
{filteredAnnotationNotes.map((item, index) => (
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={sideBarBookKey} item={item} />
))}
</ul>
)}
<div
className='flex-shrink-0'
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) / 2}px`,
}}
>
<NotebookTabNavigation activeTab={notebookActiveTab} onTabChange={handleTabChange} />
</div>
</div>
</>
@@ -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<NotebookTabNavigationProps> = ({
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 <PiNotePencil className='mx-auto' size={20} />;
case 'ai':
return <PiRobot className='mx-auto' size={20} />;
default:
return null;
}
};
return (
<div
className={clsx(
'bottom-tab border-base-300/50 bg-base-200/20 flex min-h-[52px] w-full border-t',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
)}
dir='ltr'
>
{tabs.map((tab) => (
<div
key={tab}
tabIndex={0}
role='button'
className={clsx(
'm-1.5 flex-1 cursor-pointer rounded-lg p-2 transition-colors duration-200',
activeTab === tab && 'bg-base-300/85',
)}
onClick={() => onTabChange(tab)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onTabChange(tab);
}
}}
title={getTabLabel(tab)}
aria-label={getTabLabel(tab)}
>
<div className='m-0 flex h-6 items-center p-0'>{getTabIcon(tab)}</div>
</div>
))}
</div>
);
};
export default NotebookTabNavigation;
@@ -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<ThreadHistoryAdapter | undefined>(() => {
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 (
<AIAssistantWithRuntime
adapter={adapter}
historyAdapter={historyAdapter}
onResetIndex={onResetIndex}
/>
);
};
// separate component to ensure useLocalRuntime is always called with a valid adapter
const AIAssistantWithRuntime = ({
adapter,
historyAdapter,
onResetIndex,
}: {
adapter: NonNullable<ReturnType<typeof createTauriAdapter>>;
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 (
<AssistantRuntimeProvider runtime={runtime}>
<ThreadWrapper onResetIndex={onResetIndex} />
</AssistantRuntimeProvider>
);
};
// 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 <Thread sources={sources} onClear={handleClear} onResetIndex={onResetIndex} />;
};
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<EmbeddingProgress | null>(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<typeof indexBook>[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 (
<div className='flex h-full items-center justify-center p-4'>
<p className='text-muted-foreground text-sm'>{_('Enable AI in Settings')}</p>
</div>
);
}
// 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 (
<div className='flex h-full flex-col items-center justify-center gap-3 p-4 text-center'>
<div className='bg-primary/10 rounded-full p-3'>
<BookOpenIcon className='text-primary size-6' />
</div>
<div>
<h3 className='text-foreground mb-0.5 text-sm font-medium'>{_('Index This Book')}</h3>
<p className='text-muted-foreground text-xs'>
{_('Enable AI search and chat for this book')}
</p>
</div>
<Button onClick={handleIndex} size='sm' className='h-8 text-xs'>
<BookOpenIcon className='mr-1.5 size-3.5' />
{_('Start Indexing')}
</Button>
</div>
);
}
if (isIndexing) {
return (
<div className='flex h-full flex-col items-center justify-center gap-3 p-4 text-center'>
<Loader2Icon className='text-primary size-6 animate-spin' />
<div>
<p className='text-foreground mb-1 text-sm font-medium'>{_('Indexing book...')}</p>
<p className='text-muted-foreground text-xs'>
{indexProgress?.phase === 'embedding'
? `${indexProgress.current} / ${indexProgress.total} chunks`
: _('Preparing...')}
</p>
</div>
<div className='bg-muted h-1.5 w-32 overflow-hidden rounded-full'>
<div
className='bg-primary h-full transition-all duration-300'
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
);
}
return (
<AIAssistantChat
aiSettings={aiSettings}
bookHash={bookHash}
bookTitle={bookTitle}
authorName={authorName}
currentPage={currentPage}
onResetIndex={handleResetIndex}
/>
);
};
export default AIAssistant;
@@ -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<ChatHistoryViewProps> = ({ 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<string | null>(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 (
<div className='flex h-full items-center justify-center p-4'>
<div className='border-primary size-5 animate-spin rounded-full border-2 border-t-transparent' />
</div>
);
}
return (
<div className='relative flex h-full flex-col'>
{/* Conversation list */}
<div className='flex-1 overflow-y-auto'>
{conversations.length === 0 ? (
<div className='flex h-full flex-col items-center justify-center gap-3 p-4 text-center'>
<div className='bg-base-300/50 rounded-full p-3'>
<LuMessageSquare className='text-base-content/50 size-6' />
</div>
<div>
<p className='text-base-content/70 text-sm'>{_('No conversations yet')}</p>
<p className='text-base-content/50 text-xs'>
{_('Start a new chat to ask questions about this book')}
</p>
</div>
</div>
) : (
<ul className='divide-base-300/30 divide-y pb-16'>
{conversations.map((conversation) => (
<li
key={conversation.id}
className={clsx(
'group flex cursor-pointer items-start gap-2 px-3 py-2.5',
'hover:bg-base-300/50 transition-colors duration-150',
)}
>
<div
className='flex flex-1 items-start gap-2'
tabIndex={0}
role='button'
onClick={() => handleSelectConversation(conversation)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelectConversation(conversation);
}
}}
>
<LuMessageSquare className='text-base-content/40 mt-0.5 size-4 flex-shrink-0' />
<div className='min-w-0 flex-1'>
{editingId === conversation.id ? (
<div
className='flex items-center gap-1'
role='presentation'
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<input
type='text'
value={editTitle}
onChange={(e) => 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
/>
<button
onClick={handleSaveRename}
className='btn btn-ghost btn-xs text-success'
aria-label={_('Save')}
>
<LuCheck size={14} />
</button>
<button
onClick={handleCancelRename}
className='btn btn-ghost btn-xs text-error'
aria-label={_('Cancel')}
>
<LuX size={14} />
</button>
</div>
) : (
<>
<p className='text-base-content line-clamp-1 text-sm font-medium'>
{conversation.title}
</p>
<p className='text-base-content/50 text-xs'>
{dayjs(conversation.updatedAt).format('MMM D, YYYY h:mm A')}
</p>
</>
)}
</div>
</div>
{editingId !== conversation.id && (
<div className='flex flex-shrink-0 gap-0.5 opacity-0 transition-opacity group-hover:opacity-100'>
<button
onClick={(e) => handleStartRename(e, conversation)}
className='btn btn-ghost btn-xs'
aria-label={_('Rename')}
>
<LuPencil size={12} />
</button>
<button
onClick={(e) => handleDeleteConversation(e, conversation.id)}
className='btn btn-ghost btn-xs text-error'
aria-label={_('Delete')}
>
<LuTrash2 size={12} />
</button>
</div>
)}
</li>
))}
</ul>
)}
</div>
{/* Floating New Chat button at bottom right */}
<div className='absolute bottom-4 right-4'>
<button
onClick={handleNewConversation}
className={clsx(
'flex items-center gap-2 rounded-full px-4 py-2',
'bg-base-300 text-base-content',
'hover:bg-base-content/10',
'border-base-content/10 border',
'shadow-sm',
'transition-all duration-200 ease-out',
'active:scale-[0.97]',
)}
aria-label={_('New Chat')}
>
<LuPlus size={16} />
<span className='text-sm font-medium'>{_('New Chat')}</span>
</button>
</div>
</div>
);
};
export default ChatHistoryView;
@@ -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',
)}
>
<OverlayScrollbarsComponent
className='min-h-0 flex-1'
options={{
scrollbars: { autoHide: 'scroll', clickScroll: true },
showNativeOverlaidScrollbars: false,
}}
defer
>
<div
className={clsx('scroll-container h-full transition-opacity duration-300 ease-in-out', {
'opacity-0': fade,
'opacity-100': !fade,
})}
{targetTab === 'history' ? (
<ChatHistoryView bookKey={sideBarBookKey} />
) : (
<OverlayScrollbarsComponent
className='min-h-0 flex-1'
options={{
scrollbars: { autoHide: 'scroll', clickScroll: true },
showNativeOverlaidScrollbars: false,
}}
defer
>
{targetTab === 'toc' && bookDoc.toc && (
<TOCView toc={bookDoc.toc} sections={bookDoc.sections} bookKey={sideBarBookKey} />
)}
{targetTab === 'annotations' && (
<BooknoteView type='annotation' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
{targetTab === 'bookmarks' && (
<BooknoteView type='bookmark' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
</div>
</OverlayScrollbarsComponent>
<div
className={clsx(
'scroll-container h-full transition-opacity duration-300 ease-in-out',
{
'opacity-0': fade,
'opacity-100': !fade,
},
)}
>
{targetTab === 'toc' && bookDoc.toc && (
<TOCView toc={bookDoc.toc} sections={bookDoc.sections} bookKey={sideBarBookKey} />
)}
{targetTab === 'annotations' && (
<BooknoteView type='annotation' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
{targetTab === 'bookmarks' && (
<BooknoteView type='bookmark' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
</div>
</OverlayScrollbarsComponent>
)}
</div>
<div
className='flex-shrink-0'
@@ -61,11 +61,12 @@ const SidebarHeader: React.FC<{
</button>
<Dropdown
label={_('Book Menu')}
showTooltip={false}
className={clsx(
window.innerWidth < 640 && 'dropdown-end',
'dropdown-bottom flex justify-center',
)}
menuClassName={window.innerWidth < 640 ? 'no-triangle mt-1' : 'dropdown-center mt-1'}
menuClassName={window.innerWidth < 640 ? 'no-triangle mt-1' : 'dropdown-center mt-3'}
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineMenu className='fill-base-content' />}
>
@@ -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 (
<div
className={clsx(
'bottom-tab border-base-300/50 bg-base-200/20 relative flex w-full border-t',
'bottom-tab border-base-300/50 bg-base-200/20 flex w-full border-t',
appService?.hasRoundedWindow && 'rounded-window-bottom-left',
)}
dir='ltr'
>
<div
className={clsx(
'bg-base-300/85 absolute bottom-1.5 start-1 z-10 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
'transform transition-transform duration-300',
activeTab === 'toc' && 'translate-x-0',
activeTab === 'annotations' && 'translate-x-[calc(100%+8px)]',
activeTab === 'bookmarks' && 'translate-x-[calc(200%+16px)]',
)}
/>
{tabs.map((tab) => (
<div
key={tab}
tabIndex={0}
role='button'
className='z-[11] m-1.5 flex-1 cursor-pointer rounded-md p-2'
className={clsx(
'm-1.5 flex-1 cursor-pointer rounded-lg p-2 transition-colors duration-200',
activeTab === tab && 'bg-base-300/85',
)}
onClick={() => 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)}
>
<div className={clsx('m-0 flex h-6 items-center p-0')}>
<div className='m-0 flex h-6 items-center p-0'>
{tab === 'toc' ? (
<TOCIcon className='mx-auto' />
<IoIosList className='mx-auto' />
) : tab === 'annotations' ? (
<NoteIcon className='mx-auto' />
<PiNotePencil className='mx-auto' />
) : tab === 'bookmarks' ? (
<MdBookmarkBorder className='mx-auto' />
) : (
<BookmarkIcon className='mx-auto' />
<LuMessageSquare className='mx-auto' />
)}
</div>
</div>