Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8ca11c85e | |||
| 467893db67 | |||
| 25df881a39 | |||
| 909ae9a233 | |||
| 023a8a202b | |||
| 82b77b0baa | |||
| f65aca0221 | |||
| fd8b183f51 | |||
| db4375ca8a | |||
| accab8e0cd | |||
| 6c39256008 | |||
| e48fda0a2a | |||
| 1f94effb43 | |||
| b14c052dcd | |||
| c067490a78 | |||
| 6a49f664db | |||
| 10733fab7d |
+2
-2
@@ -1,5 +1,5 @@
|
||||
# ---- Base Node ----
|
||||
FROM node:19-alpine AS base
|
||||
FROM node:20-alpine AS base
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -13,7 +13,7 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- Production ----
|
||||
FROM node:19-alpine AS production
|
||||
FROM node:20-alpine AS production
|
||||
WORKDIR /app
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY --from=build /app/.next ./.next
|
||||
|
||||
+107
-4
@@ -1,4 +1,10 @@
|
||||
import { IconClearAll, IconSettings } from '@tabler/icons-react';
|
||||
import {
|
||||
IconClearAll,
|
||||
IconSettings,
|
||||
IconMarkdown,
|
||||
IconPdf,
|
||||
IconScreenshot,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
MutableRefObject,
|
||||
memo,
|
||||
@@ -34,6 +40,11 @@ import { SystemPrompt } from './SystemPrompt';
|
||||
import { TemperatureSlider } from './Temperature';
|
||||
import { MemoizedChatMessage } from './MemoizedChatMessage';
|
||||
|
||||
import {jsPDF} from "jspdf";
|
||||
import html2canvas from "html2canvas";
|
||||
|
||||
import { toPng } from 'html-to-image';
|
||||
|
||||
interface Props {
|
||||
stopConversationRef: MutableRefObject<boolean>;
|
||||
}
|
||||
@@ -139,8 +150,9 @@ export const Chat = memo(({ stopConversationRef }: Props) => {
|
||||
if (!plugin) {
|
||||
if (updatedConversation.messages.length === 1) {
|
||||
const { content } = message;
|
||||
var textContent = content.filter(c => c.type == "text").map(c => c.text).join();
|
||||
const customName =
|
||||
content.length > 30 ? content.substring(0, 30) + '...' : content;
|
||||
textContent.length > 30 ? textContent.substring(0, 30) + '...' : textContent;
|
||||
updatedConversation = {
|
||||
...updatedConversation,
|
||||
name: customName,
|
||||
@@ -166,7 +178,7 @@ export const Chat = memo(({ stopConversationRef }: Props) => {
|
||||
isFirst = false;
|
||||
const updatedMessages: Message[] = [
|
||||
...updatedConversation.messages,
|
||||
{ role: 'assistant', content: chunkValue },
|
||||
{ role: 'assistant', content: [{type: "text", text: chunkValue}] },
|
||||
];
|
||||
updatedConversation = {
|
||||
...updatedConversation,
|
||||
@@ -182,7 +194,7 @@ export const Chat = memo(({ stopConversationRef }: Props) => {
|
||||
if (index === updatedConversation.messages.length - 1) {
|
||||
return {
|
||||
...message,
|
||||
content: text,
|
||||
content: [{type:"text", text}],
|
||||
};
|
||||
}
|
||||
return message;
|
||||
@@ -300,6 +312,79 @@ export const Chat = memo(({ stopConversationRef }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onMarkdown = () => {
|
||||
if (!selectedConversation){
|
||||
return '';
|
||||
}
|
||||
let markdownContent = '';
|
||||
|
||||
selectedConversation.messages.forEach(obj => {
|
||||
markdownContent += `## ${obj.role === "user" ? t('You') : t("AI")}\n\n${obj.content}\n\n`;
|
||||
});
|
||||
|
||||
const date = new Date().toLocaleString("default", { year: "numeric", month: "long", day: "numeric" })
|
||||
const time = new Date().toLocaleTimeString("default", {hour12: true, hour: "numeric", minute: "numeric"})
|
||||
|
||||
markdownContent += `---\n`
|
||||
markdownContent += `${t("Exported on")} ` + date + ` ${t("at")} ` + time + ".";
|
||||
|
||||
const markdownFile = new Blob([markdownContent], { type: 'text/markdown' });
|
||||
const downloadLink = document.createElement('a');
|
||||
|
||||
downloadLink.href = URL.createObjectURL(markdownFile);
|
||||
downloadLink.download = `${selectedConversation?.name || 'conversation'}.md`;
|
||||
downloadLink.click();
|
||||
}
|
||||
|
||||
const onPdf = () => {
|
||||
if (chatContainerRef.current === null) {
|
||||
return;
|
||||
}
|
||||
else {
|
||||
chatContainerRef.current.classList.remove('max-h-full')
|
||||
html2canvas(chatContainerRef.current).then((canvas) => {
|
||||
if (chatContainerRef.current) {
|
||||
chatContainerRef.current.classList.add('max-h-full')
|
||||
}
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
const orientation = canvas.width > canvas.height ? "l" : "p";
|
||||
const pixelRatio = window.devicePixelRatio > 2 ? window.devicePixelRatio : 2
|
||||
const pdf = new jsPDF(
|
||||
orientation,
|
||||
"pt",
|
||||
[canvas.width / pixelRatio, canvas.height / pixelRatio],
|
||||
true,
|
||||
);
|
||||
var pdfWidth = pdf.internal.pageSize.getWidth();
|
||||
var pdfHeight = pdf.internal.pageSize.getHeight();
|
||||
pdf.addImage(imgData, "PNG", 0, 0, pdfWidth, pdfHeight, "", "FAST");
|
||||
const title = `${selectedConversation?.name || 'conversation'}.pdf`
|
||||
pdf.save(title)
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const onScreenshot = () => {
|
||||
if (chatContainerRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
chatContainerRef.current.classList.remove('max-h-full');
|
||||
toPng(chatContainerRef.current, { cacheBust: true })
|
||||
.then((dataUrl) => {
|
||||
const link = document.createElement('a');
|
||||
link.download = `${selectedConversation?.name || 'conversation'}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
if (chatContainerRef.current) {
|
||||
chatContainerRef.current.classList.add('max-h-full');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
const scrollDown = () => {
|
||||
if (autoScrollEnabled) {
|
||||
messagesEndRef.current?.scrollIntoView(true);
|
||||
@@ -454,6 +539,24 @@ export const Chat = memo(({ stopConversationRef }: Props) => {
|
||||
>
|
||||
<IconClearAll size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="ml-2 cursor-pointer hover:opacity-50"
|
||||
onClick={onMarkdown}
|
||||
>
|
||||
<IconMarkdown size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="ml-2 cursor-pointer hover:opacity-50"
|
||||
onClick={onPdf}
|
||||
>
|
||||
<IconPdf size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="ml-2 cursor-pointer hover:opacity-50"
|
||||
onClick={onScreenshot}
|
||||
>
|
||||
<IconScreenshot size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{showSettings && (
|
||||
<div className="flex flex-col space-y-10 md:mx-auto md:max-w-xl md:gap-6 md:py-3 md:pt-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
|
||||
|
||||
+154
-97
@@ -2,6 +2,7 @@ import {
|
||||
IconArrowDown,
|
||||
IconBolt,
|
||||
IconBrandGoogle,
|
||||
IconPhoto,
|
||||
IconPlayerStop,
|
||||
IconRepeat,
|
||||
IconSend,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Message } from '@/types/chat';
|
||||
import { Content, Message } from '@/types/chat';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { Prompt } from '@/types/prompt';
|
||||
|
||||
@@ -62,6 +63,40 @@ export const ChatInput = ({
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [showPluginSelect, setShowPluginSelect] = useState(false);
|
||||
const [plugin, setPlugin] = useState<Plugin | null>(null);
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleImageInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64Image = reader.result;
|
||||
setImages((prevImages) => [...prevImages, base64Image as string]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const ImagePreview = ({ images }: { images: string[] }) => {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{images.map((image, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={image}
|
||||
alt={`uploaded-${index}`}
|
||||
className="w-20 h-20 object-cover rounded-md border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleImageInputClick = () => {
|
||||
imageInputRef.current?.click();
|
||||
};
|
||||
|
||||
const promptListRef = useRef<HTMLUListElement | null>(null);
|
||||
|
||||
@@ -97,7 +132,14 @@ export const ChatInput = ({
|
||||
return;
|
||||
}
|
||||
|
||||
onSend({ role: 'user', content }, plugin);
|
||||
var messageContent:Content[] = [{"type": "text", "text": content}];
|
||||
if(images && images.length >0){
|
||||
var imageMessages = images.map(image => { return {type: "image_url", image_url:{"url": image}}});
|
||||
messageContent = [...messageContent, ...imageMessages]
|
||||
}
|
||||
|
||||
onSend({ role: 'user', content:messageContent }, plugin);
|
||||
setImages([]);
|
||||
setContent('');
|
||||
setPlugin(null);
|
||||
|
||||
@@ -233,9 +275,8 @@ export const ChatInput = ({
|
||||
if (textareaRef && textareaRef.current) {
|
||||
textareaRef.current.style.height = 'inherit';
|
||||
textareaRef.current.style.height = `${textareaRef.current?.scrollHeight}px`;
|
||||
textareaRef.current.style.overflow = `${
|
||||
textareaRef?.current?.scrollHeight > 400 ? 'auto' : 'hidden'
|
||||
}`;
|
||||
textareaRef.current.style.overflow = `${textareaRef?.current?.scrollHeight > 400 ? 'auto' : 'hidden'
|
||||
}`;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
@@ -280,103 +321,119 @@ export const ChatInput = ({
|
||||
)}
|
||||
|
||||
<div className="relative mx-2 flex w-full flex-grow flex-col rounded-md border border-black/10 bg-white shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-gray-900/50 dark:bg-[#40414F] dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] sm:mx-4">
|
||||
<button
|
||||
className="absolute left-2 top-2 rounded-sm p-1 text-neutral-800 opacity-60 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-opacity-50 dark:text-neutral-100 dark:hover:text-neutral-200"
|
||||
onClick={() => setShowPluginSelect(!showPluginSelect)}
|
||||
onKeyDown={(e) => {}}
|
||||
>
|
||||
{plugin ? <IconBrandGoogle size={20} /> : <IconBolt size={20} />}
|
||||
</button>
|
||||
<ImagePreview images={images} />
|
||||
|
||||
{showPluginSelect && (
|
||||
<div className="absolute left-0 bottom-14 rounded bg-white dark:bg-[#343541]">
|
||||
<PluginSelect
|
||||
plugin={plugin}
|
||||
onKeyDown={(e: any) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
<div className="relative mx-0 flex w-full flex-grow flex-col rounded-md border border-black/10 bg-white shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-gray-900/50 dark:bg-[#40414F] dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] sm:mx-0">
|
||||
<button
|
||||
className="absolute left-2 top-2 rounded-sm p-1 text-neutral-800 opacity-60 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-opacity-50 dark:text-neutral-100 dark:hover:text-neutral-200"
|
||||
onClick={() => setShowPluginSelect(!showPluginSelect)}
|
||||
onKeyDown={(e) => { }}
|
||||
>
|
||||
{plugin ? <IconBrandGoogle size={20} /> : <IconBolt size={20} />}
|
||||
</button>
|
||||
|
||||
{showPluginSelect && (
|
||||
<div className="absolute left-0 bottom-14 rounded bg-white dark:bg-[#343541]">
|
||||
<PluginSelect
|
||||
plugin={plugin}
|
||||
onKeyDown={(e: any) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowPluginSelect(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
onPluginChange={(plugin: Plugin) => {
|
||||
setPlugin(plugin);
|
||||
setShowPluginSelect(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
onPluginChange={(plugin: Plugin) => {
|
||||
setPlugin(plugin);
|
||||
setShowPluginSelect(false);
|
||||
|
||||
if (textareaRef && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="m-0 w-full resize-none border-0 bg-transparent p-0 py-2 pr-8 pl-10 text-black dark:bg-transparent dark:text-white md:py-3 md:pl-10"
|
||||
style={{
|
||||
resize: 'none',
|
||||
bottom: `${textareaRef?.current?.scrollHeight}px`,
|
||||
maxHeight: '400px',
|
||||
overflow: `${
|
||||
textareaRef.current && textareaRef.current.scrollHeight > 400
|
||||
? 'auto'
|
||||
: 'hidden'
|
||||
}`,
|
||||
}}
|
||||
placeholder={
|
||||
t('Type a message or type "/" to select a prompt...') || ''
|
||||
}
|
||||
value={content}
|
||||
rows={1}
|
||||
onCompositionStart={() => setIsTyping(true)}
|
||||
onCompositionEnd={() => setIsTyping(false)}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="absolute right-2 top-2 rounded-sm p-1 text-neutral-800 opacity-60 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-opacity-50 dark:text-neutral-100 dark:hover:text-neutral-200"
|
||||
onClick={handleSend}
|
||||
>
|
||||
{messageIsStreaming ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-t-2 border-neutral-800 opacity-60 dark:border-neutral-100"></div>
|
||||
) : (
|
||||
<IconSend size={18} />
|
||||
if (textareaRef && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showScrollDownButton && (
|
||||
<div className="absolute bottom-12 right-0 lg:bottom-0 lg:-right-10">
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-neutral-300 text-gray-800 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-neutral-200"
|
||||
onClick={onScrollDownClick}
|
||||
>
|
||||
<IconArrowDown size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPromptList && filteredPrompts.length > 0 && (
|
||||
<div className="absolute bottom-12 w-full">
|
||||
<PromptList
|
||||
activePromptIndex={activePromptIndex}
|
||||
prompts={filteredPrompts}
|
||||
onSelect={handleInitModal}
|
||||
onMouseOver={setActivePromptIndex}
|
||||
promptListRef={promptListRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalVisible && (
|
||||
<VariableModal
|
||||
prompt={filteredPrompts[activePromptIndex]}
|
||||
variables={variables}
|
||||
onSubmit={handleSubmit}
|
||||
onClose={() => setIsModalVisible(false)}
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImageInputChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="absolute left-10 top-2 rounded-sm p-1 text-neutral-800 opacity-60 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-opacity-50 dark:text-neutral-100 dark:hover:text-neutral-200"
|
||||
onClick={handleImageInputClick}
|
||||
>
|
||||
<IconPhoto size={20} />
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="m-0 w-full resize-none border-0 bg-transparent p-0 py-2 pr-8 pl-20 text-black dark:bg-transparent dark:text-white md:py-3 md:pl-20"
|
||||
style={{
|
||||
resize: 'none',
|
||||
bottom: `${textareaRef?.current?.scrollHeight}px`,
|
||||
maxHeight: '400px',
|
||||
overflow: `${textareaRef.current && textareaRef.current.scrollHeight > 400
|
||||
? 'auto'
|
||||
: 'hidden'
|
||||
}`,
|
||||
}}
|
||||
placeholder={
|
||||
t('Type a message or type "/" to select a prompt...') || ''
|
||||
}
|
||||
value={content}
|
||||
rows={1}
|
||||
onCompositionStart={() => setIsTyping(true)}
|
||||
onCompositionEnd={() => setIsTyping(false)}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button
|
||||
className="absolute right-2 top-2 rounded-sm p-1 text-neutral-800 opacity-60 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-opacity-50 dark:text-neutral-100 dark:hover:text-neutral-200"
|
||||
onClick={handleSend}
|
||||
>
|
||||
{messageIsStreaming ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-t-2 border-neutral-800 opacity-60 dark:border-neutral-100"></div>
|
||||
) : (
|
||||
<IconSend size={18} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showScrollDownButton && (
|
||||
<div className="absolute bottom-12 right-0 lg:bottom-0 lg:-right-10">
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-neutral-300 text-gray-800 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-neutral-200"
|
||||
onClick={onScrollDownClick}
|
||||
>
|
||||
<IconArrowDown size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPromptList && filteredPrompts.length > 0 && (
|
||||
<div className="absolute bottom-12 w-full">
|
||||
<PromptList
|
||||
activePromptIndex={activePromptIndex}
|
||||
prompts={filteredPrompts}
|
||||
onSelect={handleInitModal}
|
||||
onMouseOver={setActivePromptIndex}
|
||||
promptListRef={promptListRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalVisible && (
|
||||
<VariableModal
|
||||
prompt={filteredPrompts[activePromptIndex]}
|
||||
variables={variables}
|
||||
onSubmit={handleSubmit}
|
||||
onClose={() => setIsModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 pt-2 pb-3 text-center text-[12px] text-black/50 dark:text-white/50 md:px-4 md:pt-3 md:pb-6">
|
||||
|
||||
@@ -40,6 +40,7 @@ export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit }) =
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [isTyping, setIsTyping] = useState<boolean>(false);
|
||||
const [messageContent, setMessageContent] = useState(message.content);
|
||||
const [messageTextContent, setMessageTextContent] = useState(message.content[0].text ?? "");
|
||||
const [messagedCopied, setMessageCopied] = useState(false);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -49,7 +50,9 @@ export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit }) =
|
||||
};
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setMessageContent(event.target.value);
|
||||
// messageContent[0].text = event.target.value;
|
||||
// setMessageContent(messageContent);
|
||||
setMessageTextContent(event.target.value);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'inherit';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
@@ -57,8 +60,9 @@ export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit }) =
|
||||
};
|
||||
|
||||
const handleEditMessage = () => {
|
||||
if (message.content != messageContent) {
|
||||
if (message.content[0].text != messageTextContent) {
|
||||
if (selectedConversation && onEdit) {
|
||||
messageContent[0].text = messageTextContent;
|
||||
onEdit({ ...message, content: messageContent });
|
||||
}
|
||||
}
|
||||
@@ -104,7 +108,9 @@ export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit }) =
|
||||
const copyOnClick = () => {
|
||||
if (!navigator.clipboard) return;
|
||||
|
||||
navigator.clipboard.writeText(message.content).then(() => {
|
||||
var content = message.content;
|
||||
var textContent = content.filter(c => c.type == "text").map(c => c.text).join();
|
||||
navigator.clipboard.writeText(textContent).then(() => {
|
||||
setMessageCopied(true);
|
||||
setTimeout(() => {
|
||||
setMessageCopied(false);
|
||||
@@ -113,7 +119,8 @@ export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit }) =
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setMessageContent(message.content);
|
||||
// setMessageContent(message.content);
|
||||
setMessageTextContent(message.content[0].text ?? "");
|
||||
}, [message.content]);
|
||||
|
||||
|
||||
@@ -150,7 +157,7 @@ export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit }) =
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="w-full resize-none whitespace-pre-wrap border-none dark:bg-[#343541]"
|
||||
value={messageContent}
|
||||
value={messageTextContent}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handlePressEnter}
|
||||
onCompositionStart={() => setIsTyping(true)}
|
||||
@@ -169,14 +176,15 @@ export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit }) =
|
||||
<button
|
||||
className="h-[40px] rounded-md bg-blue-500 px-4 py-1 text-sm font-medium text-white enabled:hover:bg-blue-600 disabled:opacity-50"
|
||||
onClick={handleEditMessage}
|
||||
disabled={messageContent.trim().length <= 0}
|
||||
disabled={messageTextContent.length <= 0}
|
||||
>
|
||||
{t('Save & Submit')}
|
||||
</button>
|
||||
<button
|
||||
className="h-[40px] rounded-md border border-neutral-300 px-4 py-1 text-sm font-medium text-neutral-700 hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
onClick={() => {
|
||||
setMessageContent(message.content);
|
||||
// setMessageContent(message.content);
|
||||
setMessageTextContent(message.content[0].text ?? "");
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
@@ -186,7 +194,14 @@ export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit }) =
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose whitespace-pre-wrap dark:prose-invert flex-1">
|
||||
{message.content}
|
||||
<div>
|
||||
{messageTextContent}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center">
|
||||
{messageContent.filter(c => c.type === "image_url").map((c, index) => (
|
||||
<img key={index} src={c.image_url?.url} alt="Message Content" className="max-w-full h-auto my-2" style={{objectFit: "contain"}} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -261,7 +276,7 @@ export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit }) =
|
||||
},
|
||||
}}
|
||||
>
|
||||
{`${message.content}${
|
||||
{`${messageTextContent}${
|
||||
messageIsStreaming && messageIndex == (selectedConversation?.messages.length ?? 0) - 1 ? '`▍`' : ''
|
||||
}`}
|
||||
</MemoizedReactMarkdown>
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
"@dqbd/tiktoken": "^1.0.2",
|
||||
"@tabler/icons-react": "^2.9.0",
|
||||
"eventsource-parser": "^0.1.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"html-to-image": "^1.11.11",
|
||||
"i18next": "^22.4.13",
|
||||
"jspdf": "^2.5.1",
|
||||
"next": "13.2.4",
|
||||
"next-i18next": "^13.2.2",
|
||||
"openai": "^3.2.1",
|
||||
|
||||
+9
-2
@@ -41,7 +41,7 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
const tokens = encoding.encode(message.content);
|
||||
const tokens = encoding.encode(message.content[0].text ?? "");
|
||||
|
||||
if (tokenCount + tokens.length + 1000 > model.tokenLimit) {
|
||||
break;
|
||||
@@ -54,7 +54,14 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
|
||||
const stream = await OpenAIStream(model, promptToSend, temperatureToUse, key, messagesToSend);
|
||||
|
||||
return new Response(stream);
|
||||
var resp = new Response(stream);
|
||||
return resp;
|
||||
|
||||
// let proxy services like nginx or argo tunnel know about pass the chunk immediately
|
||||
// similar to nginx option `proxy_buffering off;`
|
||||
resp.headers.set('Content-Type', 'text/event-stream');
|
||||
|
||||
return resp;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof OpenAIError) {
|
||||
|
||||
+11
-1
@@ -2,7 +2,17 @@ import { OpenAIModel } from './openai';
|
||||
|
||||
export interface Message {
|
||||
role: Role;
|
||||
content: string;
|
||||
content: Content[];
|
||||
}
|
||||
|
||||
export interface Content{
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: ImageUrl;
|
||||
}
|
||||
|
||||
export interface ImageUrl{
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type Role = 'assistant' | 'user';
|
||||
|
||||
@@ -10,8 +10,11 @@ export interface OpenAIModel {
|
||||
export enum OpenAIModelID {
|
||||
GPT_3_5 = 'gpt-3.5-turbo',
|
||||
GPT_3_5_AZ = 'gpt-35-turbo',
|
||||
GPT_3_5_16K = 'gpt-3.5-turbo-16k',
|
||||
GPT_4 = 'gpt-4',
|
||||
GPT_4_32K = 'gpt-4-32k',
|
||||
GPT_4_TURBO = 'gpt-4-1106-preview',
|
||||
GPT_4_TURBO_VISION = 'gpt-4-vision-preview'
|
||||
}
|
||||
|
||||
// in case the `DEFAULT_MODEL` environment variable is not set or set to an unsupported model
|
||||
@@ -30,6 +33,12 @@ export const OpenAIModels: Record<OpenAIModelID, OpenAIModel> = {
|
||||
maxLength: 12000,
|
||||
tokenLimit: 4000,
|
||||
},
|
||||
[OpenAIModelID.GPT_3_5_16K]: {
|
||||
id: OpenAIModelID.GPT_3_5_16K,
|
||||
name: 'GPT-3.5-16K',
|
||||
maxLength: 96000,
|
||||
tokenLimit: 32000,
|
||||
},
|
||||
[OpenAIModelID.GPT_4]: {
|
||||
id: OpenAIModelID.GPT_4,
|
||||
name: 'GPT-4',
|
||||
@@ -42,4 +51,16 @@ export const OpenAIModels: Record<OpenAIModelID, OpenAIModel> = {
|
||||
maxLength: 96000,
|
||||
tokenLimit: 32000,
|
||||
},
|
||||
[OpenAIModelID.GPT_4_TURBO]: {
|
||||
id: OpenAIModelID.GPT_4_TURBO,
|
||||
name: 'GPT-4-TURBO',
|
||||
maxLength: 384000,
|
||||
tokenLimit: 128000,
|
||||
},
|
||||
[OpenAIModelID.GPT_4_TURBO_VISION]: {
|
||||
id: OpenAIModelID.GPT_4_TURBO_VISION,
|
||||
name: 'GPT-4-TURBO-VISION',
|
||||
maxLength: 384000,
|
||||
tokenLimit: 128000,
|
||||
},
|
||||
};
|
||||
|
||||
+15
-14
@@ -53,11 +53,11 @@ export const OpenAIStream = async (
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemPrompt,
|
||||
content: [{type:"text", text: systemPrompt}],
|
||||
},
|
||||
...messages,
|
||||
],
|
||||
max_tokens: 1000,
|
||||
max_tokens: 4000,
|
||||
temperature: temperature,
|
||||
stream: true,
|
||||
}),
|
||||
@@ -89,22 +89,23 @@ export const OpenAIStream = async (
|
||||
const onParse = (event: ParsedEvent | ReconnectInterval) => {
|
||||
if (event.type === 'event') {
|
||||
const data = event.data;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.choices[0].finish_reason != null) {
|
||||
controller.close();
|
||||
return;
|
||||
if (data !== '[DONE]') {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.choices[0].finish_reason != null || json.choices[0].finish_details != null) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const text = json.choices[0].delta.content;
|
||||
const queue = encoder.encode(text);
|
||||
controller.enqueue(queue);
|
||||
} catch (e) {
|
||||
controller.error(e);
|
||||
}
|
||||
const text = json.choices[0].delta.content;
|
||||
const queue = encoder.encode(text);
|
||||
controller.enqueue(queue);
|
||||
} catch (e) {
|
||||
controller.error(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const parser = createParser(onParse);
|
||||
|
||||
for await (const chunk of res.body as any) {
|
||||
|
||||
Reference in New Issue
Block a user