diff --git a/examples/server/webui/package-lock.json b/examples/server/webui/package-lock.json index c6c5de3c0..056592dd4 100644 --- a/examples/server/webui/package-lock.json +++ b/examples/server/webui/package-lock.json @@ -13,6 +13,7 @@ "@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", @@ -2338,6 +2339,12 @@ "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", diff --git a/examples/server/webui/package.json b/examples/server/webui/package.json index 3be2b14de..8c833d024 100644 --- a/examples/server/webui/package.json +++ b/examples/server/webui/package.json @@ -16,6 +16,7 @@ "@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", diff --git a/examples/server/webui/src/App.tsx b/examples/server/webui/src/App.tsx index 2ce734682..3e90ec55c 100644 --- a/examples/server/webui/src/App.tsx +++ b/examples/server/webui/src/App.tsx @@ -1,4 +1,4 @@ -import { HashRouter, Outlet, Route, Routes } from 'react-router'; +import { HashRouter, Outlet, Route, Routes, Navigate } from 'react-router'; import Header from './components/Header'; import Sidebar from './components/Sidebar'; import { AppContextProvider, useAppContext } from './utils/app.context'; diff --git a/examples/server/webui/src/components/ChatMessage.tsx b/examples/server/webui/src/components/ChatMessage.tsx index ec72196ba..9c6c558f4 100644 --- a/examples/server/webui/src/components/ChatMessage.tsx +++ b/examples/server/webui/src/components/ChatMessage.tsx @@ -3,6 +3,7 @@ import { useAppContext } from '../utils/app.context'; 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']; @@ -12,17 +13,24 @@ interface SplitMessage { export default function ChatMessage({ msg, + siblingLastNodeIds, + siblingCurrIdx, id, - scrollToBottom, + onRegenerateMessage, + onEditMessage, + onChangeSibling, isPending, }: { msg: Message | PendingMessage; + siblingLastNodeIds: 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(null); const timings = useMemo( () => @@ -37,6 +45,8 @@ export default function ChatMessage({ : null, [msg.timings] ); + const nextSibling = siblingLastNodeIds[siblingCurrIdx + 1]; + const prevSibling = siblingLastNodeIds[siblingCurrIdx - 1]; // for reasoning model, we split the message into content and thought // TODO: implement this as remark/rehype plugin in the future @@ -64,13 +74,7 @@ export default function ChatMessage({ 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 (
@@ -105,13 +109,12 @@ export default function ChatMessage({ @@ -196,10 +199,35 @@ export default function ChatMessage({ {msg.content !== null && (
+ {siblingLastNodeIds && siblingLastNodeIds.length > 1 && ( +
+ + + {siblingCurrIdx + 1} / {siblingLastNodeIds.length} + + +
+ )} {/* user message */} {msg.role === 'user' && ( diff --git a/examples/server/webui/src/components/Header.tsx b/examples/server/webui/src/components/Header.tsx index 505350313..cbee394ba 100644 --- a/examples/server/webui/src/components/Header.tsx +++ b/examples/server/webui/src/components/Header.tsx @@ -25,12 +25,12 @@ export default function Header() { ); }, [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('/'); @@ -38,9 +38,9 @@ export default function Header() { }; 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'); @@ -75,38 +75,41 @@ export default function Header() { {/* action buttons (top right) */}
-
- {/* "..." button */} - - {/* dropdown menu */} - -
+ + + + + {/* dropdown menu */} + +
+ )} +
))}
- Conversations are saved to browser's localStorage + Conversations are saved to browser's IndexedDB
diff --git a/examples/server/webui/src/utils/app.context.tsx b/examples/server/webui/src/utils/app.context.tsx index af6bd885f..12306d258 100644 --- a/examples/server/webui/src/utils/app.context.tsx +++ b/examples/server/webui/src/utils/app.context.tsx @@ -5,6 +5,7 @@ import { Conversation, Message, PendingMessage, + ViewingChat, } from './types'; import StorageUtils from './storage'; import { @@ -13,24 +14,25 @@ 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; isGenerating: (convId: string) => boolean; sendMessage: ( - convId: string, + convId: string | null, + lastNodeId: Message['id'] | null, content: string, - onChunk?: CallbackGeneratedChunk + onChunk: CallbackGeneratedChunk ) => Promise; 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; // canvas @@ -44,23 +46,33 @@ interface AppContextValue { 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 = () => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any const AppContext = createContext({} as any); +const getViewingChat = async (convId: string): Promise => { + 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(null); + const [viewingChat, setViewingChat] = useState(null); const [pendingMessages, setPendingMessages] = useState< Record >({}); @@ -75,12 +87,12 @@ export const AppContextProvider = ({ 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); }; @@ -118,23 +130,39 @@ export const AppContextProvider = ({ const generateMessage = async ( convId: string, - onChunk?: CallbackGeneratedChunk + lastNodeId: 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.filterByLastNodeId( + await StorageUtils.getMessages(convId), + lastNodeId, + 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: lastNodeId, + children: [], }; setPending(convId, pendingMsg); @@ -144,7 +172,7 @@ export const AppContextProvider = ({ ...(config.systemMessage.length === 0 ? [] : [{ role: 'system', content: config.systemMessage } as APIMessage]), - ...normalizeMsgsForAPI(currConversation?.messages ?? []), + ...normalizeMsgsForAPI(currMessages), ]; if (config.excludeThoughtOnReq) { messages = filterThoughtFromMsgs(messages); @@ -205,8 +233,7 @@ export const AppContextProvider = ({ const lastContent = pendingMsg.content || ''; if (addedContent) { pendingMsg = { - id: pendingMsg.id, - role: 'assistant', + ...pendingMsg, content: lastContent + addedContent, }; } @@ -221,7 +248,7 @@ export const AppContextProvider = ({ }; } setPending(convId, pendingMsg); - onChunk?.(); + onChunk(); // don't need to switch node for pending message } } catch (err) { setPending(convId, null); @@ -236,37 +263,53 @@ export const AppContextProvider = ({ } } - 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, lastNodeId); } setPending(convId, null); - onChunk?.(); // trigger scroll to bottom + onChunk(); // trigger scroll to bottom and switch to the last node }; const sendMessage = async ( - convId: string, + convId: string | null, + lastNodeId: Message['id'] | null, content: string, - onChunk?: CallbackGeneratedChunk + onChunk: CallbackGeneratedChunk ): Promise => { - 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 || lastNodeId === null) { + const conv = await StorageUtils.createConversation( + content.substring(0, 256) + ); + convId = conv.id; + lastNodeId = 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: lastNodeId, + children: [], + }, + lastNodeId + ); + onChunk(); try { - await generateMessage(convId, onChunk); + await generateMessage(convId, currMsgId, onChunk); return true; } catch (_) { - // rollback - StorageUtils.popMsg(convId); + // TODO: rollback } return false; }; @@ -279,22 +322,33 @@ export const AppContextProvider = ({ // 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; } - await generateMessage(convId, onChunk); + onChunk(); + await generateMessage(convId, parentNodeId, onChunk); }; const saveConfig = (config: typeof CONFIG_DEFAULT) => { @@ -306,7 +360,7 @@ export const AppContextProvider = ({ !!x.toLowerCase; @@ -23,7 +22,7 @@ export async function* getSSEStreamAsync(fetchResponse: Response) { .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; @@ -55,7 +54,7 @@ export const copyStr = (textToCopy: string) => { /** * filter out redundant fields upon sending to API */ -export function normalizeMsgsForAPI(messages: Message[]) { +export function normalizeMsgsForAPI(messages: Readonly) { return messages.map((msg) => { return { role: msg.role, @@ -88,3 +87,23 @@ export function classNames(classes: Record): string { export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const throttle = ( + callback: (...args: T) => void, + delay: number +) => { + let isWaiting = false; + + return (...args: T) => { + if (isWaiting) { + return; + } + + callback(...args); + isWaiting = true; + + setTimeout(() => { + isWaiting = false; + }, delay); + }; +}; diff --git a/examples/server/webui/src/utils/storage.ts b/examples/server/webui/src/utils/storage.ts index 8c03fa781..dca81e526 100644 --- a/examples/server/webui/src/utils/storage.ts +++ b/examples/server/webui/src/utils/storage.ts @@ -2,7 +2,8 @@ // 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(); @@ -17,85 +18,151 @@ const dispatchConversationChange = (convId: string) => { ); }; +const db = new Dexie('LlamacppWebui') as Dexie & { + conversations: Table; + messages: Table; +}; + +// 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 { + 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 { + return (await db.conversations.where('id').equals(convId).first()) ?? null; }, /** - * if convId does not exist, create one + * get messages by convId and timeline */ - appendMsg(convId: string, msg: Message): void { - if (msg.content === null) return; - const conv = StorageUtils.getOneConversation(convId) || { - id: convId, - lastModified: Date.now(), - messages: [], + async getMessages(convId: string): Promise { + return await db.messages.where({ convId }).toArray(); + }, + /** + * use in conjunction with getMessages to filter messages by lastNodeId + */ + filterByLastNodeId( + msgs: Readonly, + lastNodeId: Message['id'], + includeRoot: boolean + ): Readonly { + const res: Message[] = []; + const nodeMap = new Map(); + for (const msg of msgs) { + nodeMap.set(msg.id, msg); + } + let startNode: Message | undefined = nodeMap.get(lastNodeId); + 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 lastNodeId 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; + }, + /** + * create a new conversation with a default timeline number 0 + */ + async createConversation(name: string): Promise { + const now = Date.now(); + const msgId = now; + const conv: Conversation = { + id: `conv-${now}`, + lastModified: now, + currNode: msgId, + name, }; - conv.messages.push(msg); - conv.lastModified = Date.now(); - localStorage.setItem(convId, JSON.stringify(conv)); - dispatchConversationChange(convId); + 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; }, /** - * Get new conversation id + * if convId does not exist, throw an error */ - getNewConvId(): string { - return `conv-${Date.now()}`; + async appendMsg( + msg: Exclude, + parentNodeId: Message['id'] + ): Promise { + 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 conversation by id */ - remove(convId: string): void { - localStorage.removeItem(convId); + async remove(convId: string): Promise { + await db.transaction('rw', db.conversations, db.messages, async () => { + await db.conversations.delete(convId); + await db.messages.where({ convId }).delete(); + }); dispatchConversationChange(convId); }, - /** - * remove all conversations - */ - 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)); - dispatchConversationChange(convId); - }, - /** - * remove last message from conversation - */ - 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)); - } - dispatchConversationChange(convId); - return msg; - }, // event listeners onConversationChanged(callback: CallbackConversationChanged) { @@ -136,3 +203,78 @@ const StorageUtils = { }; 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() { + 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 () => { + 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 = Date.now(); + await db.messages.add({ + id: rootId, + convId: convId, + type: 'root', + timestamp: lastModified, + 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], + }); + } + localStorage.removeItem(convId); + console.log( + `Migrated conversation ${convId} with ${messages.length} messages` + ); + } + console.log( + `Migrated ${res.length} conversations from localStorage to IndexedDB` + ); + }); +} + +migrationLStoIDB(); diff --git a/examples/server/webui/src/utils/types.ts b/examples/server/webui/src/utils/types.ts index 7cd12b40a..cb8591d18 100644 --- a/examples/server/webui/src/utils/types.ts +++ b/examples/server/webui/src/utils/types.ts @@ -5,11 +5,24 @@ export interface TimingReport { 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 UI 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 as ChatGPT, 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. + */ + 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; @@ -17,7 +30,13 @@ export type APIMessage = Pick; 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; + messages: Readonly; } export type PendingMessage = Omit & {