"@vscode/markdown-it-katex": "^1.1.1",
"autoprefixer": "^10.4.20",
"daisyui": "^4.12.14",
+ "dexie": "^4.0.11",
"highlight.js": "^11.10.0",
"katex": "^0.16.15",
"postcss": "^8.4.49",
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/dexie": {
+ "version": "4.0.11",
+ "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.11.tgz",
+ "integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A==",
+ "license": "Apache-2.0"
+ },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"@vscode/markdown-it-katex": "^1.1.1",
"autoprefixer": "^10.4.20",
"daisyui": "^4.12.14",
+ "dexie": "^4.0.11",
"highlight.js": "^11.10.0",
"katex": "^0.16.15",
"postcss": "^8.4.49",
import { Message, PendingMessage } from '../utils/types';
import { classNames } from '../utils/misc';
import MarkdownDisplay, { CopyButton } from './MarkdownDisplay';
+import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
interface SplitMessage {
content: PendingMessage['content'];
export default function ChatMessage({
msg,
+ siblingLeafNodeIds,
+ siblingCurrIdx,
id,
- scrollToBottom,
+ onRegenerateMessage,
+ onEditMessage,
+ onChangeSibling,
isPending,
}: {
msg: Message | PendingMessage;
+ siblingLeafNodeIds: Message['id'][];
+ siblingCurrIdx: number;
id?: string;
- scrollToBottom: (requiresNearBottom: boolean) => void;
+ onRegenerateMessage(msg: Message): void;
+ onEditMessage(msg: Message, content: string): void;
+ onChangeSibling(sibling: Message['id']): void;
isPending?: boolean;
}) {
- const { viewingConversation, replaceMessageAndGenerate, config } =
- useAppContext();
+ const { viewingChat, config } = useAppContext();
const [editingContent, setEditingContent] = useState<string | null>(null);
const timings = useMemo(
() =>
: null,
[msg.timings]
);
+ const nextSibling = siblingLeafNodeIds[siblingCurrIdx + 1];
+ const prevSibling = siblingLeafNodeIds[siblingCurrIdx - 1];
// for reasoning model, we split the message into content and thought
// TODO: implement this as remark/rehype plugin in the future
return { content: actualContent, thought, isThinking };
}, [msg]);
- if (!viewingConversation) return null;
-
- const regenerate = async () => {
- replaceMessageAndGenerate(viewingConversation.id, msg.id, undefined, () =>
- scrollToBottom(true)
- );
- };
+ if (!viewingChat) return null;
return (
<div className="group" id={id}>
</button>
<button
className="btn mt-2"
- onClick={() =>
- replaceMessageAndGenerate(
- viewingConversation.id,
- msg.id,
- editingContent
- )
- }
+ onClick={() => {
+ if (msg.content !== null) {
+ setEditingContent(null);
+ onEditMessage(msg as Message, editingContent);
+ }
+ }}
>
Submit
</button>
{msg.content !== null && (
<div
className={classNames({
- 'mx-4 mt-2 mb-2': true,
- 'text-right': msg.role === 'user',
+ 'flex items-center gap-2 mx-4 mt-2 mb-2': true,
+ 'flex-row-reverse': msg.role === 'user',
})}
>
+ {siblingLeafNodeIds && siblingLeafNodeIds.length > 1 && (
+ <div className="flex gap-1 items-center opacity-60 text-sm">
+ <button
+ className={classNames({
+ 'btn btn-sm btn-ghost p-1': true,
+ 'opacity-20': !prevSibling,
+ })}
+ onClick={() => prevSibling && onChangeSibling(prevSibling)}
+ >
+ <ChevronLeftIcon className="h-4 w-4" />
+ </button>
+ <span>
+ {siblingCurrIdx + 1} / {siblingLeafNodeIds.length}
+ </span>
+ <button
+ className={classNames({
+ 'btn btn-sm btn-ghost p-1': true,
+ 'opacity-20': !nextSibling,
+ })}
+ onClick={() => nextSibling && onChangeSibling(nextSibling)}
+ >
+ <ChevronRightIcon className="h-4 w-4" />
+ </button>
+ </div>
+ )}
{/* user message */}
{msg.role === 'user' && (
<button
{!isPending && (
<button
className="badge btn-mini show-on-hover mr-2"
- onClick={regenerate}
+ onClick={() => {
+ if (msg.content !== null) {
+ onRegenerateMessage(msg as Message);
+ }
+ }}
disabled={msg.content === null}
>
π Regenerate
-import { useEffect, useState } from 'react';
-import { useAppContext } from '../utils/app.context';
-import StorageUtils from '../utils/storage';
-import { useNavigate } from 'react-router';
+import { useEffect, useMemo, useState } from 'react';
+import { CallbackGeneratedChunk, useAppContext } from '../utils/app.context';
import ChatMessage from './ChatMessage';
-import { CanvasType, PendingMessage } from '../utils/types';
-import { classNames } from '../utils/misc';
+import { CanvasType, Message, PendingMessage } from '../utils/types';
+import { classNames, throttle } from '../utils/misc';
import CanvasPyInterpreter from './CanvasPyInterpreter';
+import StorageUtils from '../utils/storage';
-export default function ChatScreen() {
- const {
- viewingConversation,
- sendMessage,
- isGenerating,
- stopGenerating,
- pendingMessages,
- canvasData,
- } = useAppContext();
- const [inputMsg, setInputMsg] = useState('');
- const navigate = useNavigate();
+/**
+ * A message display is a message node with additional information for rendering.
+ * For example, siblings of the message node are stored as their last node (aka leaf node).
+ */
+export interface MessageDisplay {
+ msg: Message | PendingMessage;
+ siblingLeafNodeIds: Message['id'][];
+ siblingCurrIdx: number;
+ isPending?: boolean;
+}
- const currConvId = viewingConversation?.id ?? '';
- const pendingMsg: PendingMessage | undefined = pendingMessages[currConvId];
+function getListMessageDisplay(
+ msgs: Readonly<Message[]>,
+ leafNodeId: Message['id']
+): MessageDisplay[] {
+ const currNodes = StorageUtils.filterByLeafNodeId(msgs, leafNodeId, true);
+ const res: MessageDisplay[] = [];
+ const nodeMap = new Map<Message['id'], Message>();
+ for (const msg of msgs) {
+ nodeMap.set(msg.id, msg);
+ }
+ // find leaf node from a message node
+ const findLeafNode = (msgId: Message['id']): Message['id'] => {
+ let currNode: Message | undefined = nodeMap.get(msgId);
+ while (currNode) {
+ if (currNode.children.length === 0) break;
+ currNode = nodeMap.get(currNode.children.at(-1) ?? -1);
+ }
+ return currNode?.id ?? -1;
+ };
+ // traverse the current nodes
+ for (const msg of currNodes) {
+ const parentNode = nodeMap.get(msg.parent ?? -1);
+ if (!parentNode) continue;
+ const siblings = parentNode.children;
+ if (msg.type !== 'root') {
+ res.push({
+ msg,
+ siblingLeafNodeIds: siblings.map(findLeafNode),
+ siblingCurrIdx: siblings.indexOf(msg.id),
+ });
+ }
+ }
+ return res;
+}
- const scrollToBottom = (requiresNearBottom: boolean) => {
+const scrollToBottom = throttle(
+ (requiresNearBottom: boolean, delay: number = 80) => {
const mainScrollElem = document.getElementById('main-scroll');
if (!mainScrollElem) return;
const spaceToBottom =
if (!requiresNearBottom || spaceToBottom < 50) {
setTimeout(
() => mainScrollElem.scrollTo({ top: mainScrollElem.scrollHeight }),
- 1
+ delay
);
}
- };
+ },
+ 80
+);
+
+export default function ChatScreen() {
+ const {
+ viewingChat,
+ sendMessage,
+ isGenerating,
+ stopGenerating,
+ pendingMessages,
+ canvasData,
+ replaceMessageAndGenerate,
+ } = useAppContext();
+ const [inputMsg, setInputMsg] = useState('');
+
+ // keep track of leaf node for rendering
+ const [currNodeId, setCurrNodeId] = useState<number>(-1);
+ const messages: MessageDisplay[] = useMemo(() => {
+ if (!viewingChat) return [];
+ else return getListMessageDisplay(viewingChat.messages, currNodeId);
+ }, [currNodeId, viewingChat]);
+
+ const currConvId = viewingChat?.conv.id ?? null;
+ const pendingMsg: PendingMessage | undefined =
+ pendingMessages[currConvId ?? ''];
- // scroll to bottom when conversation changes
useEffect(() => {
- scrollToBottom(false);
- }, [viewingConversation?.id]);
+ // reset to latest node when conversation changes
+ setCurrNodeId(-1);
+ // scroll to bottom when conversation changes
+ scrollToBottom(false, 1);
+ }, [currConvId]);
+
+ const onChunk: CallbackGeneratedChunk = (currLeafNodeId?: Message['id']) => {
+ if (currLeafNodeId) {
+ setCurrNodeId(currLeafNodeId);
+ }
+ scrollToBottom(true);
+ };
const sendNewMessage = async () => {
- if (inputMsg.trim().length === 0 || isGenerating(currConvId)) return;
- const convId = viewingConversation?.id ?? StorageUtils.getNewConvId();
+ if (inputMsg.trim().length === 0 || isGenerating(currConvId ?? '')) return;
const lastInpMsg = inputMsg;
setInputMsg('');
- if (!viewingConversation) {
- // if user is creating a new conversation, redirect to the new conversation
- navigate(`/chat/${convId}`);
- }
scrollToBottom(false);
- // auto scroll as message is being generated
- const onChunk = () => scrollToBottom(true);
- if (!(await sendMessage(convId, inputMsg, onChunk))) {
+ setCurrNodeId(-1);
+ // get the last message node
+ const lastMsgNodeId = messages.at(-1)?.msg.id ?? null;
+ if (!(await sendMessage(currConvId, lastMsgNodeId, inputMsg, onChunk))) {
// restore the input message if failed
setInputMsg(lastInpMsg);
}
};
+ const handleEditMessage = async (msg: Message, content: string) => {
+ if (!viewingChat) return;
+ setCurrNodeId(msg.id);
+ scrollToBottom(false);
+ await replaceMessageAndGenerate(
+ viewingChat.conv.id,
+ msg.parent,
+ content,
+ onChunk
+ );
+ setCurrNodeId(-1);
+ scrollToBottom(false);
+ };
+
+ const handleRegenerateMessage = async (msg: Message) => {
+ if (!viewingChat) return;
+ setCurrNodeId(msg.parent);
+ scrollToBottom(false);
+ await replaceMessageAndGenerate(
+ viewingChat.conv.id,
+ msg.parent,
+ null,
+ onChunk
+ );
+ setCurrNodeId(-1);
+ scrollToBottom(false);
+ };
+
const hasCanvas = !!canvasData;
+ // due to some timing issues of StorageUtils.appendMsg(), we need to make sure the pendingMsg is not duplicated upon rendering (i.e. appears once in the saved conversation and once in the pendingMsg)
+ const pendingMsgDisplay: MessageDisplay[] =
+ pendingMsg && messages.at(-1)?.msg.id !== pendingMsg.id
+ ? [
+ {
+ msg: pendingMsg,
+ siblingLeafNodeIds: [],
+ siblingCurrIdx: 0,
+ isPending: true,
+ },
+ ]
+ : [];
+
return (
<div
className={classNames({
<div id="messages-list" className="grow">
<div className="mt-auto flex justify-center">
{/* placeholder to shift the message to the bottom */}
- {viewingConversation ? '' : 'Send a message to start'}
+ {viewingChat ? '' : 'Send a message to start'}
</div>
- {viewingConversation?.messages.map((msg) => (
+ {[...messages, ...pendingMsgDisplay].map((msg) => (
<ChatMessage
- key={msg.id}
- msg={msg}
- scrollToBottom={scrollToBottom}
+ key={msg.msg.id}
+ msg={msg.msg}
+ siblingLeafNodeIds={msg.siblingLeafNodeIds}
+ siblingCurrIdx={msg.siblingCurrIdx}
+ onRegenerateMessage={handleRegenerateMessage}
+ onEditMessage={handleEditMessage}
+ onChangeSibling={setCurrNodeId}
/>
))}
-
- {pendingMsg && (
- <ChatMessage
- msg={pendingMsg}
- scrollToBottom={scrollToBottom}
- isPending
- id="pending-msg"
- />
- )}
</div>
{/* chat input */}
id="msg-input"
dir="auto"
></textarea>
- {isGenerating(currConvId) ? (
+ {isGenerating(currConvId ?? '') ? (
<button
className="btn btn-neutral ml-2"
- onClick={() => stopGenerating(currConvId)}
+ onClick={() => stopGenerating(currConvId ?? '')}
>
Stop
</button>
);
}, [selectedTheme]);
- const { isGenerating, viewingConversation } = useAppContext();
- const isCurrConvGenerating = isGenerating(viewingConversation?.id ?? '');
+ const { isGenerating, viewingChat } = useAppContext();
+ const isCurrConvGenerating = isGenerating(viewingChat?.conv.id ?? '');
const removeConversation = () => {
- if (isCurrConvGenerating || !viewingConversation) return;
- const convId = viewingConversation.id;
+ if (isCurrConvGenerating || !viewingChat) return;
+ const convId = viewingChat?.conv.id;
if (window.confirm('Are you sure to delete this conversation?')) {
StorageUtils.remove(convId);
navigate('/');
};
const downloadConversation = () => {
- if (isCurrConvGenerating || !viewingConversation) return;
- const convId = viewingConversation.id;
- const conversationJson = JSON.stringify(viewingConversation, null, 2);
+ if (isCurrConvGenerating || !viewingChat) return;
+ const convId = viewingChat?.conv.id;
+ const conversationJson = JSON.stringify(viewingChat, null, 2);
const blob = new Blob([conversationJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
{/* action buttons (top right) */}
<div className="flex items-center">
- <div v-if="messages.length > 0" className="dropdown dropdown-end">
- {/* "..." button */}
- <button
- tabIndex={0}
- role="button"
- className="btn m-1"
- disabled={isCurrConvGenerating}
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- width="16"
- height="16"
- fill="currentColor"
- className="bi bi-three-dots-vertical"
- viewBox="0 0 16 16"
+ {viewingChat && (
+ <div className="dropdown dropdown-end">
+ {/* "..." button */}
+ <button
+ tabIndex={0}
+ role="button"
+ className="btn m-1"
+ disabled={isCurrConvGenerating}
>
- <path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0" />
- </svg>
- </button>
- {/* dropdown menu */}
- <ul
- tabIndex={0}
- className="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
- >
- <li onClick={downloadConversation}>
- <a>Download</a>
- </li>
- <li className="text-error" onClick={removeConversation}>
- <a>Delete</a>
- </li>
- </ul>
- </div>
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="16"
+ height="16"
+ fill="currentColor"
+ className="bi bi-three-dots-vertical"
+ viewBox="0 0 16 16"
+ >
+ <path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0" />
+ </svg>
+ </button>
+ {/* dropdown menu */}
+ <ul
+ tabIndex={0}
+ className="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
+ >
+ <li onClick={downloadConversation}>
+ <a>Download</a>
+ </li>
+ <li className="text-error" onClick={removeConversation}>
+ <a>Delete</a>
+ </li>
+ </ul>
+ </div>
+ )}
+
<div className="tooltip tooltip-bottom" data-tip="Settings">
<button className="btn" onClick={() => setShowSettings(true)}>
{/* settings button */}
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useState } from 'react';
import { classNames } from '../utils/misc';
import { Conversation } from '../utils/types';
import StorageUtils from '../utils/storage';
export default function Sidebar() {
const params = useParams();
const navigate = useNavigate();
- const currConv = useMemo(
- () => StorageUtils.getOneConversation(params.convId ?? ''),
- [params.convId]
- );
const [conversations, setConversations] = useState<Conversation[]>([]);
+ const [currConv, setCurrConv] = useState<Conversation | null>(null);
+
+ useEffect(() => {
+ StorageUtils.getOneConversation(params.convId ?? '').then(setCurrConv);
+ }, [params.convId]);
useEffect(() => {
- const handleConversationChange = () => {
- setConversations(StorageUtils.getAllConversations());
+ const handleConversationChange = async () => {
+ setConversations(await StorageUtils.getAllConversations());
};
StorageUtils.onConversationChanged(handleConversationChange);
handleConversationChange();
onClick={() => navigate(`/chat/${conv.id}`)}
dir="auto"
>
- <span className="truncate">{conv.messages[0].content}</span>
+ <span className="truncate">{conv.name}</span>
</div>
))}
<div className="text-center text-xs opacity-40 mt-auto mx-4">
- Conversations are saved to browser's localStorage
+ Conversations are saved to browser's IndexedDB
</div>
</div>
</div>
Conversation,
Message,
PendingMessage,
+ ViewingChat,
} from './types';
import StorageUtils from './storage';
import {
getSSEStreamAsync,
} from './misc';
import { BASE_URL, CONFIG_DEFAULT, isDev } from '../Config';
-import { matchPath, useLocation } from 'react-router';
+import { matchPath, useLocation, useNavigate } from 'react-router';
interface AppContextValue {
// conversations and messages
- viewingConversation: Conversation | null;
+ viewingChat: ViewingChat | null;
pendingMessages: Record<Conversation['id'], PendingMessage>;
isGenerating: (convId: string) => boolean;
sendMessage: (
- convId: string,
+ convId: string | null,
+ leafNodeId: Message['id'] | null,
content: string,
- onChunk?: CallbackGeneratedChunk
+ onChunk: CallbackGeneratedChunk
) => Promise<boolean>;
stopGenerating: (convId: string) => void;
replaceMessageAndGenerate: (
convId: string,
- origMsgId: Message['id'],
- content?: string,
- onChunk?: CallbackGeneratedChunk
+ parentNodeId: Message['id'], // the parent node of the message to be replaced
+ content: string | null,
+ onChunk: CallbackGeneratedChunk
) => Promise<void>;
// canvas
setShowSettings: (show: boolean) => void;
}
-// for now, this callback is only used for scrolling to the bottom of the chat
-type CallbackGeneratedChunk = () => void;
+// this callback is used for scrolling to the bottom of the chat and switching to the last node
+export type CallbackGeneratedChunk = (currLeafNodeId?: Message['id']) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const AppContext = createContext<AppContextValue>({} as any);
+const getViewingChat = async (convId: string): Promise<ViewingChat | null> => {
+ const conv = await StorageUtils.getOneConversation(convId);
+ if (!conv) return null;
+ return {
+ conv: conv,
+ // all messages from all branches, not filtered by last node
+ messages: await StorageUtils.getMessages(convId),
+ };
+};
+
export const AppContextProvider = ({
children,
}: {
children: React.ReactElement;
}) => {
const { pathname } = useLocation();
+ const navigate = useNavigate();
const params = matchPath('/chat/:convId', pathname);
const convId = params?.params?.convId;
- const [viewingConversation, setViewingConversation] =
- useState<Conversation | null>(null);
+ const [viewingChat, setViewingChat] = useState<ViewingChat | null>(null);
const [pendingMessages, setPendingMessages] = useState<
Record<Conversation['id'], PendingMessage>
>({});
useEffect(() => {
// also reset the canvas data
setCanvasData(null);
- const handleConversationChange = (changedConvId: string) => {
+ const handleConversationChange = async (changedConvId: string) => {
if (changedConvId !== convId) return;
- setViewingConversation(StorageUtils.getOneConversation(convId));
+ setViewingChat(await getViewingChat(changedConvId));
};
StorageUtils.onConversationChanged(handleConversationChange);
- setViewingConversation(StorageUtils.getOneConversation(convId ?? ''));
+ getViewingChat(convId ?? '').then(setViewingChat);
return () => {
StorageUtils.offConversationChanged(handleConversationChange);
};
const generateMessage = async (
convId: string,
- onChunk?: CallbackGeneratedChunk
+ leafNodeId: Message['id'],
+ onChunk: CallbackGeneratedChunk
) => {
if (isGenerating(convId)) return;
const config = StorageUtils.getConfig();
- const currConversation = StorageUtils.getOneConversation(convId);
+ const currConversation = await StorageUtils.getOneConversation(convId);
if (!currConversation) {
throw new Error('Current conversation is not found');
}
+ const currMessages = StorageUtils.filterByLeafNodeId(
+ await StorageUtils.getMessages(convId),
+ leafNodeId,
+ false
+ );
const abortController = new AbortController();
setAbort(convId, abortController);
+ if (!currMessages) {
+ throw new Error('Current messages are not found');
+ }
+
+ const pendingId = Date.now() + 1;
let pendingMsg: PendingMessage = {
- id: Date.now() + 1,
+ id: pendingId,
+ convId,
+ type: 'text',
+ timestamp: pendingId,
role: 'assistant',
content: null,
+ parent: leafNodeId,
+ children: [],
};
setPending(convId, pendingMsg);
...(config.systemMessage.length === 0
? []
: [{ role: 'system', content: config.systemMessage } as APIMessage]),
- ...normalizeMsgsForAPI(currConversation?.messages ?? []),
+ ...normalizeMsgsForAPI(currMessages),
];
if (config.excludeThoughtOnReq) {
messages = filterThoughtFromMsgs(messages);
const lastContent = pendingMsg.content || '';
if (addedContent) {
pendingMsg = {
- id: pendingMsg.id,
- role: 'assistant',
+ ...pendingMsg,
content: lastContent + addedContent,
};
}
};
}
setPending(convId, pendingMsg);
- onChunk?.();
+ onChunk(); // don't need to switch node for pending message
}
} catch (err) {
setPending(convId, null);
}
}
- if (pendingMsg.content) {
- StorageUtils.appendMsg(currConversation.id, {
- id: pendingMsg.id,
- content: pendingMsg.content,
- role: pendingMsg.role,
- timings: pendingMsg.timings,
- });
+ if (pendingMsg.content !== null) {
+ await StorageUtils.appendMsg(pendingMsg as Message, leafNodeId);
}
setPending(convId, null);
- onChunk?.(); // trigger scroll to bottom
+ onChunk(pendingId); // trigger scroll to bottom and switch to the last node
};
const sendMessage = async (
- convId: string,
+ convId: string | null,
+ leafNodeId: Message['id'] | null,
content: string,
- onChunk?: CallbackGeneratedChunk
+ onChunk: CallbackGeneratedChunk
): Promise<boolean> => {
- if (isGenerating(convId) || content.trim().length === 0) return false;
+ if (isGenerating(convId ?? '') || content.trim().length === 0) return false;
- StorageUtils.appendMsg(convId, {
- id: Date.now(),
- role: 'user',
- content,
- });
+ if (convId === null || convId.length === 0 || leafNodeId === null) {
+ const conv = await StorageUtils.createConversation(
+ content.substring(0, 256)
+ );
+ convId = conv.id;
+ leafNodeId = conv.currNode;
+ // if user is creating a new conversation, redirect to the new conversation
+ navigate(`/chat/${convId}`);
+ }
+
+ const now = Date.now();
+ const currMsgId = now;
+ StorageUtils.appendMsg(
+ {
+ id: currMsgId,
+ timestamp: now,
+ type: 'text',
+ convId,
+ role: 'user',
+ content,
+ parent: leafNodeId,
+ children: [],
+ },
+ leafNodeId
+ );
+ onChunk(currMsgId);
try {
- await generateMessage(convId, onChunk);
+ await generateMessage(convId, currMsgId, onChunk);
return true;
} catch (_) {
- // rollback
- StorageUtils.popMsg(convId);
+ // TODO: rollback
}
return false;
};
// if content is undefined, we remove last assistant message
const replaceMessageAndGenerate = async (
convId: string,
- origMsgId: Message['id'],
- content?: string,
- onChunk?: CallbackGeneratedChunk
+ parentNodeId: Message['id'], // the parent node of the message to be replaced
+ content: string | null,
+ onChunk: CallbackGeneratedChunk
) => {
if (isGenerating(convId)) return;
- StorageUtils.filterAndKeepMsgs(convId, (msg) => msg.id < origMsgId);
- if (content) {
- StorageUtils.appendMsg(convId, {
- id: Date.now(),
- role: 'user',
- content,
- });
+ if (content !== null) {
+ const now = Date.now();
+ const currMsgId = now;
+ StorageUtils.appendMsg(
+ {
+ id: currMsgId,
+ timestamp: now,
+ type: 'text',
+ convId,
+ role: 'user',
+ content,
+ parent: parentNodeId,
+ children: [],
+ },
+ parentNodeId
+ );
+ parentNodeId = currMsgId;
}
+ onChunk(parentNodeId);
- await generateMessage(convId, onChunk);
+ await generateMessage(convId, parentNodeId, onChunk);
};
const saveConfig = (config: typeof CONFIG_DEFAULT) => {
<AppContext.Provider
value={{
isGenerating,
- viewingConversation,
+ viewingChat,
pendingMessages,
sendMessage,
stopGenerating,
// ponyfill for missing ReadableStream asyncIterator on Safari
import { asyncIterator } from '@sec-ant/readable-stream/ponyfill/asyncIterator';
-import { isDev } from '../Config';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isString = (x: any) => !!x.toLowerCase;
.pipeThrough(new TextLineStream());
// @ts-expect-error asyncIterator complains about type, but it should work
for await (const line of asyncIterator(lines)) {
- if (isDev) console.log({ line });
+ //if (isDev) console.log({ line });
if (line.startsWith('data:') && !line.endsWith('[DONE]')) {
const data = JSON.parse(line.slice(5));
yield data;
/**
* filter out redundant fields upon sending to API
*/
-export function normalizeMsgsForAPI(messages: Message[]) {
+export function normalizeMsgsForAPI(messages: Readonly<Message[]>) {
return messages.map((msg) => {
return {
role: msg.role,
export const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
+
+export const throttle = <T extends unknown[]>(
+ callback: (...args: T) => void,
+ delay: number
+) => {
+ let isWaiting = false;
+
+ return (...args: T) => {
+ if (isWaiting) {
+ return;
+ }
+
+ callback(...args);
+ isWaiting = true;
+
+ setTimeout(() => {
+ isWaiting = false;
+ }, delay);
+ };
+};
// format: { [convId]: { id: string, lastModified: number, messages: [...] } }
import { CONFIG_DEFAULT } from '../Config';
-import { Conversation, Message } from './types';
+import { Conversation, Message, TimingReport } from './types';
+import Dexie, { Table } from 'dexie';
const event = new EventTarget();
);
};
+const db = new Dexie('LlamacppWebui') as Dexie & {
+ conversations: Table<Conversation>;
+ messages: Table<Message>;
+};
+
+// https://dexie.org/docs/Version/Version.stores()
+db.version(1).stores({
+ // Unlike SQL, you donβt need to specify all properties but only the one you wish to index.
+ conversations: '&id, lastModified',
+ messages: '&id, convId, [convId+id], timestamp',
+});
+
// convId is a string prefixed with 'conv-'
const StorageUtils = {
/**
* manage conversations
*/
- getAllConversations(): Conversation[] {
- const res = [];
- for (const key in localStorage) {
- if (key.startsWith('conv-')) {
- res.push(JSON.parse(localStorage.getItem(key) ?? '{}'));
- }
- }
- res.sort((a, b) => b.lastModified - a.lastModified);
- return res;
+ async getAllConversations(): Promise<Conversation[]> {
+ await migrationLStoIDB().catch(console.error); // noop if already migrated
+ return (await db.conversations.toArray()).sort(
+ (a, b) => b.lastModified - a.lastModified
+ );
},
/**
* can return null if convId does not exist
*/
- getOneConversation(convId: string): Conversation | null {
- return JSON.parse(localStorage.getItem(convId) || 'null');
+ async getOneConversation(convId: string): Promise<Conversation | null> {
+ return (await db.conversations.where('id').equals(convId).first()) ?? null;
},
/**
- * if convId does not exist, create one
+ * get all message nodes in a conversation
*/
- appendMsg(convId: string, msg: Message): void {
- if (msg.content === null) return;
- const conv = StorageUtils.getOneConversation(convId) || {
- id: convId,
- lastModified: Date.now(),
- messages: [],
- };
- conv.messages.push(msg);
- conv.lastModified = Date.now();
- localStorage.setItem(convId, JSON.stringify(conv));
- dispatchConversationChange(convId);
+ async getMessages(convId: string): Promise<Message[]> {
+ return await db.messages.where({ convId }).toArray();
},
/**
- * Get new conversation id
+ * use in conjunction with getMessages to filter messages by leafNodeId
+ * includeRoot: whether to include the root node in the result
+ * if node with leafNodeId does not exist, return the path with the latest timestamp
*/
- getNewConvId(): string {
- return `conv-${Date.now()}`;
+ filterByLeafNodeId(
+ msgs: Readonly<Message[]>,
+ leafNodeId: Message['id'],
+ includeRoot: boolean
+ ): Readonly<Message[]> {
+ const res: Message[] = [];
+ const nodeMap = new Map<Message['id'], Message>();
+ for (const msg of msgs) {
+ nodeMap.set(msg.id, msg);
+ }
+ let startNode: Message | undefined = nodeMap.get(leafNodeId);
+ if (!startNode) {
+ // if not found, we return the path with the latest timestamp
+ let latestTime = -1;
+ for (const msg of msgs) {
+ if (msg.timestamp > latestTime) {
+ startNode = msg;
+ latestTime = msg.timestamp;
+ }
+ }
+ }
+ // traverse the path from leafNodeId to root
+ // startNode can never be undefined here
+ let currNode: Message | undefined = startNode;
+ while (currNode) {
+ if (currNode.type !== 'root' || (currNode.type === 'root' && includeRoot))
+ res.push(currNode);
+ currNode = nodeMap.get(currNode.parent ?? -1);
+ }
+ res.sort((a, b) => a.timestamp - b.timestamp);
+ return res;
},
/**
- * remove conversation by id
+ * create a new conversation with a default root node
*/
- remove(convId: string): void {
- localStorage.removeItem(convId);
- dispatchConversationChange(convId);
+ async createConversation(name: string): Promise<Conversation> {
+ const now = Date.now();
+ const msgId = now;
+ const conv: Conversation = {
+ id: `conv-${now}`,
+ lastModified: now,
+ currNode: msgId,
+ name,
+ };
+ await db.conversations.add(conv);
+ // create a root node
+ await db.messages.add({
+ id: msgId,
+ convId: conv.id,
+ type: 'root',
+ timestamp: now,
+ role: 'system',
+ content: '',
+ parent: -1,
+ children: [],
+ });
+ return conv;
},
/**
- * remove all conversations
+ * if convId does not exist, throw an error
*/
- filterAndKeepMsgs(
- convId: string,
- predicate: (msg: Message) => boolean
- ): void {
- const conv = StorageUtils.getOneConversation(convId);
- if (!conv) return;
- conv.messages = conv.messages.filter(predicate);
- conv.lastModified = Date.now();
- localStorage.setItem(convId, JSON.stringify(conv));
+ async appendMsg(
+ msg: Exclude<Message, 'parent' | 'children'>,
+ parentNodeId: Message['id']
+ ): Promise<void> {
+ if (msg.content === null) return;
+ const { convId } = msg;
+ await db.transaction('rw', db.conversations, db.messages, async () => {
+ const conv = await StorageUtils.getOneConversation(convId);
+ const parentMsg = await db.messages
+ .where({ convId, id: parentNodeId })
+ .first();
+ // update the currNode of conversation
+ if (!conv) {
+ throw new Error(`Conversation ${convId} does not exist`);
+ }
+ if (!parentMsg) {
+ throw new Error(
+ `Parent message ID ${parentNodeId} does not exist in conversation ${convId}`
+ );
+ }
+ await db.conversations.update(convId, {
+ lastModified: Date.now(),
+ currNode: msg.id,
+ });
+ // update parent
+ await db.messages.update(parentNodeId, {
+ children: [...parentMsg.children, msg.id],
+ });
+ // create message
+ await db.messages.add({
+ ...msg,
+ parent: parentNodeId,
+ children: [],
+ });
+ });
dispatchConversationChange(convId);
},
/**
- * remove last message from conversation
+ * remove conversation by id
*/
- popMsg(convId: string): Message | undefined {
- const conv = StorageUtils.getOneConversation(convId);
- if (!conv) return;
- const msg = conv.messages.pop();
- conv.lastModified = Date.now();
- if (conv.messages.length === 0) {
- StorageUtils.remove(convId);
- } else {
- localStorage.setItem(convId, JSON.stringify(conv));
- }
+ async remove(convId: string): Promise<void> {
+ await db.transaction('rw', db.conversations, db.messages, async () => {
+ await db.conversations.delete(convId);
+ await db.messages.where({ convId }).delete();
+ });
dispatchConversationChange(convId);
- return msg;
},
// event listeners
};
export default StorageUtils;
+
+// Migration from localStorage to IndexedDB
+
+// these are old types, LS prefix stands for LocalStorage
+interface LSConversation {
+ id: string; // format: `conv-{timestamp}`
+ lastModified: number; // timestamp from Date.now()
+ messages: LSMessage[];
+}
+interface LSMessage {
+ id: number;
+ role: 'user' | 'assistant' | 'system';
+ content: string;
+ timings?: TimingReport;
+}
+async function migrationLStoIDB() {
+ if (localStorage.getItem('migratedToIDB')) return;
+ const res: LSConversation[] = [];
+ for (const key in localStorage) {
+ if (key.startsWith('conv-')) {
+ res.push(JSON.parse(localStorage.getItem(key) ?? '{}'));
+ }
+ }
+ if (res.length === 0) return;
+ await db.transaction('rw', db.conversations, db.messages, async () => {
+ let migratedCount = 0;
+ for (const conv of res) {
+ const { id: convId, lastModified, messages } = conv;
+ const firstMsg = messages[0];
+ const lastMsg = messages.at(-1);
+ if (messages.length < 2 || !firstMsg || !lastMsg) {
+ console.log(
+ `Skipping conversation ${convId} with ${messages.length} messages`
+ );
+ continue;
+ }
+ const name = firstMsg.content ?? '(no messages)';
+ await db.conversations.add({
+ id: convId,
+ lastModified,
+ currNode: lastMsg.id,
+ name,
+ });
+ const rootId = messages[0].id - 2;
+ await db.messages.add({
+ id: rootId,
+ convId: convId,
+ type: 'root',
+ timestamp: rootId,
+ role: 'system',
+ content: '',
+ parent: -1,
+ children: [firstMsg.id],
+ });
+ for (let i = 0; i < messages.length; i++) {
+ const msg = messages[i];
+ await db.messages.add({
+ ...msg,
+ type: 'text',
+ convId: convId,
+ timestamp: msg.id,
+ parent: i === 0 ? rootId : messages[i - 1].id,
+ children: i === messages.length - 1 ? [] : [messages[i + 1].id],
+ });
+ }
+ migratedCount++;
+ console.log(
+ `Migrated conversation ${convId} with ${messages.length} messages`
+ );
+ }
+ console.log(
+ `Migrated ${migratedCount} conversations from localStorage to IndexedDB`
+ );
+ localStorage.setItem('migratedToIDB', '1');
+ });
+}
predicted_ms: number;
}
+/**
+ * What is conversation "branching"? It is a feature that allows the user to edit an old message in the history, while still keeping the conversation flow.
+ * Inspired by ChatGPT / Claude / Hugging Chat where you edit a message, a new branch of the conversation is created, and the old message is still visible.
+ *
+ * We use the same node-based structure like other chat UIs, where each message has a parent and children. A "root" message is the first message in a conversation, which will not be displayed in the UI.
+ *
+ * root
+ * βββ message 1
+ * β βββ message 2
+ * β βββ message 3
+ * βββ message 4
+ * βββ message 5
+ *
+ * In the above example, assuming that user wants to edit message 2, a new branch will be created:
+ *
+ * βββ message 2
+ * β βββ message 3
+ * βββ message 6
+ *
+ * Message 2 and 6 are siblings, and message 6 is the new branch.
+ *
+ * We only need to know the last node (aka leaf) to get the current branch. In the above example, message 5 is the leaf of branch containing message 4 and 5.
+ *
+ * For the implementation:
+ * - StorageUtils.getMessages() returns list of all nodes
+ * - StorageUtils.filterByLeafNodeId() filters the list of nodes from a given leaf node
+ */
+
+// Note: the term "message" and "node" are used interchangeably in this context
export interface Message {
id: number;
+ convId: string;
+ type: 'text' | 'root';
+ timestamp: number; // timestamp from Date.now()
role: 'user' | 'assistant' | 'system';
content: string;
timings?: TimingReport;
+ // node based system for branching
+ parent: Message['id'];
+ children: Message['id'][];
}
export type APIMessage = Pick<Message, 'role' | 'content'>;
export interface Conversation {
id: string; // format: `conv-{timestamp}`
lastModified: number; // timestamp from Date.now()
- messages: Message[];
+ currNode: Message['id']; // the current message node being viewed
+ name: string;
+}
+
+export interface ViewingChat {
+ conv: Readonly<Conversation>;
+ messages: Readonly<Message[]>;
}
export type PendingMessage = Omit<Message, 'content'> & {