mirror of
https://github.com/ItzCrazyKns/Perplexica.git
synced 2025-06-02 16:08:30 +00:00
- Added LM Studio provider with OpenAI-compatible API support - Dynamic model discovery via /v1/models endpoint - Support for both chat and embeddings models - Docker-compatible networking configuration - Thinking Model Panel: Added collapsible UI panel for model's chain of thought -Parses responses with tags to separate reasoning -Maintains backward compatibility with regular responses -Styled consistently with app theme for light/dark modes -Preserves all existing message functionality (sources, markdown, etc.) These improvements enhance the app's compatibility with local LLMs and provide better visibility into model reasoning processes while maintaining existing functionality.
281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
'use client';
|
|
|
|
/* eslint-disable @next/next/no-img-element */
|
|
import React, { MutableRefObject, useEffect, useState } from 'react';
|
|
import { Message } from './ChatWindow';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
BookCopy,
|
|
Disc3,
|
|
Volume2,
|
|
StopCircle,
|
|
Layers3,
|
|
Plus,
|
|
Brain,
|
|
ChevronDown,
|
|
} from 'lucide-react';
|
|
import Markdown from 'markdown-to-jsx';
|
|
import Copy from './MessageActions/Copy';
|
|
import Rewrite from './MessageActions/Rewrite';
|
|
import MessageSources from './MessageSources';
|
|
import SearchImages from './SearchImages';
|
|
import SearchVideos from './SearchVideos';
|
|
import { useSpeech } from 'react-text-to-speech';
|
|
|
|
const MessageBox = ({
|
|
message,
|
|
messageIndex,
|
|
history,
|
|
loading,
|
|
dividerRef,
|
|
isLast,
|
|
rewrite,
|
|
sendMessage,
|
|
}: {
|
|
message: Message;
|
|
messageIndex: number;
|
|
history: Message[];
|
|
loading: boolean;
|
|
dividerRef?: MutableRefObject<HTMLDivElement | null>;
|
|
isLast: boolean;
|
|
rewrite: (messageId: string) => void;
|
|
sendMessage: (message: string) => void;
|
|
}) => {
|
|
const [parsedMessage, setParsedMessage] = useState(message.content);
|
|
const [speechMessage, setSpeechMessage] = useState(message.content);
|
|
const [thinking, setThinking] = useState<string>('');
|
|
const [answer, setAnswer] = useState<string>('');
|
|
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const regex = /\[(\d+)\]/g;
|
|
const thinkRegex = /<think>(.*?)(?:<\/think>|$)(.*)/s;
|
|
|
|
// Check for thinking content, including partial tags
|
|
const match = message.content.match(thinkRegex);
|
|
if (match) {
|
|
const [_, thinkingContent, answerContent] = match;
|
|
|
|
// Set thinking content even if </think> hasn't appeared yet
|
|
if (thinkingContent) {
|
|
setThinking(thinkingContent.trim());
|
|
setIsThinkingExpanded(true); // Auto-expand when thinking starts
|
|
}
|
|
|
|
// Only set answer content if we have it (after </think>)
|
|
if (answerContent) {
|
|
setAnswer(answerContent.trim());
|
|
|
|
// Process the answer part for sources if needed
|
|
if (message.role === 'assistant' && message?.sources && message.sources.length > 0) {
|
|
setParsedMessage(
|
|
answerContent.trim().replace(
|
|
regex,
|
|
(_, number) =>
|
|
`<a href="${message.sources?.[number - 1]?.metadata?.url}" target="_blank" className="bg-light-secondary dark:bg-dark-secondary px-1 rounded ml-1 no-underline text-xs text-black/70 dark:text-white/70 relative">${number}</a>`,
|
|
),
|
|
);
|
|
} else {
|
|
setParsedMessage(answerContent.trim());
|
|
}
|
|
setSpeechMessage(answerContent.trim().replace(regex, ''));
|
|
}
|
|
} else {
|
|
// No thinking content - process as before
|
|
if (message.role === 'assistant' && message?.sources && message.sources.length > 0) {
|
|
setParsedMessage(
|
|
message.content.replace(
|
|
regex,
|
|
(_, number) =>
|
|
`<a href="${message.sources?.[number - 1]?.metadata?.url}" target="_blank" className="bg-light-secondary dark:bg-dark-secondary px-1 rounded ml-1 no-underline text-xs text-black/70 dark:text-white/70 relative">${number}</a>`,
|
|
),
|
|
);
|
|
} else {
|
|
setParsedMessage(message.content);
|
|
}
|
|
setSpeechMessage(message.content.replace(regex, ''));
|
|
}
|
|
}, [message.content, message.sources, message.role]);
|
|
|
|
const { speechStatus, start, stop } = useSpeech({ text: speechMessage });
|
|
|
|
return (
|
|
<div>
|
|
{message.role === 'user' && (
|
|
<div className={cn('w-full', messageIndex === 0 ? 'pt-16' : 'pt-8')}>
|
|
<h2 className="text-black dark:text-white font-medium text-3xl lg:w-9/12">
|
|
{message.content}
|
|
</h2>
|
|
</div>
|
|
)}
|
|
|
|
{message.role === 'assistant' && (
|
|
<div className="flex flex-col space-y-9 lg:space-y-0 lg:flex-row lg:justify-between lg:space-x-9">
|
|
<div
|
|
ref={dividerRef}
|
|
className="flex flex-col space-y-6 w-full lg:w-9/12"
|
|
>
|
|
{message.sources && message.sources.length > 0 && (
|
|
<div className="flex flex-col space-y-2">
|
|
<div className="flex flex-row items-center space-x-2">
|
|
<BookCopy className="text-black dark:text-white" size={20} />
|
|
<h3 className="text-black dark:text-white font-medium text-xl">
|
|
Sources
|
|
</h3>
|
|
</div>
|
|
<MessageSources sources={message.sources} />
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col space-y-4">
|
|
{thinking && (
|
|
<div className="flex flex-col space-y-2 mb-4">
|
|
<button
|
|
onClick={() => setIsThinkingExpanded(!isThinkingExpanded)}
|
|
className="flex flex-row items-center space-x-2 group text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white transition duration-200"
|
|
>
|
|
<Brain size={20} />
|
|
<h3 className="font-medium text-xl">Reasoning</h3>
|
|
<ChevronDown
|
|
size={16}
|
|
className={cn(
|
|
"transition-transform duration-200",
|
|
isThinkingExpanded ? "rotate-180" : ""
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
{isThinkingExpanded && (
|
|
<div className="rounded-lg bg-light-secondary/50 dark:bg-dark-secondary/50 p-4">
|
|
{thinking.split('\n\n').map((paragraph, index) => {
|
|
if (!paragraph.trim()) return null;
|
|
|
|
const content = paragraph.replace(/^[•\-\d.]\s*/, '');
|
|
|
|
return (
|
|
<div key={index} className="mb-2 last:mb-0">
|
|
<details className="group [&_summary::-webkit-details-marker]:hidden">
|
|
<summary className="flex items-center cursor-pointer list-none text-sm text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white">
|
|
<span className="arrow mr-2 inline-block transition-transform duration-200 group-open:rotate-90 group-open:self-start group-open:mt-1">▸</span>
|
|
<p className="relative whitespace-normal line-clamp-1 group-open:line-clamp-none after:content-['...'] after:inline group-open:after:hidden transition-all duration-200 text-ellipsis overflow-hidden group-open:overflow-visible">
|
|
{content}
|
|
</p>
|
|
</summary>
|
|
</details>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col space-y-2">
|
|
<div className="flex flex-row items-center space-x-2">
|
|
<Disc3
|
|
className={cn(
|
|
'text-black dark:text-white',
|
|
isLast && loading ? 'animate-spin' : 'animate-none',
|
|
)}
|
|
size={20}
|
|
/>
|
|
<h3 className="text-black dark:text-white font-medium text-xl">
|
|
Answer
|
|
</h3>
|
|
</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]',
|
|
'max-w-none break-words text-black dark:text-white',
|
|
)}
|
|
>
|
|
{parsedMessage}
|
|
</Markdown>
|
|
</div>
|
|
{loading && isLast ? null : (
|
|
<div className="flex flex-row items-center justify-between w-full text-black dark:text-white py-4 -mx-2">
|
|
<div className="flex flex-row items-center space-x-1">
|
|
{/* <button className="p-2 text-black/70 dark:text-white/70 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black text-black dark:hover:text-white">
|
|
<Share size={18} />
|
|
</button> */}
|
|
<Rewrite rewrite={rewrite} messageId={message.messageId} />
|
|
</div>
|
|
<div className="flex flex-row items-center space-x-1">
|
|
<Copy initialMessage={message.content} message={message} />
|
|
<button
|
|
onClick={() => {
|
|
if (speechStatus === 'started') {
|
|
stop();
|
|
} else {
|
|
start();
|
|
}
|
|
}}
|
|
className="p-2 text-black/70 dark:text-white/70 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
|
|
>
|
|
{speechStatus === 'started' ? (
|
|
<StopCircle size={18} />
|
|
) : (
|
|
<Volume2 size={18} />
|
|
)}
|
|
</button>
|
|
</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>
|
|
<div className="flex flex-col space-y-3">
|
|
{message.suggestions.map((suggestion, i) => (
|
|
<div
|
|
className="flex flex-col space-y-3 text-sm"
|
|
key={i}
|
|
>
|
|
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
|
|
<div
|
|
onClick={() => {
|
|
sendMessage(suggestion);
|
|
}}
|
|
className="cursor-pointer flex flex-row justify-between font-medium space-x-2 items-center"
|
|
>
|
|
<p className="transition duration-200 hover:text-[#24A0ED]">
|
|
{suggestion}
|
|
</p>
|
|
<Plus
|
|
size={20}
|
|
className="text-[#24A0ED] flex-shrink-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</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">
|
|
<SearchImages
|
|
query={history[messageIndex - 1].content}
|
|
chatHistory={history.slice(0, messageIndex - 1)}
|
|
/>
|
|
<SearchVideos
|
|
chatHistory={history.slice(0, messageIndex - 1)}
|
|
query={history[messageIndex - 1].content}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MessageBox;
|