forked from akai/readest
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user