mirror of
https://github.com/ItzCrazyKns/Perplexica.git
synced 2025-09-18 07:11:34 +00:00
feat(app):
- Adds true chat mode. Moves writing mode to local research mode. - Adds model stats that shows model name and response time for messages. - Adds settings toggle to allow turning off automatic suggestions
This commit is contained in:
@@ -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;
|
||||
|
@@ -13,6 +13,15 @@ import { Settings } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import NextError from 'next/error';
|
||||
|
||||
export type ModelStats = {
|
||||
modelName: string;
|
||||
responseTime?: number;
|
||||
};
|
||||
|
||||
export type MessageMetadata = {
|
||||
modelStats?: ModelStats;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
@@ -21,6 +30,7 @@ export type Message = {
|
||||
role: 'user' | 'assistant';
|
||||
suggestions?: string[];
|
||||
sources?: Document[];
|
||||
metadata?: MessageMetadata;
|
||||
};
|
||||
|
||||
export interface File {
|
||||
@@ -207,7 +217,6 @@ const loadMessages = async (
|
||||
const messages = data.messages.map((msg: any) => {
|
||||
return {
|
||||
...msg,
|
||||
...JSON.parse(msg.metadata),
|
||||
};
|
||||
}) as Message[];
|
||||
|
||||
@@ -339,9 +348,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 +394,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 +445,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 +477,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 +517,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 +600,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,
|
||||
});
|
||||
};
|
||||
|
80
src/components/MessageActions/ModelInfo.tsx
Normal file
80
src/components/MessageActions/ModelInfo.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'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<ModelInfoButtonProps> = ({ modelStats }) => {
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(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 (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="p-1 ml-1 text-black/50 dark:text-white/50 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
|
||||
onClick={() => setShowPopover(!showPopover)}
|
||||
aria-label="Show model information"
|
||||
>
|
||||
<Info size={14} />
|
||||
</button>
|
||||
{showPopover && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute z-10 left-6 top-0 w-64 rounded-md shadow-lg bg-white dark:bg-dark-secondary border border-light-200 dark:border-dark-200"
|
||||
>
|
||||
<div className="py-2 px-3">
|
||||
<h4 className="text-sm font-medium mb-2 text-black dark:text-white">
|
||||
Model Information
|
||||
</h4>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-black/70 dark:text-white/70">Model:</span>
|
||||
<span className="text-black dark:text-white font-medium">
|
||||
{modelName}
|
||||
</span>
|
||||
</div>
|
||||
{modelStats?.responseTime && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-black/70 dark:text-white/70">Response time:</span>
|
||||
<span className="text-black dark:text-white font-medium">
|
||||
{(modelStats.responseTime / 1000).toFixed(2)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelInfoButton;
|
@@ -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<HTMLDivElement | null>;
|
||||
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 = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col space-y-2">
|
||||
{' '}
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<Disc3
|
||||
className={cn(
|
||||
@@ -160,8 +202,10 @@ const MessageBox = ({
|
||||
<h3 className="text-black dark:text-white font-medium text-xl">
|
||||
Answer
|
||||
</h3>
|
||||
{message.metadata?.modelStats && (
|
||||
<ModelInfoButton modelStats={message.metadata.modelStats} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Markdown
|
||||
className={cn(
|
||||
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
|
||||
@@ -200,18 +244,36 @@ const MessageBox = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLast &&
|
||||
message.suggestions &&
|
||||
message.suggestions.length > 0 &&
|
||||
message.role === 'assistant' &&
|
||||
!loading && (
|
||||
<>
|
||||
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
|
||||
<div className="flex flex-col space-y-3 text-black dark:text-white">
|
||||
<div className="flex flex-row items-center space-x-2 mt-4">
|
||||
<Layers3 />
|
||||
<h3 className="text-xl font-medium">Related</h3>
|
||||
</div>
|
||||
{isLast && message.role === 'assistant' && !loading && (
|
||||
<>
|
||||
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
|
||||
<div className="flex flex-col space-y-3 text-black dark:text-white">
|
||||
<div className="flex flex-row items-center space-x-2 mt-4">
|
||||
<Layers3 />
|
||||
<h3 className="text-xl font-medium">Related</h3>{' '}
|
||||
{(!autoSuggestions || autoSuggestions === 'false') && (!message.suggestions ||
|
||||
message.suggestions.length === 0) ? (
|
||||
<div className="bg-light-secondary dark:bg-dark-secondary">
|
||||
<button
|
||||
onClick={handleLoadSuggestions}
|
||||
disabled={loadingSuggestions}
|
||||
className="px-4 py-2 flex flex-row items-center justify-center space-x-2 rounded-lg bg-light-secondary dark:bg-dark-secondary hover:bg-light-200 dark:hover:bg-dark-200 transition duration-200 text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
{loadingSuggestions ? (
|
||||
<div className="w-4 h-4 border-2 border-t-transparent border-gray-400 dark:border-gray-500 rounded-full animate-spin" />
|
||||
) : (
|
||||
<Sparkles size={16} />
|
||||
)}
|
||||
<span>
|
||||
{loadingSuggestions
|
||||
? 'Loading suggestions...'
|
||||
: 'Load suggestions'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{message.suggestions && message.suggestions.length > 0 ? (
|
||||
<div className="flex flex-col space-y-3">
|
||||
{message.suggestions.map((suggestion, i) => (
|
||||
<div
|
||||
@@ -236,9 +298,10 @@ const MessageBox = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:sticky lg:top-20 flex flex-col items-center space-y-3 w-full lg:w-3/12 z-30 h-full pb-4">
|
||||
|
@@ -2,6 +2,7 @@ import {
|
||||
BadgePercent,
|
||||
ChevronDown,
|
||||
Globe,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
ScanEye,
|
||||
SwatchBook,
|
||||
@@ -30,11 +31,23 @@ const focusModes = [
|
||||
icon: <SwatchBook size={20} />,
|
||||
},
|
||||
{
|
||||
key: 'writingAssistant',
|
||||
title: 'Writing',
|
||||
description: 'Chat without searching the web',
|
||||
key: 'chat',
|
||||
title: 'Chat',
|
||||
description: 'Have a creative conversation',
|
||||
icon: <MessageCircle size={16} />,
|
||||
},
|
||||
{
|
||||
key: 'localResearch',
|
||||
title: 'Local Research',
|
||||
description: 'Research and interact with local files with citations',
|
||||
icon: <Pencil size={16} />,
|
||||
},
|
||||
{
|
||||
key: 'redditSearch',
|
||||
title: 'Reddit',
|
||||
description: 'Search for discussions and opinions',
|
||||
icon: <SiReddit className="h-5 w-auto mr-0.5" />,
|
||||
},
|
||||
{
|
||||
key: 'wolframAlphaSearch',
|
||||
title: 'Wolfram Alpha',
|
||||
@@ -47,12 +60,6 @@ const focusModes = [
|
||||
description: 'Search and watch videos',
|
||||
icon: <SiYoutube className="h-5 w-auto mr-0.5" />,
|
||||
},
|
||||
{
|
||||
key: 'redditSearch',
|
||||
title: 'Reddit',
|
||||
description: 'Search for discussions and opinions',
|
||||
icon: <SiReddit className="h-5 w-auto mr-0.5" />,
|
||||
},
|
||||
];
|
||||
|
||||
const Focus = ({
|
||||
|
Reference in New Issue
Block a user