8bea05478a
* refactor(ai): remove chat icons from conversation list items * feat(ai): add scroll-to-bottom button with animations and dynamic spacer * fix(ai): align connection status icon with text in settings * feat(ai): scroll to bottom on chat panel open and improve scroll behavior * feat(ai): add loading overlay with blur transition for chat history * feat(ai): pass loading state through component chain for history loading
366 lines
10 KiB
TypeScript
366 lines
10 KiB
TypeScript
'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,
|
|
isLoadingHistory,
|
|
} = 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}
|
|
isLoadingHistory={isLoadingHistory}
|
|
hasActiveConversation={!!activeConversationId}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const AIAssistantWithRuntime = ({
|
|
adapter,
|
|
historyAdapter,
|
|
onResetIndex,
|
|
isLoadingHistory,
|
|
hasActiveConversation,
|
|
}: {
|
|
adapter: NonNullable<ReturnType<typeof createTauriAdapter>>;
|
|
historyAdapter?: ThreadHistoryAdapter;
|
|
onResetIndex: () => void;
|
|
isLoadingHistory: boolean;
|
|
hasActiveConversation: boolean;
|
|
}) => {
|
|
const runtime = useLocalRuntime(adapter, {
|
|
adapters: historyAdapter ? { history: historyAdapter } : undefined,
|
|
});
|
|
|
|
if (!runtime) return null;
|
|
|
|
return (
|
|
<AssistantRuntimeProvider runtime={runtime}>
|
|
<ThreadWrapper
|
|
onResetIndex={onResetIndex}
|
|
isLoadingHistory={isLoadingHistory}
|
|
hasActiveConversation={hasActiveConversation}
|
|
/>
|
|
</AssistantRuntimeProvider>
|
|
);
|
|
};
|
|
|
|
const ThreadWrapper = ({
|
|
onResetIndex,
|
|
isLoadingHistory,
|
|
hasActiveConversation,
|
|
}: {
|
|
onResetIndex: () => void;
|
|
isLoadingHistory: boolean;
|
|
hasActiveConversation: boolean;
|
|
}) => {
|
|
const [sources, setSources] = useState(getLastSources());
|
|
const assistantRuntime = useAssistantRuntime();
|
|
const { setActiveConversation } = useAIChatStore();
|
|
|
|
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}
|
|
isLoadingHistory={isLoadingHistory}
|
|
hasActiveConversation={hasActiveConversation}
|
|
/>
|
|
);
|
|
};
|
|
|
|
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;
|