diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 2ae80be..4b8ce79 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -92,12 +92,24 @@ const handleEmitterEvents = async ( sources = parsedData.data; } }); + let modelStats = { + modelName: '', + }; + + stream.on('stats', (data) => { + const parsedData = JSON.parse(data); + if (parsedData.type === 'modelStats') { + modelStats = parsedData.data; + } + }); + stream.on('end', () => { writer.write( encoder.encode( JSON.stringify({ type: 'messageEnd', messageId: aiMessageId, + modelStats: modelStats, }) + '\n', ), ); @@ -109,10 +121,9 @@ const handleEmitterEvents = async ( chatId: chatId, messageId: aiMessageId, role: 'assistant', - metadata: JSON.stringify({ - createdAt: new Date(), - ...(sources && sources.length > 0 && { sources }), - }), + metadata: { + modelStats: modelStats, + }, }) .execute(); }); diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index e97d189..42fff1e 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from 'react'; import { cn } from '@/lib/utils'; import { Switch } from '@headlessui/react'; import ThemeSwitcher from '@/components/theme/Switcher'; -import { ImagesIcon, VideoIcon } from 'lucide-react'; +import { ImagesIcon, VideoIcon, Layers3 } from 'lucide-react'; import Link from 'next/link'; import { PROVIDER_METADATA } from '@/lib/providers'; @@ -147,6 +147,7 @@ const Page = () => { const [isLoading, setIsLoading] = useState(false); const [automaticImageSearch, setAutomaticImageSearch] = useState(false); const [automaticVideoSearch, setAutomaticVideoSearch] = useState(false); + const [automaticSuggestions, setAutomaticSuggestions] = useState(true); const [systemInstructions, setSystemInstructions] = useState(''); const [savingStates, setSavingStates] = useState>({}); const [contextWindowSize, setContextWindowSize] = useState(2048); @@ -214,6 +215,9 @@ const Page = () => { setAutomaticVideoSearch( localStorage.getItem('autoVideoSearch') === 'true', ); + setAutomaticSuggestions( + localStorage.getItem('autoSuggestions') !== 'false', // default to true if not set + ); const storedContextWindow = parseInt( localStorage.getItem('ollamaContextWindow') ?? '2048', ); @@ -372,6 +376,8 @@ const Page = () => { localStorage.setItem('autoImageSearch', value.toString()); } else if (key === 'automaticVideoSearch') { localStorage.setItem('autoVideoSearch', value.toString()); + } else if (key === 'automaticSuggestions') { + localStorage.setItem('autoSuggestions', value.toString()); } else if (key === 'chatModelProvider') { localStorage.setItem('chatModelProvider', value); } else if (key === 'chatModel') { @@ -526,6 +532,48 @@ const Page = () => { /> + +
+
+
+ +
+
+

+ Automatic Suggestions +

+

+ Automatically show related suggestions after + responses +

+
+
+ { + setAutomaticSuggestions(checked); + saveConfig('automaticSuggestions', checked); + }} + className={cn( + automaticSuggestions + ? 'bg-[#24A0ED]' + : 'bg-light-200 dark:bg-dark-200', + 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none', + )} + > + + +
diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index bee90b5..0cbf1f6 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -20,7 +20,14 @@ const Chat = ({ setOptimizationMode, }: { messages: Message[]; - sendMessage: (message: string) => void; + sendMessage: ( + message: string, + options?: { + messageId?: string; + rewriteIndex?: number; + suggestions?: string[]; + }, + ) => void; loading: boolean; messageAppeared: boolean; rewrite: (messageId: string) => void; diff --git a/src/components/ChatWindow.tsx b/src/components/ChatWindow.tsx index b4882e5..306bb70 100644 --- a/src/components/ChatWindow.tsx +++ b/src/components/ChatWindow.tsx @@ -13,6 +13,14 @@ import { Settings } from 'lucide-react'; import Link from 'next/link'; import NextError from 'next/error'; +export type ModelStats = { + modelName: string; +}; + +export type MessageMetadata = { + modelStats?: ModelStats; +}; + export type Message = { messageId: string; chatId: string; @@ -21,6 +29,7 @@ export type Message = { role: 'user' | 'assistant'; suggestions?: string[]; sources?: Document[]; + metadata?: MessageMetadata; }; export interface File { @@ -207,7 +216,6 @@ const loadMessages = async ( const messages = data.messages.map((msg: any) => { return { ...msg, - ...JSON.parse(msg.metadata), }; }) as Message[]; @@ -339,9 +347,25 @@ const ChatWindow = ({ id }: { id?: string }) => { const sendMessage = async ( message: string, - messageId?: string, - options?: { rewriteIndex?: number }, + options?: { + messageId?: string; + rewriteIndex?: number; + suggestions?: string[]; + }, ) => { + // Special case: If we're just updating an existing message with suggestions + if (options?.suggestions && options.messageId) { + setMessages((prev) => + prev.map((msg) => { + if (msg.messageId === options.messageId) { + return { ...msg, suggestions: options.suggestions }; + } + return msg; + }), + ); + return; + } + if (loading) return; if (!isConfigReady) { toast.error('Cannot send message before the configuration is ready'); @@ -369,7 +393,8 @@ const ChatWindow = ({ id }: { id?: string }) => { setChatHistory(messageChatHistory); } - messageId = messageId ?? crypto.randomBytes(7).toString('hex'); + const messageId = + options?.messageId ?? crypto.randomBytes(7).toString('hex'); setMessages((prevMessages) => [ ...prevMessages, @@ -419,6 +444,12 @@ const ChatWindow = ({ id }: { id?: string }) => { role: 'assistant', sources: sources, createdAt: new Date(), + metadata: { + // modelStats will be added when we receive messageEnd event + modelStats: { + modelName: data.modelName, + }, + }, }, ]); added = true; @@ -445,12 +476,29 @@ const ChatWindow = ({ id }: { id?: string }) => { ['assistant', recievedMessage], ]); + // Always update the message, adding modelStats if available + setMessages((prev) => + prev.map((message) => { + if (message.messageId === data.messageId) { + return { + ...message, + metadata: { + // Include model stats if available, otherwise null + modelStats: data.modelStats || null, + }, + }; + } + return message; + }), + ); + setLoading(false); const lastMsg = messagesRef.current[messagesRef.current.length - 1]; const autoImageSearch = localStorage.getItem('autoImageSearch'); const autoVideoSearch = localStorage.getItem('autoVideoSearch'); + const autoSuggestions = localStorage.getItem('autoSuggestions'); if (autoImageSearch === 'true') { document @@ -468,7 +516,8 @@ const ChatWindow = ({ id }: { id?: string }) => { lastMsg.role === 'assistant' && lastMsg.sources && lastMsg.sources.length > 0 && - !lastMsg.suggestions + !lastMsg.suggestions && + autoSuggestions !== 'false' // Default to true if not set ) { const suggestions = await getSuggestions(messagesRef.current); setMessages((prev) => @@ -550,7 +599,8 @@ const ChatWindow = ({ id }: { id?: string }) => { (msg) => msg.messageId === messageId, ); if (messageIndex == -1) return; - sendMessage(messages[messageIndex - 1].content, messageId, { + sendMessage(messages[messageIndex - 1].content, { + messageId: messageId, rewriteIndex: messageIndex, }); }; diff --git a/src/components/MessageActions/ModelInfo.tsx b/src/components/MessageActions/ModelInfo.tsx new file mode 100644 index 0000000..8299497 --- /dev/null +++ b/src/components/MessageActions/ModelInfo.tsx @@ -0,0 +1,72 @@ +'use client'; + +import React, { useState, useEffect, useRef } from 'react'; +import { Info } from 'lucide-react'; +import { ModelStats } from '../ChatWindow'; +import { cn } from '@/lib/utils'; + +interface ModelInfoButtonProps { + modelStats: ModelStats | null; +} + +const ModelInfoButton: React.FC = ({ modelStats }) => { + const [showPopover, setShowPopover] = useState(false); + const popoverRef = useRef(null); + const buttonRef = useRef(null); + + // Always render, using "Unknown" as fallback if model info isn't available + const modelName = modelStats?.modelName || 'Unknown'; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setShowPopover(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( +
+ + {showPopover && ( +
+
+

+ Model Information +

+
+
+ Model: + + {modelName} + +
+
+
+
+ )} +
+ ); +}; + +export default ModelInfoButton; diff --git a/src/components/MessageBox.tsx b/src/components/MessageBox.tsx index a7c46ec..193bff8 100644 --- a/src/components/MessageBox.tsx +++ b/src/components/MessageBox.tsx @@ -4,6 +4,7 @@ import React, { MutableRefObject, useEffect, useState } from 'react'; import { Message } from './ChatWindow'; import { cn } from '@/lib/utils'; +import { getSuggestions } from '@/lib/actions'; import { BookCopy, Disc3, @@ -11,10 +12,12 @@ import { StopCircle, Layers3, Plus, + Sparkles, } from 'lucide-react'; import Markdown, { MarkdownToJSX } from 'markdown-to-jsx'; import Copy from './MessageActions/Copy'; import Rewrite from './MessageActions/Rewrite'; +import ModelInfoButton from './MessageActions/ModelInfo'; import MessageSources from './MessageSources'; import SearchImages from './SearchImages'; import SearchVideos from './SearchVideos'; @@ -42,10 +45,36 @@ const MessageBox = ({ dividerRef?: MutableRefObject; isLast: boolean; rewrite: (messageId: string) => void; - sendMessage: (message: string) => void; + sendMessage: ( + message: string, + options?: { + messageId?: string; + rewriteIndex?: number; + suggestions?: string[]; + }, + ) => void; }) => { const [parsedMessage, setParsedMessage] = useState(message.content); const [speechMessage, setSpeechMessage] = useState(message.content); + const [loadingSuggestions, setLoadingSuggestions] = useState(false); + const [autoSuggestions, setAutoSuggestions] = useState( + localStorage.getItem('autoSuggestions') + ); + + const handleLoadSuggestions = async () => { + if (loadingSuggestions || (message?.suggestions && message.suggestions.length > 0)) return; + + setLoadingSuggestions(true); + try { + const suggestions = await getSuggestions([...history]); + // We need to update the message.suggestions property through parent component + sendMessage('', { messageId: message.messageId, suggestions }); + } catch (error) { + console.error('Error loading suggestions:', error); + } finally { + setLoadingSuggestions(false); + } + }; useEffect(() => { const citationRegex = /\[([^\]]+)\]/g; @@ -105,6 +134,18 @@ const MessageBox = ({ setParsedMessage(processedMessage); }, [message.content, message.sources, message.role]); + useEffect(() => { + const handleStorageChange = () => { + setAutoSuggestions(localStorage.getItem('autoSuggestions')); + }; + + window.addEventListener('storage', handleStorageChange); + + return () => { + window.removeEventListener('storage', handleStorageChange); + }; + }, []); + const { speechStatus, start, stop } = useSpeech({ text: speechMessage }); const markdownOverrides: MarkdownToJSX.Options = { @@ -149,6 +190,7 @@ const MessageBox = ({ )}
+ {' '}
Answer + {message.metadata?.modelStats && ( + + )}
-
)} - {isLast && - message.suggestions && - message.suggestions.length > 0 && - message.role === 'assistant' && - !loading && ( - <> -
-
-
- -

Related

-
+ {isLast && message.role === 'assistant' && !loading && ( + <> +
+
+
+ +

Related

{' '} + {(!autoSuggestions || autoSuggestions === 'false') && (!message.suggestions || + message.suggestions.length === 0) ? ( +
+ +
+ ) : null} +
+ {message.suggestions && message.suggestions.length > 0 ? (
{message.suggestions.map((suggestion, i) => (
))}
-
- - )} + ) : null} +
+ + )}
diff --git a/src/lib/search/metaSearchAgent.ts b/src/lib/search/metaSearchAgent.ts index 67b7c58..03ad982 100644 --- a/src/lib/search/metaSearchAgent.ts +++ b/src/lib/search/metaSearchAgent.ts @@ -434,13 +434,13 @@ class MetaSearchAgent implements MetaSearchAgentType { private async handleStream( stream: AsyncGenerator, emitter: eventEmitter, + llm: BaseChatModel, ) { for await (const event of stream) { if ( event.event === 'on_chain_end' && event.name === 'FinalSourceRetriever' ) { - ``; emitter.emit( 'data', JSON.stringify({ type: 'sources', data: event.data.output }), @@ -459,6 +459,50 @@ class MetaSearchAgent implements MetaSearchAgentType { event.event === 'on_chain_end' && event.name === 'FinalResponseGenerator' ) { + // Get model name safely with better detection + let modelName = 'Unknown'; + try { + // @ts-ignore - Different LLM implementations have different properties + if (llm.modelName) { + // @ts-ignore + modelName = llm.modelName; + // @ts-ignore + } else if (llm._llm && llm._llm.modelName) { + // @ts-ignore + modelName = llm._llm.modelName; + // @ts-ignore + } else if (llm.model && llm.model.modelName) { + // @ts-ignore + modelName = llm.model.modelName; + } else if ('model' in llm) { + // @ts-ignore + const model = llm.model; + if (typeof model === 'string') { + modelName = model; + // @ts-ignore + } else if (model && model.modelName) { + // @ts-ignore + modelName = model.modelName; + } + } else if (llm.constructor && llm.constructor.name) { + // Last resort: use the class name + modelName = llm.constructor.name; + } + } catch (e) { + console.error('Failed to get model name:', e); + } + + // Send model info before ending + emitter.emit( + 'stats', + JSON.stringify({ + type: 'modelStats', + data: { + modelName, + }, + }), + ); + emitter.emit('end'); } } @@ -493,7 +537,7 @@ class MetaSearchAgent implements MetaSearchAgentType { }, ); - this.handleStream(stream, emitter); + this.handleStream(stream, emitter, llm); return emitter; }